From 4597183a1e58a8ebf75def9eea228ca6fb5f75de Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 15:00:37 +0300 Subject: [PATCH 1/2] fix(ai-chat): WYSIWYG Copy chat export keeps the on-screen partial reply (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Copy chat" built the Markdown from persisted rows plus a live tail that was only included while isStreaming. When a turn was interrupted (dropped stream / "Lost connection" banner) isStreaming flipped false, the live tail was dropped, and the partial assistant reply visible on screen — whose row often never persisted — vanished from the export, leaving only the user messages. - buildChatMarkdown is now live-first: the on-screen `live` messages ARE the document. Each is matched to a persisted row by id to enrich it with token usage / error / timestamp; authoritative usage/error already on the live message win over the row. When `live` is empty it falls back to the persisted rows (old format preserved). Only the tail assistant is flagged "still generating", and only when it is genuinely the streaming tail — so the status==="submitted" window (tail is the user message) never mislabels the previous, completed answer. - The on-screen banner (classified error / dropped connection / manual stop) is flattened to a string in ChatThread, mirrored into liveStateRef alongside the messages/isStreaming snapshot, and appended at the end of the export. - handleCopy maps the live messages and passes live/rows/isStreaming/banner. Tests: chat-markdown rewritten for the live/enrichment/fallback/banner paths and the submitted-window regression (26); full ai-chat suite green (186). tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/ai-chat-window.tsx | 53 ++- .../ai-chat/components/chat-thread.tsx | 44 ++- .../ai-chat/utils/chat-markdown.test.ts | 303 +++++++++++++----- .../features/ai-chat/utils/chat-markdown.ts | 185 ++++++++--- 4 files changed, 424 insertions(+), 161 deletions(-) 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"); From df81851eb352d05a0d86265a2cf89760162c6f1a Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Thu, 25 Jun 2026 03:52:03 +0300 Subject: [PATCH 2/2] fix(ai-chat): export the first unsaved turn (#174) The "Copy chat" button was hidden during a brand-new chat's very first turn: both the `canExport` gate and the `handleCopy` early-return required an `activeChatId` AND persisted `messageRows`, neither of which exists yet while the first turn is streaming or after it was interrupted before any row was persisted. Decouple the export gate from persisted state. ChatThread now reports a reactive `onLiveContentChange(messages.length > 0)` signal (the live snapshot lives in a non-reactive ref, so a separate reactive flag is needed to re-render the button); the parent keeps it in `hasLiveContent` and exports whenever there is anything on screen OR persisted. `handleCopy` passes a `"unsaved"` placeholder chat id when none exists yet, and the live-first builder serializes the on-screen thread WYSIWYG. Builds on #160 (WYSIWYG export); covers the first-turn edge case that was explicitly out of scope there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/components/ai-chat-window.tsx | 108 ++++++++---- .../ai-chat/components/chat-thread.tsx | 24 ++- .../ai-chat/utils/chat-markdown.test.ts | 159 ++++++++++++++++-- 3 files changed, 240 insertions(+), 51 deletions(-) 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 3990a0ba..740945c4 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 @@ -80,17 +80,31 @@ function computeInitialGeom() { Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN), ); 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); return { left, top, width, height }; } // 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 effHeight = Math.max(g.height, MIN_HEIGHT); - const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN); - const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN); + const maxLeft = Math.max( + EDGE_MARGIN, + window.innerWidth - effWidth - EDGE_MARGIN, + ); + const maxTop = Math.max( + EDGE_MARGIN, + window.innerHeight - effHeight - EDGE_MARGIN, + ); return { ...g, left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft), @@ -166,6 +180,12 @@ export default function AiChatWindow() { // `null` means no turn is in flight -> the badge falls back to the persisted // context size below. const [liveTurnTokens, setLiveTurnTokens] = useState(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 // parent layout route, so useParams() can't see :pageSlug. Match the full @@ -190,17 +210,21 @@ export default function AiChatWindow() { // 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 // matter — no memoization ceremony needed. - const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } = - useChatSession({ - activeChatId, - setActiveChatId, - chats, - messagesLoading, - onInvalidateChatList: () => - queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }), - onInvalidateChatMessages: (id) => - queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }), - }); + const { + threadKey, + waitingForHistory, + onTurnFinished, + cancelPendingAdoption, + } = useChatSession({ + activeChatId, + setActiveChatId, + chats, + messagesLoading, + onInvalidateChatList: () => + queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }), + onInvalidateChatMessages: (id) => + queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }), + }); // startNewChat/selectChat set the public atom; the hook's render-phase // reconciler handles the remount when activeChatId actually CHANGES. But @@ -236,13 +260,23 @@ export default function AiChatWindow() { () => chats?.items?.find((c) => c.id === activeChatId) ?? null, [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 // 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 // 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) { return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null }; } @@ -254,23 +288,25 @@ 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; + // Export gate. There must be SOMETHING to export — either a live on-screen + // message or a persisted row. A brand-new chat whose first turn is streaming + // or was interrupted has live messages but no persisted rows yet; it still + // exports the on-screen thread WYSIWYG (#174). Only a truly empty chat (no + // live messages and no rows) is non-exportable (the button is hidden too — + // see `canExport`). + const live = liveThreadRef.current; + const hasRows = !!messageRows && messageRows.length > 0; + if (live.messages.length === 0 && !hasRows) return; // 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; + // 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({ title: activeChat?.title ?? null, - chatId: activeChatId, + chatId: activeChatId ?? "unsaved", live: live.messages.map((m) => ({ id: m.id, role: m.role, @@ -370,7 +406,8 @@ export default function AiChatWindow() { const width = el.offsetWidth; const height = el.offsetHeight; 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 }; }); }); @@ -516,11 +553,15 @@ export default function AiChatWindow() { flash a "0" badge before any token streams in (#151 review). */} {liveTurnTokens !== null && liveTurnTokens > 0 ? ( - {formatTokens(liveTurnTokens)} + + {formatTokens(liveTurnTokens)} + ) : contextTokens > 0 ? ( - {formatTokens(contextTokens)} + + {formatTokens(contextTokens)} + ) : null} @@ -534,7 +575,11 @@ export default function AiChatWindow() { aria-label={t("Copy chat")} onClick={handleCopy} > - {clipboard.copied ? : } + {clipboard.copied ? ( + + ) : ( + + )} )}