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;
+};