fix(ai-chat): WYSIWYG Copy chat export keeps the on-screen partial reply (#160)
"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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user