diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 5f6b1dde..3990a0ba 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -151,9 +151,14 @@ export default function AiChatWindow() { // 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) // 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: [], isStreaming: false, + banner: null, }); // Live turn-token total (reasoning + output) for the in-flight turn, pushed up @@ -249,28 +254,42 @@ export default function AiChatWindow() { // call) and copy it to the clipboard. The "Copied" notification is the // feedback. const handleCopy = useCallback(() => { + // Export gate. Requiring at least one persisted row means a brand-new chat + // whose VERY FIRST turn dropped before the server persisted even the user + // message cannot be exported (the button is also hidden — see `canExport`). + // That narrow first-turn case is deliberately out of scope for #160; the user + // message is normally persisted before model contact, so an interrupted later + // turn still has rows and exports the on-screen partial reply WYSIWYG. if (!activeChatId || !messageRows || messageRows.length === 0) return; - // While the active thread is streaming, the current user message and the - // in-progress assistant reply are NOT yet in messageRows (the persisted - // query is only refetched after the turn finishes). Pull the live tail — - // messages whose id is not among the persisted rows — and append them, - // flagging the streaming assistant message as still generating. + // WYSIWYG export: the live on-screen messages ARE the document (so a partial + // reply from an interrupted turn — which never reached the persisted rows — + // is exported just as it appears). The persisted rows enrich each live + // message (token usage / error / timestamp) by id and serve as the fallback + // when the live mirror is empty. The on-screen banner is appended too. See + // issue #160. const live = liveThreadRef.current; - const rowIds = new Set(messageRows.map((r) => r.id)); - const pending = live.isStreaming - ? live.messages - .filter((m) => !rowIds.has(m.id)) - .map((m) => ({ - role: m.role, - parts: (m.parts ?? []) as { type: string; text?: string }[], - generating: m.role === "assistant", - })) - : []; const markdown = buildChatMarkdown({ title: activeChat?.title ?? null, chatId: activeChatId, + 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, - pending, + isStreaming: live.isStreaming, + banner: live.banner, t, }); clipboard.copy(markdown); diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 3898136e..f58d47d4 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -73,7 +73,11 @@ interface ChatThreadProps { * "Copy chat" export can include the in-progress, not-yet-persisted * assistant message. A ref (not state) avoids re-rendering the parent on * 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 * 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 @@ -309,18 +313,37 @@ export default function ChatThread({ if (isStreaming) setStopNotice(null); }, [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 - // (handled in AiChatWindow) can include the in-progress streaming turn. The - // cleanup clears the ref on unmount so a thread torn down by `key` on chat - // switch can't leak its (possibly still-streaming) tail into the next chat's - // export before the new thread's effect repopulates the ref. + // (handled in AiChatWindow) can include the in-progress streaming turn AND the + // on-screen banner. The cleanup clears the ref on unmount so a thread torn down + // by `key` on chat switch can't leak its (possibly still-streaming) tail into + // the next chat's export before the new thread's effect repopulates the ref. useEffect(() => { if (!liveStateRef) return; - liveStateRef.current = { messages, isStreaming }; + liveStateRef.current = { messages, isStreaming, banner }; return () => { - liveStateRef.current = { messages: [], isStreaming: false }; + liveStateRef.current = { messages: [], isStreaming: false, banner: null }; }; - }, [liveStateRef, messages, isStreaming]); + }, [liveStateRef, messages, isStreaming, banner]); // 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 @@ -370,11 +393,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 // 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) diff --git a/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts b/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts index 651d1d26..97628d8b 100644 --- a/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts +++ b/apps/client/src/features/ai-chat/utils/chat-markdown.test.ts @@ -367,125 +367,258 @@ describe("buildChatMarkdown — token totals", () => { }); }); -describe("buildChatMarkdown — pending / in-progress messages", () => { - it("continues the heading numbering after the persisted rows", () => { +// A minimal on-screen (live) message, matching the subset buildChatMarkdown reads. +function live(partial: { + id?: string; + role?: string; + parts?: { type: string; text?: string }[]; + metadata?: { usage?: Record; 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({ title: "t", chatId: "c", - rows: [row({ role: "user", content: "persisted" })], - pending: [ - { - role: "user", - parts: [{ type: "text", text: "live question" }], - generating: false, - }, - { - role: "assistant", - parts: [{ type: "text", text: "live answer" }], - generating: true, - }, + // Persisted rows hold only the user turn; the assistant reply is live-only. + rows: [row({ id: "u1", role: "user", content: "persisted user" })], + live: [ + live({ id: "u1", role: "user", parts: [{ type: "text", text: "on-screen user" }] }), + live({ id: "a1", role: "assistant", parts: [{ type: "text", text: "on-screen reply" }] }), ], + isStreaming: false, t, }); expect(md).toContain("## 1. You"); - expect(md).toContain("## 2. You"); - expect(md).toContain("## 3. AI agent"); - expect(md).toContain("live question"); - expect(md).toContain("live answer"); + expect(md).toContain("## 2. AI agent"); + expect(md).toContain("on-screen user"); + expect(md).toContain("on-screen reply"); + // 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({ title: "t", chatId: "c", - rows: [row({ role: "user", content: "persisted" })], - pending: [ - { + 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: "partial reply" }], - generating: true, - }, + parts: [{ type: "text", text: "partial plan before the drop" }], + }), ], + isStreaming: false, // the stream dropped — not streaming anymore + banner: "Connection lost — the answer was interrupted.", t, }); - expect(md).toContain("partial reply"); - expect(md).toContain("still being generated"); + // The partial assistant answer that was on screen IS in the export. + 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({ title: "t", chatId: "c", - rows: [row({ role: "user", content: "persisted" })], - pending: [ - { - role: "user", - parts: [{ type: "text", text: "my live message" }], - generating: false, - }, + rows: [], + live: [ + live({ id: "a", role: "assistant", parts: [{ type: "text", text: "completed answer" }] }), + live({ id: "u", role: "user", parts: [{ type: "text", text: "the new question" }] }), ], + isStreaming: true, t, }); - expect(md).toContain("my live message"); + expect(md).toContain("completed answer"); 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({ title: "t", chatId: "c", - rows: [ - row({ role: "user", content: "a" }), - row({ role: "assistant", content: "b" }), - ], - 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, - }, + 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: [] }), ], + isStreaming: true, t, }); expect(md).toContain("## 2. AI agent"); 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(""); + }); + + 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(""); + }); +}); + +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("_⚠️"); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/chat-markdown.ts b/apps/client/src/features/ai-chat/utils/chat-markdown.ts index c3c3b3b2..f70836d5 100644 --- a/apps/client/src/features/ai-chat/utils/chat-markdown.ts +++ b/apps/client/src/features/ai-chat/utils/chat-markdown.ts @@ -25,11 +25,23 @@ type Translate = (key: string, values?: Record) => string; interface BuildChatMarkdownArgs { title: string | null; 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[]; - /** In-progress, not-yet-persisted live messages (the current streaming - * turn) to append after the persisted rows. `generating: true` adds a - * note that the message is still being produced. */ - pending?: PendingMessage[]; + /** Whether the live thread is still streaming. Only then is the tail assistant + * message flagged "still generating"; an interrupted (non-streaming) partial + * reply is exported as-is and the `banner` explains the interruption. */ + 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; } @@ -39,10 +51,31 @@ interface TextLikePart { text?: string; } -/** A live, not-yet-persisted message (current streaming turn) to append. */ -interface PendingMessage { +/** Authoritative per-turn usage the server attaches to a message / row. */ +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; 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; } @@ -127,53 +160,128 @@ function renderMessageParts(parts: TextLikePart[], t: Translate): string[] { 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 * export timestamp), so it is straightforward to unit-test. */ 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 items = resolveItems(live, rows, isStreaming === true); + const heading = (title ?? "").trim() || t("Untitled chat"); blocks.push(`# ${heading}`); // Metadata bullet list. Total tokens is only shown when there is a sum. - const totalTokens = rows.reduce((sum, row) => { - const usage = row.metadata?.usage; - return usage ? sum + rowTokens(usage) : sum; - }, 0); + const totalTokens = items.reduce( + (sum, item) => (item.usage ? sum + rowTokens(item.usage) : sum), + 0, + ); const meta = [ `- Chat ID: \`${chatId}\``, `- Exported: ${new Date().toISOString()}`, - `- Messages: ${rows.length + (pending?.length ?? 0)}`, + `- Messages: ${items.length}`, ]; if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`); blocks.push(meta.join("\n")); - rows.forEach((row, index) => { + items.forEach((item, index) => { 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}`); // Created-at kept in source as an HTML comment (out of the rendered prose). - blocks.push(``); + // A live message of the current turn has no persisted row yet — omit it. + if (item.createdAt) blocks.push(``); - // Resolve parts: prefer the rich persisted parts, else a single text part - // 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(item.parts, t)); - blocks.push(...renderMessageParts(parts, t)); - - if (row.metadata?.error) { - blocks.push(`**⚠️ Error:** ${row.metadata.error}`); + // A generating assistant may have empty/no parts yet — the heading (above) + // and this note still record the in-progress turn. + if (item.generating) { + 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) { const total = usage.totalTokens ?? rowTokens(usage); // 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 - // streaming turn) after the persisted rows. Heading numbering CONTINUES from - // the persisted rows. A `generating` assistant gets a note that the captured - // response is partial; pending messages carry no usage/token footer yet. - (pending ?? []).forEach((message, p) => { + // Record the on-screen banner (error / dropped connection / manual stop) so + // the export reflects exactly what the user saw, including an interruption. + if (banner && banner.trim().length > 0) { blocks.push("---"); - - 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._", - ); - } - }); + blocks.push(`_⚠️ ${banner.trim()}_`); + } // Blank line between blocks so the Markdown renders cleanly. return blocks.join("\n\n");