diff --git a/src/components/organisms/Studio/Studio.tsx b/src/components/organisms/Studio/Studio.tsx index 5327972..78d883f 100644 --- a/src/components/organisms/Studio/Studio.tsx +++ b/src/components/organisms/Studio/Studio.tsx @@ -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'; @@ -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(() => { diff --git a/src/components/organisms/Studio/hooks/useChat.ts b/src/components/organisms/Studio/hooks/useChat.ts index 1b054c8..0b147cb 100644 --- a/src/components/organisms/Studio/hooks/useChat.ts +++ b/src/components/organisms/Studio/hooks/useChat.ts @@ -20,6 +20,7 @@ import { } from '@/lib/studio/chatLine'; import { createCanvasEdits } from '@/lib/studio/canvasEdits'; import { + clarifyAnswer, clarifyFromFrame, clarifyNote, clarifyReply, @@ -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, @@ -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 @@ -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, @@ -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; @@ -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], ); @@ -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], ); diff --git a/src/components/organisms/Studio/hooks/useConversationSave.ts b/src/components/organisms/Studio/hooks/useConversationSave.ts index d1590c7..769efa1 100644 --- a/src/components/organisms/Studio/hooks/useConversationSave.ts +++ b/src/components/organisms/Studio/hooks/useConversationSave.ts @@ -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(), }); diff --git a/src/components/organisms/Studio/workspace/ChatPanel.tsx b/src/components/organisms/Studio/workspace/ChatPanel.tsx index b86b8f7..4642b75 100644 --- a/src/components/organisms/Studio/workspace/ChatPanel.tsx +++ b/src/components/organisms/Studio/workspace/ChatPanel.tsx @@ -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'; @@ -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 (
{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 ( diff --git a/src/lib/chat/history.ts b/src/lib/chat/history.ts index c882603..349dac9 100644 --- a/src/lib/chat/history.ts +++ b/src/lib/chat/history.ts @@ -51,11 +51,19 @@ export type SavedConversation = { titles?: Record; // User notes on data cards (menu → Add Note), by widget id. notes?: Record; - // 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; +}; diff --git a/src/lib/studio/clarify.ts b/src/lib/studio/clarify.ts index 5510bab..05dd5d4 100644 --- a/src/lib/studio/clarify.ts +++ b/src/lib/studio/clarify.ts @@ -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; diff --git a/src/lib/studio/test/clarifyReply.test.ts b/src/lib/studio/test/clarifyReply.test.ts index 6f94aa3..4b3364b 100644 --- a/src/lib/studio/test/clarifyReply.test.ts +++ b/src/lib/studio/test/clarifyReply.test.ts @@ -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'; @@ -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 ' } }), diff --git a/src/lib/studio/test/transcript.test.ts b/src/lib/studio/test/transcript.test.ts new file mode 100644 index 0000000..fefd2a5 --- /dev/null +++ b/src/lib/studio/test/transcript.test.ts @@ -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); +}); diff --git a/src/lib/studio/transcript.ts b/src/lib/studio/transcript.ts new file mode 100644 index 0000000..4ae9682 --- /dev/null +++ b/src/lib/studio/transcript.ts @@ -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; +};