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:
@@ -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