feat(ai-chat): include in-progress streaming turn in chat export

The "Copy chat" export read only persisted DB rows (messageRows), so an
assistant reply that was still streaming — and the user message that
triggered it — were absent from the export until the turn finished and
the messages query was refetched.

ChatThread now mirrors its live useChat snapshot ({ messages,
isStreaming }) into a parent-owned ref; the effect clears the ref on
unmount so a thread switch can't leak its tail into the next chat.
AiChatWindow.handleCopy computes the not-yet-persisted live tail
(messages whose id is absent from messageRows, only while streaming) and
passes it to buildChatMarkdown as `pending`. buildChatMarkdown appends
pending messages after the persisted rows (continuing the heading
numbering), flags the streaming assistant message with an
"still being generated" note, and reuses an extracted renderMessageParts
helper so persisted and pending rendering stay identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 20:00:35 +03:00
parent 11d5a75c79
commit 7ddd0cba05
4 changed files with 257 additions and 37 deletions

View File

@@ -1,4 +1,11 @@
import { useCallback, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
} from "react";
import { generateId } from "ai";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
@@ -52,6 +59,12 @@ interface ChatThreadProps {
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
/** Parent-owned ref that this thread keeps updated with its live useChat
* snapshot (full message list + streaming flag), so the header's
* "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 }>;
}
/**
@@ -90,6 +103,7 @@ export default function ChatThread({
onRolePicked,
assistantName,
onTurnFinished,
liveStateRef,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -249,6 +263,19 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// 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.
useEffect(() => {
if (!liveStateRef) return;
liveStateRef.current = { messages, isStreaming };
return () => {
liveStateRef.current = { messages: [], isStreaming: false };
};
}, [liveStateRef, messages, 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".