fix(ai-chat): WYSIWYG Copy chat export + first-turn export (#160, #174) #165

Merged
vvzvlad merged 2 commits from fix/ai-chat-copy-chat-wysiwyg into develop 2026-06-25 03:54:35 +03:00
4 changed files with 637 additions and 185 deletions

View File

@@ -80,17 +80,31 @@ function computeInitialGeom() {
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN), Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
); );
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24); const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN); const maxTop = Math.max(
EDGE_MARGIN,
window.innerHeight - height - EDGE_MARGIN,
);
const top = Math.min(60, maxTop); const top = Math.min(60, maxTop);
return { left, top, width, height }; return { left, top, width, height };
} }
// Clamp a geometry so the window stays within the current viewport. // Clamp a geometry so the window stays within the current viewport.
function clampGeom(g: { left: number; top: number; width: number; height: number }) { function clampGeom(g: {
left: number;
top: number;
width: number;
height: number;
}) {
const effWidth = Math.max(g.width, MIN_WIDTH); const effWidth = Math.max(g.width, MIN_WIDTH);
const effHeight = Math.max(g.height, MIN_HEIGHT); const effHeight = Math.max(g.height, MIN_HEIGHT);
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN); const maxLeft = Math.max(
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN); EDGE_MARGIN,
window.innerWidth - effWidth - EDGE_MARGIN,
);
const maxTop = Math.max(
EDGE_MARGIN,
window.innerHeight - effHeight - EDGE_MARGIN,
);
return { return {
...g, ...g,
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft), left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
@@ -151,9 +165,14 @@ export default function AiChatWindow() {
// Live snapshot of the active thread's useChat state, kept up to date by // Live snapshot of the active thread's useChat state, kept up to date by
// ChatThread. Lets the export include the in-progress (not-yet-persisted) // ChatThread. Lets the export include the in-progress (not-yet-persisted)
// streaming turn. A ref avoids re-rendering this window on every token. // streaming turn. A ref avoids re-rendering this window on every token.
const liveThreadRef = useRef<{ messages: UIMessage[]; isStreaming: boolean }>({ const liveThreadRef = useRef<{
messages: UIMessage[];
isStreaming: boolean;
banner: string | null;
}>({
messages: [], messages: [],
isStreaming: false, isStreaming: false,
banner: null,
}); });
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up // Live turn-token total (reasoning + output) for the in-flight turn, pushed up
@@ -161,6 +180,12 @@ export default function AiChatWindow() {
// `null` means no turn is in flight -> the badge falls back to the persisted // `null` means no turn is in flight -> the badge falls back to the persisted
// context size below. // context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null); const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// Whether the on-screen thread currently holds at least one message. Reported
// reactively by ChatThread (the live snapshot lives in a non-reactive ref). This
// lets the "Copy chat" button stay available for a brand-new, not-yet-persisted
// chat whose first turn is in flight or was interrupted — that case has no
// persisted rows yet, so a persisted-rows-only gate would hide the button (#174).
const [hasLiveContent, setHasLiveContent] = useState(false);
// The page the user is currently viewing. AiChatWindow lives in a pathless // The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full // parent layout route, so useParams() can't see :pageSlug. Match the full
@@ -185,8 +210,12 @@ export default function AiChatWindow() {
// The invalidate closures are passed inline: `onTurnFinished` is read live by // The invalidate closures are passed inline: `onTurnFinished` is read live by
// useChat's onFinish (never in an effect dep array), so their identity does not // useChat's onFinish (never in an effect dep array), so their identity does not
// matter — no memoization ceremony needed. // matter — no memoization ceremony needed.
const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } = const {
useChatSession({ threadKey,
waitingForHistory,
onTurnFinished,
cancelPendingAdoption,
} = useChatSession({
activeChatId, activeChatId,
setActiveChatId, setActiveChatId,
chats, chats,
@@ -231,13 +260,23 @@ export default function AiChatWindow() {
() => chats?.items?.find((c) => c.id === activeChatId) ?? null, () => chats?.items?.find((c) => c.id === activeChatId) ?? null,
[chats, activeChatId], [chats, activeChatId],
); );
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0; // Export is available when there is anything to export: either persisted rows
// for the active chat, OR a live on-screen thread with at least one message.
// The live arm covers a brand-new chat whose first turn is streaming or was
// interrupted before the server persisted any row (#174); the persisted arm is
// the steady-state path for an already-saved chat (#160).
const canExport =
hasLiveContent ||
(!!activeChatId && !!messageRows && messageRows.length > 0);
// The role to display in the header and as the assistant's name. Prefer the // The role to display in the header and as the assistant's name. Prefer the
// persisted role of an existing chat (chat-list JOIN); fall back to the role // persisted role of an existing chat (chat-list JOIN); fall back to the role
// picked via a card click for a brand-new or just-adopted chat. selectChat // picked via a card click for a brand-new or just-adopted chat. selectChat
// resets selectedRoleId, so this fallback never leaks into an unrelated chat. // resets selectedRoleId, so this fallback never leaks into an unrelated chat.
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => { const currentRole = useMemo<{
name: string;
emoji: string | null;
} | null>(() => {
if (activeChat?.roleName) { if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null }; return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
} }
@@ -249,28 +288,44 @@ export default function AiChatWindow() {
// call) and copy it to the clipboard. The "Copied" notification is the // call) and copy it to the clipboard. The "Copied" notification is the
// feedback. // feedback.
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
if (!activeChatId || !messageRows || messageRows.length === 0) return; // Export gate. There must be SOMETHING to export — either a live on-screen
// While the active thread is streaming, the current user message and the // message or a persisted row. A brand-new chat whose first turn is streaming
// in-progress assistant reply are NOT yet in messageRows (the persisted // or was interrupted has live messages but no persisted rows yet; it still
// query is only refetched after the turn finishes). Pull the live tail — // exports the on-screen thread WYSIWYG (#174). Only a truly empty chat (no
// messages whose id is not among the persisted rows — and append them, // live messages and no rows) is non-exportable (the button is hidden too —
// flagging the streaming assistant message as still generating. // see `canExport`).
const live = liveThreadRef.current; const live = liveThreadRef.current;
const rowIds = new Set(messageRows.map((r) => r.id)); const hasRows = !!messageRows && messageRows.length > 0;
const pending = live.isStreaming if (live.messages.length === 0 && !hasRows) return;
? live.messages // WYSIWYG export: the live on-screen messages ARE the document (so a partial
.filter((m) => !rowIds.has(m.id)) // reply from an interrupted turn — which never reached the persisted rows —
.map((m) => ({ // is exported just as it appears). The persisted rows enrich each live
role: m.role, // message (token usage / error / timestamp) by id and serve as the fallback
parts: (m.parts ?? []) as { type: string; text?: string }[], // when the live mirror is empty. The on-screen banner is appended too. See
generating: m.role === "assistant", // issues #160 and #174. `chatId` may be null for a not-yet-saved chat — use a
})) // placeholder so the header line still renders.
: [];
const markdown = buildChatMarkdown({ const markdown = buildChatMarkdown({
title: activeChat?.title ?? null, title: activeChat?.title ?? null,
chatId: activeChatId, chatId: activeChatId ?? "unsaved",
live: live.messages.map((m) => ({
id: m.id,
role: m.role,
parts: (m.parts ?? []) as { type: string; text?: string }[],
metadata: m.metadata as
| {
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
};
error?: string;
}
| undefined,
})),
rows: messageRows, rows: messageRows,
pending, isStreaming: live.isStreaming,
banner: live.banner,
t, t,
}); });
clipboard.copy(markdown); clipboard.copy(markdown);
@@ -351,7 +406,8 @@ export default function AiChatWindow() {
const width = el.offsetWidth; const width = el.offsetWidth;
const height = el.offsetHeight; const height = el.offsetHeight;
setGeom((prev) => { setGeom((prev) => {
if (!prev || (prev.width === width && prev.height === height)) return prev; if (!prev || (prev.width === width && prev.height === height))
return prev;
return { ...prev, width, height }; return { ...prev, width, height };
}); });
}); });
@@ -497,11 +553,15 @@ export default function AiChatWindow() {
flash a "0" badge before any token streams in (#151 review). */} flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? ( {liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow> <Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span> <span className={classes.badge}>
{formatTokens(liveTurnTokens)}
</span>
</Tooltip> </Tooltip>
) : contextTokens > 0 ? ( ) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow> <Tooltip label={t("Current context size")} withArrow>
<span className={classes.badge}>{formatTokens(contextTokens)}</span> <span className={classes.badge}>
{formatTokens(contextTokens)}
</span>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
@@ -515,7 +575,11 @@ export default function AiChatWindow() {
aria-label={t("Copy chat")} aria-label={t("Copy chat")}
onClick={handleCopy} onClick={handleCopy}
> >
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />} {clipboard.copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)}
</button> </button>
)} )}
<button <button
@@ -623,6 +687,7 @@ export default function AiChatWindow() {
onTurnFinished={onTurnFinished} onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef} liveStateRef={liveThreadRef}
onLiveTurnTokens={setLiveTurnTokens} onLiveTurnTokens={setLiveTurnTokens}
onLiveContentChange={setHasLiveContent}
/> />
)} )}
</div> </div>

View File

@@ -73,13 +73,25 @@ interface ChatThreadProps {
* "Copy chat" export can include the in-progress, not-yet-persisted * "Copy chat" export can include the in-progress, not-yet-persisted
* assistant message. A ref (not state) avoids re-rendering the parent on * assistant message. A ref (not state) avoids re-rendering the parent on
* every streamed delta. */ * every streamed delta. */
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>; liveStateRef?: MutableRefObject<{
messages: UIMessage[];
isStreaming: boolean;
banner: string | null;
}>;
/** Reports the live turn-token total (reasoning + output) for the in-flight /** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED * turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on * here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the * every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */ * parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void; onLiveTurnTokens?: (tokens: number | null) => void;
/** Reports whether the live thread currently holds at least one message, so the
* parent can gate the "Copy chat" button on the on-screen thread rather than on
* the persisted rows alone. This stays truthy for a brand-new, not-yet-saved
* chat the moment its first user message appears — so an interrupted very first
* turn (no persisted rows yet) is still exportable (#174). Called with `false`
* on unmount so a thread torn down by `key` on chat switch can't leave the
* button enabled for the next, possibly empty, chat. */
onLiveContentChange?: (hasContent: boolean) => void;
} }
/** /**
@@ -125,6 +137,7 @@ export default function ChatThread({
onTurnFinished, onTurnFinished,
liveStateRef, liveStateRef,
onLiveTurnTokens, onLiveTurnTokens,
onLiveContentChange,
}: ChatThreadProps) { }: ChatThreadProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -309,18 +322,49 @@ export default function ChatThread({
if (isStreaming) setStopNotice(null); if (isStreaming) setStopNotice(null);
}, [isStreaming]); }, [isStreaming]);
// Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong". Computed here (not only in the JSX) so
// the SAME on-screen banner text can be mirrored into the export (issue #160).
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// The exact banner the user sees under the message list, flattened to a single
// string for the "Copy chat" export so the artifact records the interruption
// WYSIWYG. Mirrors the JSX precedence below: error first, else the stop notice.
const banner = errorView
? errorView.detail
? `${errorView.title}${errorView.detail}`
: errorView.title
: stopNotice === "manual"
? t("Response stopped.")
: stopNotice === "disconnect"
? t("Connection lost — the answer was interrupted.")
: null;
// Mirror the live useChat snapshot into the parent-owned ref so the export // Mirror the live useChat snapshot into the parent-owned ref so the export
// (handled in AiChatWindow) can include the in-progress streaming turn. The // (handled in AiChatWindow) can include the in-progress streaming turn AND the
// cleanup clears the ref on unmount so a thread torn down by `key` on chat // on-screen banner. The cleanup clears the ref on unmount so a thread torn down
// switch can't leak its (possibly still-streaming) tail into the next chat's // by `key` on chat switch can't leak its (possibly still-streaming) tail into
// export before the new thread's effect repopulates the ref. // the next chat's export before the new thread's effect repopulates the ref.
useEffect(() => { useEffect(() => {
if (!liveStateRef) return; if (!liveStateRef) return;
liveStateRef.current = { messages, isStreaming }; liveStateRef.current = { messages, isStreaming, banner };
return () => { return () => {
liveStateRef.current = { messages: [], isStreaming: false }; liveStateRef.current = { messages: [], isStreaming: false, banner: null };
}; };
}, [liveStateRef, messages, isStreaming]); }, [liveStateRef, messages, isStreaming, banner]);
// Reactively report "the live thread has content" to the parent. `liveStateRef`
// above is a ref (deliberately non-reactive so streaming deltas don't re-render
// the parent), so the export button needs a SEPARATE reactive signal to flip on
// for a not-yet-persisted chat. Keyed on the boolean only — identical values are
// a no-op setState in the parent, so this does not add per-delta re-renders.
const hasLiveContent = messages.length > 0;
useEffect(() => {
if (!onLiveContentChange) return;
onLiveContentChange(hasLiveContent);
return () => onLiveContentChange(false);
}, [onLiveContentChange, hasLiveContent]);
// Report the live turn-token total to the parent header badge, THROTTLED to // Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every // ~8 Hz so the parent re-renders a few times a second instead of on every
@@ -343,8 +387,7 @@ export default function ChatThread({
return; return;
} }
const tail = messages[messages.length - 1]; const tail = messages[messages.length - 1];
const live = const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0; const total = live ? live.reasoning + live.output : 0;
const now = Date.now(); const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz) const MIN_INTERVAL = 120; // ms (~8 Hz)
@@ -370,11 +413,6 @@ export default function ChatThread({
}; };
}, []); }, []);
// Classify the turn error into a heading + detail so the banner names the cause
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
// of a generic "Something went wrong".
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// A role was picked with autoStart=false: the role is bound but NOTHING was // A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards. // sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated) // This flag hides the cards and reveals the composer (with the role indicated)

View File

@@ -165,7 +165,9 @@ describe("buildChatMarkdown — tool parts", () => {
], ],
t, t,
}); });
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error"); expect(md).toContain(
"**Tool: Ran tool mysteryTool** (`mysteryTool`) — error",
);
expect(md).toContain("**Error:** boom"); expect(md).toContain("**Error:** boom");
}); });
@@ -307,7 +309,9 @@ describe("buildChatMarkdown — token totals", () => {
row({ row({
role: "assistant", role: "assistant",
content: "x", content: "x",
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } }, metadata: {
usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 },
},
}), }),
], ],
t, t,
@@ -367,125 +371,377 @@ describe("buildChatMarkdown — token totals", () => {
}); });
}); });
describe("buildChatMarkdown — pending / in-progress messages", () => { // A minimal on-screen (live) message, matching the subset buildChatMarkdown reads.
it("continues the heading numbering after the persisted rows", () => { function live(partial: {
id?: string;
role?: string;
parts?: { type: string; text?: string }[];
metadata?: { usage?: Record<string, number>; error?: string };
}) {
return {
id: partial.id ?? "live-id",
role: partial.role ?? "assistant",
parts: partial.parts ?? [],
metadata: partial.metadata,
};
}
describe("buildChatMarkdown — live (WYSIWYG) source", () => {
it("uses the live messages as the document (what's on screen), numbered from 1", () => {
const md = buildChatMarkdown({ const md = buildChatMarkdown({
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [row({ role: "user", content: "persisted" })], // Persisted rows hold only the user turn; the assistant reply is live-only.
pending: [ rows: [row({ id: "u1", role: "user", content: "persisted user" })],
{ live: [
live({
id: "u1",
role: "user", role: "user",
parts: [{ type: "text", text: "live question" }], parts: [{ type: "text", text: "on-screen user" }],
generating: false, }),
}, live({
{ id: "a1",
role: "assistant", role: "assistant",
parts: [{ type: "text", text: "live answer" }], parts: [{ type: "text", text: "on-screen reply" }],
generating: true, }),
},
], ],
isStreaming: false,
t, t,
}); });
expect(md).toContain("## 1. You"); expect(md).toContain("## 1. You");
expect(md).toContain("## 2. You"); expect(md).toContain("## 2. AI agent");
expect(md).toContain("## 3. AI agent"); expect(md).toContain("on-screen user");
expect(md).toContain("live question"); expect(md).toContain("on-screen reply");
expect(md).toContain("live answer"); // Message count reflects the LIVE document, not rows + live.
expect(md).toContain("- Messages: 2");
}); });
it("flags a generating assistant pending message as still being generated", () => { it("captures a partial reply from an interrupted (non-streaming) turn — no 'generating' note", () => {
const md = buildChatMarkdown({ const md = buildChatMarkdown({
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [row({ role: "user", content: "persisted" })], rows: [row({ id: "u1", role: "user", content: "q" })],
pending: [ live: [
{ live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a-live",
role: "assistant", role: "assistant",
parts: [{ type: "text", text: "partial reply" }], parts: [{ type: "text", text: "partial plan before the drop" }],
generating: true, }),
},
], ],
isStreaming: false, // the stream dropped — not streaming anymore
banner: "Connection lost — the answer was interrupted.",
t, t,
}); });
expect(md).toContain("partial reply"); // The partial assistant answer that was on screen IS in the export.
expect(md).toContain("still being generated"); expect(md).toContain("partial plan before the drop");
// It is NOT flagged still-generating (the turn is over, just interrupted).
expect(md).not.toContain("still being generated");
// The on-screen banner is recorded at the end.
expect(md).toContain("Connection lost — the answer was interrupted.");
}); });
it("renders a non-generating user pending message without the note", () => { it("flags ONLY the tail assistant as still generating, and only while streaming", () => {
const streaming = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [],
live: [
live({
id: "a",
role: "assistant",
parts: [{ type: "text", text: "done earlier" }],
}),
live({
id: "u",
role: "user",
parts: [{ type: "text", text: "next q" }],
}),
live({
id: "b",
role: "assistant",
parts: [{ type: "text", text: "streaming now" }],
}),
],
isStreaming: true,
t,
});
// Exactly one "still being generated" note (the tail assistant).
expect(streaming.match(/still being generated/g)?.length).toBe(1);
const idle = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [],
live: [
live({
id: "b",
role: "assistant",
parts: [{ type: "text", text: "final" }],
}),
],
isStreaming: false,
t,
});
expect(idle).not.toContain("still being generated");
});
it("does NOT flag a completed assistant as generating when the streaming tail is a user message", () => {
// The `status === "submitted"` window: the user just sent, isStreaming is
// already true, but the new assistant turn has no message yet so the tail is
// the USER message. The previous assistant answer is complete on screen and
// must not be marked still-generating (WYSIWYG; regression for #160 review).
const md = buildChatMarkdown({ const md = buildChatMarkdown({
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [row({ role: "user", content: "persisted" })], rows: [],
pending: [ live: [
{ live({
id: "a",
role: "assistant",
parts: [{ type: "text", text: "completed answer" }],
}),
live({
id: "u",
role: "user", role: "user",
parts: [{ type: "text", text: "my live message" }], parts: [{ type: "text", text: "the new question" }],
generating: false, }),
},
], ],
isStreaming: true,
t, t,
}); });
expect(md).toContain("my live message"); expect(md).toContain("completed answer");
expect(md).not.toContain("still being generated"); expect(md).not.toContain("still being generated");
}); });
it("includes the pending messages in the metadata message count", () => { it("emits the heading + note for a streaming tail assistant with empty parts", () => {
const md = buildChatMarkdown({ const md = buildChatMarkdown({
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [ rows: [row({ id: "u1", role: "user", content: "q" })],
row({ role: "user", content: "a" }), live: [
row({ role: "assistant", content: "b" }), live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
], live({ id: "a-live", role: "assistant", parts: [] }),
pending: [
{
role: "user",
parts: [{ type: "text", text: "c" }],
generating: false,
},
{
role: "assistant",
parts: [{ type: "text", text: "d" }],
generating: true,
},
],
t,
});
// 2 persisted rows + 2 pending = 4.
expect(md).toContain("- Messages: 4");
});
it("emits the heading and note for a generating assistant with empty parts", () => {
expect(() =>
buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "assistant",
parts: [],
generating: true,
},
],
t,
}),
).not.toThrow();
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "persisted" })],
pending: [
{
role: "assistant",
parts: [],
generating: true,
},
], ],
isStreaming: true,
t, t,
}); });
expect(md).toContain("## 2. AI agent"); expect(md).toContain("## 2. AI agent");
expect(md).toContain("still being generated"); expect(md).toContain("still being generated");
}); });
}); });
describe("buildChatMarkdown — live enrichment from persisted rows", () => {
it("pulls usage / error / timestamp from the persisted row matched by id", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
id: "a1",
role: "assistant",
content: "x",
createdAt: "2026-06-22T10:00:00.000Z",
metadata: {
usage: { inputTokens: 10, outputTokens: 5 },
error: "rate limited",
},
}),
],
live: [
// Same id as the persisted row, but no usage/error/timestamp on the live msg.
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "reply" }],
}),
],
isStreaming: false,
t,
});
expect(md).toContain("reply");
// Token footer + total come from the enriched row.
expect(md).toContain("_Tokens — in: 10, out: 5, total: 15_");
expect(md).toContain("- Total tokens: 15");
expect(md).toContain("**⚠️ Error:** rate limited");
// The persisted timestamp is carried into the export.
expect(md).toContain("<!-- 2026-06-22T10:00:00.000Z -->");
});
it("prefers authoritative usage already on the live message over the row's", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
id: "a1",
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
},
}),
],
live: [
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "reply" }],
metadata: {
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
},
}),
],
isStreaming: false,
t,
});
// The live (authoritative, freshest) usage wins, not the stale row usage.
expect(md).toContain("- Total tokens: 150");
expect(md).not.toContain("- Total tokens: 2");
});
it("a current-turn live message with no matching row renders without a footer", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ id: "u1", role: "user", content: "q" })],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a-live",
role: "assistant",
parts: [{ type: "text", text: "fresh reply" }],
}),
],
isStreaming: false,
t,
});
expect(md).toContain("fresh reply");
// No persisted row for the live assistant -> no token footer, no timestamp.
expect(md).not.toContain("_Tokens —");
expect(md).not.toContain("<!-- undefined -->");
});
});
describe("buildChatMarkdown — fallback + banner", () => {
it("falls back to the persisted rows when there are no live messages", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "from rows" }),
row({
role: "assistant",
content: "answer",
metadata: { usage: { inputTokens: 4, outputTokens: 6 } },
}),
],
live: [], // empty live mirror -> fallback path
isStreaming: false,
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("from rows");
expect(md).toContain("- Messages: 2");
expect(md).toContain("- Total tokens: 10");
});
it("appends the on-screen banner once, after the messages", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "q" })],
live: [
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
],
isStreaming: false,
banner: "Rate limit reached — try again shortly.",
t,
});
expect(md).toContain("_⚠️ Rate limit reached — try again shortly._");
// Banner comes after the (only) message block.
expect(md.indexOf("Rate limit reached")).toBeGreaterThan(
md.indexOf("## 1."),
);
});
it("omits the banner block when there is no banner", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "q" })],
live: [
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
],
isStreaming: false,
banner: null,
t,
});
expect(md).not.toContain("_⚠️");
});
});
// #174: a brand-new, not-yet-persisted chat whose first turn is streaming (or was
// interrupted) has live messages but NO persisted rows yet, and its chat id is not
// known (the caller passes a placeholder). The export must still capture the
// on-screen thread WYSIWYG from the live messages alone.
describe("buildChatMarkdown — first-turn export with no persisted base (#174)", () => {
it("builds the document from live messages alone when rows are empty", () => {
const md = buildChatMarkdown({
title: null,
chatId: "unsaved",
rows: [],
live: [
live({
id: "u1",
role: "user",
parts: [{ type: "text", text: "hello" }],
}),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "partial reply" }],
}),
],
isStreaming: true,
t,
});
// Both on-screen messages are serialized, numbered from 1.
expect(md).toContain("## 1. You");
expect(md).toContain("hello");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("partial reply");
// The streaming tail assistant is flagged as in-progress.
expect(md).toContain("still being generated");
// The placeholder chat id and the live message count are recorded.
expect(md).toContain("- Chat ID: `unsaved`");
expect(md).toContain("- Messages: 2");
// No persisted timestamp exists for a current-turn live message.
expect(md).not.toContain("<!--");
});
it("captures an interrupted first turn (no rows, not streaming) without a generating note", () => {
const md = buildChatMarkdown({
title: null,
chatId: "unsaved",
rows: [],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "half an answer" }],
}),
],
isStreaming: false,
banner: "Connection dropped — the response was cut off.",
t,
});
expect(md).toContain("half an answer");
// An interrupted (non-streaming) partial is exported as-is, no generating note.
expect(md).not.toContain("still being generated");
// The on-screen banner records the interruption.
expect(md).toContain("_⚠️ Connection dropped — the response was cut off._");
});
});

View File

@@ -25,11 +25,23 @@ type Translate = (key: string, values?: Record<string, unknown>) => string;
interface BuildChatMarkdownArgs { interface BuildChatMarkdownArgs {
title: string | null; title: string | null;
chatId: string; chatId: string;
/** The live, on-screen messages — the WYSIWYG source of the export. When
* present and non-empty these DRIVE the document (so it mirrors exactly what
* the user sees, including a partial reply from an interrupted turn). Each is
* matched to a persisted row by `id` to enrich it with token usage / error /
* timestamp. When absent or empty the builder falls back to `rows`. */
live?: LiveMessage[];
/** Persisted message rows. Enrichment source (matched to `live` by id) AND the
* fallback document source when `live` is empty. */
rows: IAiChatMessageRow[]; rows: IAiChatMessageRow[];
/** In-progress, not-yet-persisted live messages (the current streaming /** Whether the live thread is still streaming. Only then is the tail assistant
* turn) to append after the persisted rows. `generating: true` adds a * message flagged "still generating"; an interrupted (non-streaming) partial
* note that the message is still being produced. */ * reply is exported as-is and the `banner` explains the interruption. */
pending?: PendingMessage[]; isStreaming?: boolean;
/** The on-screen banner text (error / dropped connection / manual stop),
* appended at the end of the export so the artifact records the interruption
* the user saw. */
banner?: string | null;
t: Translate; t: Translate;
} }
@@ -39,10 +51,31 @@ interface TextLikePart {
text?: string; text?: string;
} }
/** A live, not-yet-persisted message (current streaming turn) to append. */ /** Authoritative per-turn usage the server attaches to a message / row. */
interface PendingMessage { interface UsageLike {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** A live, on-screen message (subset of the AI SDK UIMessage we consume). */
interface LiveMessage {
id: string;
role: "user" | "assistant" | string; role: "user" | "assistant" | string;
parts: TextLikePart[]; parts: TextLikePart[];
metadata?: { usage?: UsageLike; error?: string };
}
/** One message normalized for rendering, regardless of live/persisted origin. */
interface ExportItem {
role: string;
parts: TextLikePart[];
usage?: UsageLike;
error?: string;
/** ISO timestamp from the persisted row, when one is known. */
createdAt?: string;
/** True only for the tail assistant message while the thread is streaming. */
generating: boolean; generating: boolean;
} }
@@ -127,53 +160,128 @@ function renderMessageParts(parts: TextLikePart[], t: Translate): string[] {
return out; return out;
} }
/** Resolve a persisted row's parts: prefer the rich persisted parts, else a
* single text part built from the plain-text content (mirrors `rowToUiMessage`). */
function rowParts(row: IAiChatMessageRow): TextLikePart[] {
return Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
? (row.metadata.parts as TextLikePart[])
: [{ type: "text", text: row.content ?? "" }];
}
/**
* Normalize the export to one ordered list of {@link ExportItem}, WYSIWYG-first:
*
* - When `live` messages are present, THEY are the document (what the user sees,
* incl. an interrupted turn's partial reply). Each is matched to a persisted
* row by `id` to pull token usage / error / timestamp — a live message of the
* CURRENT turn has no matching row yet, so it simply renders without a footer.
* Authoritative `usage`/`error` already on the live message metadata win over
* the row (the server attaches usage to the streamed message at a step
* boundary before the row is refetched). Only the tail assistant message is
* flagged `generating`, and only while `isStreaming`.
* - When `live` is empty (e.g. the export runs before the live mirror is
* populated), fall back to the persisted `rows` so the format never regresses.
*/
function resolveItems(
live: LiveMessage[] | undefined,
rows: IAiChatMessageRow[],
isStreaming: boolean,
): ExportItem[] {
if (live && live.length > 0) {
const rowsById = new Map(rows.map((r) => [r.id, r]));
// The "still generating" note may apply ONLY to an assistant message that is
// the actual TAIL of the list — that is where the on-screen typing indicator
// sits. While `status === "submitted"` (isStreaming true) right after the
// user hit send, the tail is the USER message and the new assistant turn has
// no message yet; the previous assistant answer is shown complete on screen,
// so it must NOT be flagged (the indicator renders as a separate bottom
// block, not on that answer).
const lastIndex = live.length - 1;
const tailIsStreamingAssistant =
isStreaming && live[lastIndex]?.role === "assistant";
return live.map((m, i) => {
const row = rowsById.get(m.id);
return {
role: m.role,
parts: m.parts ?? [],
// Authoritative usage/error already on the live message (the server
// attaches usage to the streamed message at a step boundary) wins over
// the persisted row; a current-turn live message has no matching row yet
// and simply renders without a token footer (the accepted WYSIWYG
// tradeoff — an interrupted turn loses only its token footer, not text).
usage: m.metadata?.usage ?? row?.metadata?.usage,
error: m.metadata?.error ?? row?.metadata?.error ?? undefined,
createdAt: row?.createdAt,
generating: tailIsStreamingAssistant && i === lastIndex,
};
});
}
return rows.map((row) => ({
role: row.role,
parts: rowParts(row),
usage: row.metadata?.usage,
error: row.metadata?.error ?? undefined,
createdAt: row.createdAt,
generating: false,
}));
}
/** /**
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the * Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
* export timestamp), so it is straightforward to unit-test. * export timestamp), so it is straightforward to unit-test.
*/ */
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string { export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const { title, chatId, rows, pending, t } = args; const { title, chatId, live, rows, isStreaming, banner, t } = args;
const blocks: string[] = []; const blocks: string[] = [];
const items = resolveItems(live, rows, isStreaming === true);
const heading = (title ?? "").trim() || t("Untitled chat"); const heading = (title ?? "").trim() || t("Untitled chat");
blocks.push(`# ${heading}`); blocks.push(`# ${heading}`);
// Metadata bullet list. Total tokens is only shown when there is a sum. // Metadata bullet list. Total tokens is only shown when there is a sum.
const totalTokens = rows.reduce((sum, row) => { const totalTokens = items.reduce(
const usage = row.metadata?.usage; (sum, item) => (item.usage ? sum + rowTokens(item.usage) : sum),
return usage ? sum + rowTokens(usage) : sum; 0,
}, 0); );
const meta = [ const meta = [
`- Chat ID: \`${chatId}\``, `- Chat ID: \`${chatId}\``,
`- Exported: ${new Date().toISOString()}`, `- Exported: ${new Date().toISOString()}`,
`- Messages: ${rows.length + (pending?.length ?? 0)}`, `- Messages: ${items.length}`,
]; ];
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`); if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
blocks.push(meta.join("\n")); blocks.push(meta.join("\n"));
rows.forEach((row, index) => { items.forEach((item, index) => {
blocks.push("---"); blocks.push("---");
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You"); const roleLabel = item.role === "assistant" ? t("AI agent") : t("You");
blocks.push(`## ${index + 1}. ${roleLabel}`); blocks.push(`## ${index + 1}. ${roleLabel}`);
// Created-at kept in source as an HTML comment (out of the rendered prose). // Created-at kept in source as an HTML comment (out of the rendered prose).
blocks.push(`<!-- ${row.createdAt} -->`); // A live message of the current turn has no persisted row yet — omit it.
if (item.createdAt) blocks.push(`<!-- ${item.createdAt} -->`);
// Resolve parts: prefer the rich persisted parts, else a single text part blocks.push(...renderMessageParts(item.parts, t));
// built from the plain-text content (mirrors `rowToUiMessage`).
const parts: TextLikePart[] =
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
? (row.metadata.parts as TextLikePart[])
: [{ type: "text", text: row.content ?? "" }];
blocks.push(...renderMessageParts(parts, t)); // A generating assistant may have empty/no parts yet — the heading (above)
// and this note still record the in-progress turn.
if (row.metadata?.error) { if (item.generating) {
blocks.push(`**⚠️ Error:** ${row.metadata.error}`); blocks.push(
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
);
} }
const usage = row.metadata?.usage; // A persisted per-message error (the raw provider text) may coexist with the
// trailing `banner` (the classified on-screen alert) when the failed turn's
// row has already been refetched by export time. They describe the same
// failure at different fidelity; showing both is an accepted, minor redundancy.
if (item.error) {
blocks.push(`**⚠️ Error:** ${item.error}`);
}
const usage = item.usage;
if (usage) { if (usage) {
const total = usage.totalTokens ?? rowTokens(usage); const total = usage.totalTokens ?? rowTokens(usage);
// Reasoning (thinking) tokens are shown only when the provider reported a // Reasoning (thinking) tokens are shown only when the provider reported a
@@ -188,27 +296,12 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
} }
}); });
// Append the in-progress, not-yet-persisted live messages (the current // Record the on-screen banner (error / dropped connection / manual stop) so
// streaming turn) after the persisted rows. Heading numbering CONTINUES from // the export reflects exactly what the user saw, including an interruption.
// the persisted rows. A `generating` assistant gets a note that the captured if (banner && banner.trim().length > 0) {
// response is partial; pending messages carry no usage/token footer yet.
(pending ?? []).forEach((message, p) => {
blocks.push("---"); blocks.push("---");
blocks.push(`_⚠️ ${banner.trim()}_`);
const num = rows.length + p + 1;
const roleLabel = message.role === "assistant" ? t("AI agent") : t("You");
blocks.push(`## ${num}. ${roleLabel}`);
blocks.push(...renderMessageParts(message.parts, t));
// A generating assistant may have empty/no parts yet — still emit the
// heading (above) and this note so the export shows the in-progress turn.
if (message.generating === true) {
blocks.push(
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
);
} }
});
// Blank line between blocks so the Markdown renders cleanly. // Blank line between blocks so the Markdown renders cleanly.
return blocks.join("\n\n"); return blocks.join("\n\n");