Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/organisms/Studio/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { stashGuestSession, takeGuestSession } from '@/lib/studio/guestStash';
import { logRefusedDescriptions } from '@/lib/studio/infoPanel';
import { takeSeeds } from '@/lib/studio/seedStore';
import { deriveTheme, themeVars } from '@/lib/studio/theme';
import { isPrompt } from '@/lib/studio/transcript';
import type { Seed } from '@/lib/studio/wire';
import { pushConversation } from '@/lib/chat/historyClient';
import { uuid } from '@/lib/uuid';
Expand Down Expand Up @@ -165,7 +166,7 @@ const Studio = ({
// removed): the count follows the user turns in the transcript, so the
// queue's own drain is covered too. Only growth counts — New Chat empties
// the transcript without giving prompts back.
const sentCount = conversation.messages.filter((message) => message.role === 'user').length;
const sentCount = conversation.messages.filter(isPrompt).length;
const sentRef = useRef(sentCount);

useEffect(() => {
Expand Down
32 changes: 23 additions & 9 deletions src/components/organisms/Studio/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@/lib/studio/chatLine';
import { createCanvasEdits } from '@/lib/studio/canvasEdits';
import {
clarifyAnswer,
clarifyFromFrame,
clarifyNote,
clarifyReply,
Expand All @@ -29,6 +30,7 @@ import {
} from '@/lib/studio/clarify';
import { restoreCanvas, takeManifest, type CanvasBaseline } from '@/lib/studio/composition';
import { plainTextFromNote } from '@/lib/studio/note';
import { withAnswer } from '@/lib/studio/transcript';
import {
cancelTurn,
fetchComponentRevision,
Expand All @@ -51,9 +53,11 @@ import { uuid } from '@/lib/uuid';
import type { CardRef, Widget } from '@/lib/chat/widgets';

// A user message sent from a card's "Ask follow up" keeps that card's title,
// so the transcript shows what the question was about.
// so the transcript shows what the question was about. `answer` marks the
// user's answer to a paused turn (a clarify choice or text, a plan
// amendment): shown like any user message, but part of the turn it answers.
export type ChatMessage =
| { role: 'user'; text: string; card?: { title: string } }
| { role: 'user'; text: string; card?: { title: string }; answer?: true }
| { role: 'bot'; text: string; followups?: string[] };

// A widget stamped with the send() turn it arrived in — the canvas keeps
Expand Down Expand Up @@ -849,18 +853,21 @@ export const useChat = (initial?: ChatSnapshot) => {
}, []);

// Continue a paused turn on /interrupt — shared by clarify answers, the
// skip button and the plan actions.
// skip button and the plan actions. `answer` is what the user chose or
// wrote, which enters the transcript.
const resume = useCallback(
(reply: ClarifyReply, note: string | null) => {
(reply: ClarifyReply, note: string | null, answer?: string) => {
const conversation = sessionRef.current;
const pending = pendingInterruptRef.current;

pendingInterruptRef.current = null;

setState((prev) => ({
// The decision shows on the status line, and its permanent
// record arrives with message.complete (the notes prefix).
// The decision shows on the status line; the user's answer
// also stays in the transcript, once (a repeated click finds
// no pause left to answer).
...withLine(prev, note ? plainTextFromNote(note) : 'Thinking'),
messages: answer && pending ? withAnswer(prev.messages, answer) : prev.messages,
thinking: true,
clarify: null,
plan: null,
Expand All @@ -879,7 +886,8 @@ export const useChat = (initial?: ChatSnapshot) => {

// Answer every question of the pending clarify at once, keyed by
// question id. The reply takes the frame's shape (`answers`, or a legacy
// frame's `payload`); the note names the chosen labels and typed text.
// frame's `payload`); the note and the transcript name the chosen labels
// and typed text.
const answerClarify = useCallback(
(answers: ClarifyAnswers) => {
const clarify = pendingInterruptRef.current?.clarify;
Expand All @@ -888,7 +896,9 @@ export const useChat = (initial?: ChatSnapshot) => {
return;
}

resume(clarifyReply(clarify, answers), `_${clarifyNote(clarify, answers)}_`);
const note = clarifyNote(clarify, answers);

resume(clarifyReply(clarify, answers), `_${note}_`, clarifyAnswer(clarify, answers));
},
[resume],
);
Expand Down Expand Up @@ -922,7 +932,11 @@ export const useChat = (initial?: ChatSnapshot) => {
? '_Plan rejected._'
: `_Change requested: ${payload ?? ''}_`;

resume({ decision, ...(decision === 'amend' ? { payload } : {}) }, note);
resume(
{ decision, ...(decision === 'amend' ? { payload } : {}) },
note,
decision === 'amend' ? payload : undefined,
);
},
[resume],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ export const useConversationSave = ({
if ('id' in answer && kind === 'conversation') {
addShareMark({
messageIndex: saved.messages.length,
userMessages: saved.messages.filter((message) => message.role === 'user')
.length,
shareId: answer.id,
at: Date.now(),
});
Expand Down
11 changes: 5 additions & 6 deletions src/components/organisms/Studio/workspace/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import PromptForm from '../prompt/PromptForm';
import Button from '@/components/atoms/Button/Button';
import Icon from '@/components/atoms/Icon/Icon';
import type { ShareMark } from '@/lib/chat/history';
import { isPrompt, shareMarkAfter } from '@/lib/studio/transcript';

export type ChatTab = 'chat' | 'activity';

Expand Down Expand Up @@ -110,7 +111,7 @@ const ChatPanel = ({

// A Chat + Canvas share point sits after message `index`?
const sharedAfter = (index: number) =>
!readOnly && shareMarks.some((mark) => mark.messageIndex === index + 1);
!readOnly && shareMarks.some((mark) => shareMarkAfter(chat.messages, mark) === index);

return (
<div
Expand All @@ -128,11 +129,9 @@ const ChatPanel = ({
<div ref={scrollRef} className={styles.chatScroll}>
{chat.messages.map((message, index) => {
// The turn this bot message answers = the number
// of user messages before it (send() numbers
// turns the same way).
const turn = chat.messages
.slice(0, index)
.filter((other) => other.role === 'user').length;
// of prompts before it (send() numbers turns the
// same way).
const turn = chat.messages.slice(0, index).filter(isPrompt).length;

return (
<Fragment key={index}>
Expand Down
18 changes: 13 additions & 5 deletions src/lib/chat/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ export type SavedConversation = {
titles?: Record<string, string>;
// User notes on data cards (menu → Add Note), by widget id.
notes?: Record<string, string>;
// Where the transcript was shared (Chat + Canvas): everything from
// `messageIndex` on is only in the owner's copy — the chat shows a
// "beyond this point" marker there. A share is a snapshot; nothing after
// it ever reaches the link.
// Where the transcript was shared (Chat + Canvas): what came after the
// share is only in the owner's copy — the chat shows a "beyond this
// point" marker there (see shareMarkAfter). A share is a snapshot;
// nothing after it ever reaches the link.
shareMarks?: ShareMark[];
};

export type ShareMark = { messageIndex: number; shareId: string; at: number };
export type ShareMark = {
messageIndex: number;
shareId: string;
at: number;
// User messages (prompts and answers) the share held — where the marker
// goes (see shareMarkAfter). Optional: marks from before this didn't
// have it.
userMessages?: number;
};
6 changes: 6 additions & 0 deletions src/lib/studio/clarify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,9 @@ export const clarifyNote = (clarify: Clarify, answers: ClarifyAnswers): string =

return chosen.length > 0 ? `You chose: ${chosen.join(', ')}` : 'Left to DFO';
};

/** The user's transcript text, omitted when every question was skipped. */
export const clarifyAnswer = (clarify: Clarify, answers: ClarifyAnswers): string | undefined =>
Object.values(answers).some((answer) => answer.kind !== 'skipped')
? clarifyNote(clarify, answers)
: undefined;
39 changes: 38 additions & 1 deletion src/lib/studio/test/clarifyReply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

import { clarifyFromFrame, clarifyNote, clarifyReply, type Clarify } from '../clarify.ts';
import {
clarifyAnswer,
clarifyFromFrame,
clarifyNote,
clarifyReply,
type Clarify,
} from '../clarify.ts';
import { recordAnswer, startStepper, stepperAnswers } from '../clarifyStepper.ts';
import type { ElicitationRequest, InterruptResponse } from '../wire.ts';

Expand Down Expand Up @@ -134,6 +140,37 @@ test('the note reads Left to DFO when every question was skipped', () => {
);
});

test('skipping every question sends the reply without a transcript answer', () => {
let stepper = startStepper();

stepper = recordAnswer(several, stepper, { kind: 'skipped' });
stepper = recordAnswer(several, stepper, { kind: 'skipped' });

const answers = stepperAnswers(several, stepper);

assert.ok(answers);
assert.deepEqual(clarifyReply(several, answers), { decision: 'answer', answers });
assert.equal(clarifyNote(several, answers), 'Left to DFO');
assert.equal(clarifyAnswer(several, answers), undefined);
});

test('a selected option among skipped questions keeps its transcript answer', () => {
assert.equal(
clarifyAnswer(several, {
q1: { kind: 'skipped' },
q2: { kind: 'option', option_id: 'gross' },
}),
'You chose: Gross disbursements',
);
});

test("typing the skip status text still records the user's words", () => {
assert.equal(
clarifyAnswer(single, { q1: { kind: 'text', text: 'Left to DFO' } }),
'Left to DFO',
);
});

test('the note for one question answered in own words is the text alone', () => {
assert.equal(
clarifyNote(single, { q1: { kind: 'text', text: ' grants only ' } }),
Expand Down
88 changes: 88 additions & 0 deletions src/lib/studio/test/transcript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import type { ChatMessage } from '../../../components/organisms/Studio/hooks/useChat.ts';
import type { ShareMark } from '../../chat/history.ts';
import { isPrompt, shareMarkAfter, withAnswer } from '../transcript.ts';

const paused: ChatMessage[] = [
{ role: 'user', text: 'Show me aid to Kenya' },
{ role: 'bot', text: '' },
];

test("an answer goes before the paused turn's reply, which stays last for the stream", () => {
assert.deepEqual(withAnswer(paused, 'Gross disbursements'), [
{ role: 'user', text: 'Show me aid to Kenya' },
{ role: 'user', text: 'Gross disbursements', answer: true },
{ role: 'bot', text: '' },
]);
});

test('a second pause in the same turn keeps the answers in order', () => {
const twice = withAnswer(withAnswer(paused, 'Gross disbursements'), 'Make it since 2018.');

assert.deepEqual(
twice.map((message) => message.text),
['Show me aid to Kenya', 'Gross disbursements', 'Make it since 2018.', ''],
);
});

test('the transcript it is given is left as it was', () => {
withAnswer(paused, 'Gross disbursements');

assert.equal(paused.length, 2);
});

test('answers do not number turns: the reply still belongs to the first turn', () => {
const messages = withAnswer(withAnswer(paused, 'Gross disbursements'), 'Since 2018.');

assert.equal(messages.slice(0, messages.length - 1).filter(isPrompt).length, 1);
});

test('only a turn-opening user message is a prompt', () => {
assert.equal(isPrompt({ role: 'user', text: 'Show me aid to Kenya' }), true);
assert.equal(isPrompt({ role: 'user', text: 'Gross disbursements', answer: true }), false);
assert.equal(isPrompt({ role: 'bot', text: 'According to the OECD…' }), false);
});

// The mark useConversationSave records for a share of `messages`.
const markFor = (messages: ChatMessage[]): ShareMark => ({
messageIndex: messages.length,
userMessages: messages.filter((message) => message.role === 'user').length,
shareId: 'share',
at: 0,
});

test('a share taken during a pause stays above the answer given after it', () => {
const mark = markFor(paused);
const answered = withAnswer(paused, 'You chose: Gross disbursements');

assert.equal(answered[shareMarkAfter(answered, mark)]?.text, 'Show me aid to Kenya');
});

test('a share of a re-offered plan whose reply has text stays above the next amendment', () => {
// A refused amendment re-offers the plan with the refusal in the reply.
const reoffered: ChatMessage[] = [
paused[0],
{ role: 'user', text: 'Make it since 2018.', answer: true },
{ role: 'bot', text: 'That change could not be made.' },
];
const mark = markFor(reoffered);
const amended = withAnswer(reoffered, 'Make it since 2019.');

assert.equal(amended[shareMarkAfter(amended, mark)]?.text, 'Make it since 2018.');
});

test('a share of a finished turn sits after its reply, before the next prompt', () => {
const finished: ChatMessage[] = [paused[0], { role: 'bot', text: 'According to the OECD…' }];
const mark = markFor(finished);

assert.equal(shareMarkAfter(finished, mark), 1);
assert.equal(shareMarkAfter([...finished, { role: 'user', text: 'And Ghana?' }], mark), 1);
});

test('a mark saved before userMessages keeps its message count', () => {
const mark: ShareMark = { messageIndex: 2, shareId: 'share', at: 0 };

assert.equal(shareMarkAfter(withAnswer(paused, 'Since 2018.'), mark), 1);
});
43 changes: 43 additions & 0 deletions src/lib/studio/transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ChatMessage } from '@/components/organisms/Studio/hooks/useChat';
import type { ShareMark } from '@/lib/chat/history';

/**
* A user message that opens a turn. A user's answer to a paused turn (a
* clarify choice, typed text, a plan amendment) continues that turn, so it
* neither numbers turns nor counts as a prompt sent.
*/
export const isPrompt = (message: ChatMessage): boolean =>
message.role === 'user' && !message.answer;

/**
* The transcript with the user's answer to a paused turn. It goes before the
* turn's reply, which the resumed stream goes on writing into.
*/
export const withAnswer = (messages: ChatMessage[], text: string): ChatMessage[] => {
const answer: ChatMessage = { role: 'user', text, answer: true };
const last = messages[messages.length - 1];

return last?.role === 'bot' ? [...messages.slice(0, -1), answer, last] : [...messages, answer];
};

/**
* The index of the message a Chat + Canvas share mark follows: the one
* before the first user message the share did not hold (an answer can go in
* before a paused turn's reply, so a message count would drift), else the
* last. Marks saved without `userMessages` keep their message count.
*/
export const shareMarkAfter = (messages: ChatMessage[], mark: ShareMark): number => {
if (mark.userMessages === undefined) {
return mark.messageIndex - 1;
}

let users = 0;

for (const [index, message] of messages.entries()) {
if (message.role === 'user' && users++ === mark.userMessages) {
return index - 1;
}
}

return messages.length - 1;
};
Loading