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) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-25 03:52:03 +03:00
parent 4597183a1e
commit df81851eb3
3 changed files with 240 additions and 51 deletions

View File

@@ -80,17 +80,31 @@ function computeInitialGeom() {
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN), Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
); );
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24); 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); const top = Math.min(60, maxTop);
return { left, top, width, height }; return { left, top, width, height };
} }
// Clamp a geometry so the window stays within the current viewport. // 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 effWidth = Math.max(g.width, MIN_WIDTH);
const effHeight = Math.max(g.height, MIN_HEIGHT); const effHeight = Math.max(g.height, MIN_HEIGHT);
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN); const maxLeft = Math.max(
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN); EDGE_MARGIN,
window.innerWidth - effWidth - EDGE_MARGIN,
);
const maxTop = Math.max(
EDGE_MARGIN,
window.innerHeight - effHeight - EDGE_MARGIN,
);
return { return {
...g, ...g,
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft), 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 // `null` means no turn is in flight -> the badge falls back to the persisted
// context size below. // context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null); const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(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 // The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full // parent layout route, so useParams() can't see :pageSlug. Match the full
@@ -190,8 +210,12 @@ export default function AiChatWindow() {
// The invalidate closures are passed inline: `onTurnFinished` is read live by // 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 // useChat's onFinish (never in an effect dep array), so their identity does not
// matter — no memoization ceremony needed. // matter — no memoization ceremony needed.
const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } = const {
useChatSession({ threadKey,
waitingForHistory,
onTurnFinished,
cancelPendingAdoption,
} = useChatSession({
activeChatId, activeChatId,
setActiveChatId, setActiveChatId,
chats, chats,
@@ -236,13 +260,23 @@ export default function AiChatWindow() {
() => chats?.items?.find((c) => c.id === activeChatId) ?? null, () => chats?.items?.find((c) => c.id === activeChatId) ?? null,
[chats, activeChatId], [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 // 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 // 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 // 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. // 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) { if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null }; 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 // call) and copy it to the clipboard. The "Copied" notification is the
// feedback. // feedback.
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
// Export gate. Requiring at least one persisted row means a brand-new chat // Export gate. There must be SOMETHING to export — either a live on-screen
// whose VERY FIRST turn dropped before the server persisted even the user // message or a persisted row. A brand-new chat whose first turn is streaming
// message cannot be exported (the button is also hidden — see `canExport`). // or was interrupted has live messages but no persisted rows yet; it still
// That narrow first-turn case is deliberately out of scope for #160; the user // exports the on-screen thread WYSIWYG (#174). Only a truly empty chat (no
// message is normally persisted before model contact, so an interrupted later // live messages and no rows) is non-exportable (the button is hidden too —
// turn still has rows and exports the on-screen partial reply WYSIWYG. // see `canExport`).
if (!activeChatId || !messageRows || messageRows.length === 0) return; 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 // WYSIWYG export: the live on-screen messages ARE the document (so a partial
// reply from an interrupted turn — which never reached the persisted rows — // reply from an interrupted turn — which never reached the persisted rows —
// is exported just as it appears). The persisted rows enrich each live // is exported just as it appears). The persisted rows enrich each live
// message (token usage / error / timestamp) by id and serve as the fallback // 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 // when the live mirror is empty. The on-screen banner is appended too. See
// issue #160. // issues #160 and #174. `chatId` may be null for a not-yet-saved chat — use a
const live = liveThreadRef.current; // placeholder so the header line still renders.
const markdown = buildChatMarkdown({ const markdown = buildChatMarkdown({
title: activeChat?.title ?? null, title: activeChat?.title ?? null,
chatId: activeChatId, chatId: activeChatId ?? "unsaved",
live: live.messages.map((m) => ({ live: live.messages.map((m) => ({
id: m.id, id: m.id,
role: m.role, role: m.role,
@@ -370,7 +406,8 @@ export default function AiChatWindow() {
const width = el.offsetWidth; const width = el.offsetWidth;
const height = el.offsetHeight; const height = el.offsetHeight;
setGeom((prev) => { 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 }; return { ...prev, width, height };
}); });
}); });
@@ -516,11 +553,15 @@ export default function AiChatWindow() {
flash a "0" badge before any token streams in (#151 review). */} flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? ( {liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow> <Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span> <span className={classes.badge}>
{formatTokens(liveTurnTokens)}
</span>
</Tooltip> </Tooltip>
) : contextTokens > 0 ? ( ) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow> <Tooltip label={t("Current context size")} withArrow>
<span className={classes.badge}>{formatTokens(contextTokens)}</span> <span className={classes.badge}>
{formatTokens(contextTokens)}
</span>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
@@ -534,7 +575,11 @@ export default function AiChatWindow() {
aria-label={t("Copy chat")} aria-label={t("Copy chat")}
onClick={handleCopy} onClick={handleCopy}
> >
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />} {clipboard.copied ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)}
</button> </button>
)} )}
<button <button
@@ -642,6 +687,7 @@ export default function AiChatWindow() {
onTurnFinished={onTurnFinished} onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef} liveStateRef={liveThreadRef}
onLiveTurnTokens={setLiveTurnTokens} onLiveTurnTokens={setLiveTurnTokens}
onLiveContentChange={setHasLiveContent}
/> />
)} )}
</div> </div>

View File

@@ -84,6 +84,14 @@ interface ChatThreadProps {
* every streamed delta. Called with `null` when no turn is in flight (the * every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */ * parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void; onLiveTurnTokens?: (tokens: number | null) => void;
/** Reports whether the live thread currently holds at least one message, so the
* parent can gate the "Copy chat" button on the on-screen thread rather than on
* the persisted rows alone. This stays truthy for a brand-new, not-yet-saved
* chat the moment its first user message appears — so an interrupted very first
* turn (no persisted rows yet) is still exportable (#174). Called with `false`
* on unmount so a thread torn down by `key` on chat switch can't leave the
* button enabled for the next, possibly empty, chat. */
onLiveContentChange?: (hasContent: boolean) => void;
} }
/** /**
@@ -129,6 +137,7 @@ export default function ChatThread({
onTurnFinished, onTurnFinished,
liveStateRef, liveStateRef,
onLiveTurnTokens, onLiveTurnTokens,
onLiveContentChange,
}: ChatThreadProps) { }: ChatThreadProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -345,6 +354,18 @@ export default function ChatThread({
}; };
}, [liveStateRef, messages, isStreaming, banner]); }, [liveStateRef, messages, isStreaming, banner]);
// Reactively report "the live thread has content" to the parent. `liveStateRef`
// above is a ref (deliberately non-reactive so streaming deltas don't re-render
// the parent), so the export button needs a SEPARATE reactive signal to flip on
// for a not-yet-persisted chat. Keyed on the boolean only — identical values are
// a no-op setState in the parent, so this does not add per-delta re-renders.
const hasLiveContent = messages.length > 0;
useEffect(() => {
if (!onLiveContentChange) return;
onLiveContentChange(hasLiveContent);
return () => onLiveContentChange(false);
}, [onLiveContentChange, hasLiveContent]);
// Report the live turn-token total to the parent header badge, THROTTLED to // 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 // ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while // streamed delta. The tail assistant message's reasoning+output (estimate while
@@ -366,8 +387,7 @@ export default function ChatThread({
return; return;
} }
const tail = messages[messages.length - 1]; const tail = messages[messages.length - 1];
const live = const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0; const total = live ? live.reasoning + live.output : 0;
const now = Date.now(); const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz) const MIN_INTERVAL = 120; // ms (~8 Hz)

View File

@@ -165,7 +165,9 @@ describe("buildChatMarkdown — tool parts", () => {
], ],
t, t,
}); });
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error"); expect(md).toContain(
"**Tool: Ran tool mysteryTool** (`mysteryTool`) — error",
);
expect(md).toContain("**Error:** boom"); expect(md).toContain("**Error:** boom");
}); });
@@ -307,7 +309,9 @@ describe("buildChatMarkdown — token totals", () => {
row({ row({
role: "assistant", role: "assistant",
content: "x", content: "x",
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } }, metadata: {
usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 },
},
}), }),
], ],
t, t,
@@ -390,8 +394,16 @@ describe("buildChatMarkdown — live (WYSIWYG) source", () => {
// Persisted rows hold only the user turn; the assistant reply is live-only. // Persisted rows hold only the user turn; the assistant reply is live-only.
rows: [row({ id: "u1", role: "user", content: "persisted user" })], rows: [row({ id: "u1", role: "user", content: "persisted user" })],
live: [ live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "on-screen user" }] }), live({
live({ id: "a1", role: "assistant", parts: [{ type: "text", text: "on-screen reply" }] }), 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, isStreaming: false,
t, t,
@@ -435,9 +447,21 @@ describe("buildChatMarkdown — live (WYSIWYG) source", () => {
chatId: "c", chatId: "c",
rows: [], rows: [],
live: [ live: [
live({ id: "a", role: "assistant", parts: [{ type: "text", text: "done earlier" }] }), live({
live({ id: "u", role: "user", parts: [{ type: "text", text: "next q" }] }), id: "a",
live({ id: "b", role: "assistant", parts: [{ type: "text", text: "streaming now" }] }), 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, isStreaming: true,
t, t,
@@ -449,7 +473,13 @@ describe("buildChatMarkdown — live (WYSIWYG) source", () => {
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [], rows: [],
live: [live({ id: "b", role: "assistant", parts: [{ type: "text", text: "final" }] })], live: [
live({
id: "b",
role: "assistant",
parts: [{ type: "text", text: "final" }],
}),
],
isStreaming: false, isStreaming: false,
t, t,
}); });
@@ -466,8 +496,16 @@ describe("buildChatMarkdown — live (WYSIWYG) source", () => {
chatId: "c", chatId: "c",
rows: [], rows: [],
live: [ live: [
live({ id: "a", role: "assistant", parts: [{ type: "text", text: "completed answer" }] }), live({
live({ id: "u", role: "user", parts: [{ type: "text", text: "the new question" }] }), id: "a",
role: "assistant",
parts: [{ type: "text", text: "completed answer" }],
}),
live({
id: "u",
role: "user",
parts: [{ type: "text", text: "the new question" }],
}),
], ],
isStreaming: true, isStreaming: true,
t, t,
@@ -504,12 +542,19 @@ describe("buildChatMarkdown — live enrichment from persisted rows", () => {
role: "assistant", role: "assistant",
content: "x", content: "x",
createdAt: "2026-06-22T10:00:00.000Z", createdAt: "2026-06-22T10:00:00.000Z",
metadata: { usage: { inputTokens: 10, outputTokens: 5 }, error: "rate limited" }, metadata: {
usage: { inputTokens: 10, outputTokens: 5 },
error: "rate limited",
},
}), }),
], ],
live: [ live: [
// Same id as the persisted row, but no usage/error/timestamp on the live msg. // 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" }] }), live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "reply" }],
}),
], ],
isStreaming: false, isStreaming: false,
t, t,
@@ -532,7 +577,9 @@ describe("buildChatMarkdown — live enrichment from persisted rows", () => {
id: "a1", id: "a1",
role: "assistant", role: "assistant",
content: "x", content: "x",
metadata: { usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, metadata: {
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
},
}), }),
], ],
live: [ live: [
@@ -540,7 +587,9 @@ describe("buildChatMarkdown — live enrichment from persisted rows", () => {
id: "a1", id: "a1",
role: "assistant", role: "assistant",
parts: [{ type: "text", text: "reply" }], parts: [{ type: "text", text: "reply" }],
metadata: { usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 } }, metadata: {
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
},
}), }),
], ],
isStreaming: false, isStreaming: false,
@@ -558,7 +607,11 @@ describe("buildChatMarkdown — live enrichment from persisted rows", () => {
rows: [row({ id: "u1", role: "user", content: "q" })], rows: [row({ id: "u1", role: "user", content: "q" })],
live: [ live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }), live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({ id: "a-live", role: "assistant", parts: [{ type: "text", text: "fresh reply" }] }), live({
id: "a-live",
role: "assistant",
parts: [{ type: "text", text: "fresh reply" }],
}),
], ],
isStreaming: false, isStreaming: false,
t, t,
@@ -599,14 +652,18 @@ describe("buildChatMarkdown — fallback + banner", () => {
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [row({ role: "user", content: "q" })], rows: [row({ role: "user", content: "q" })],
live: [live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] })], live: [
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
],
isStreaming: false, isStreaming: false,
banner: "Rate limit reached — try again shortly.", banner: "Rate limit reached — try again shortly.",
t, t,
}); });
expect(md).toContain("_⚠️ Rate limit reached — try again shortly._"); expect(md).toContain("_⚠️ Rate limit reached — try again shortly._");
// Banner comes after the (only) message block. // Banner comes after the (only) message block.
expect(md.indexOf("Rate limit reached")).toBeGreaterThan(md.indexOf("## 1.")); expect(md.indexOf("Rate limit reached")).toBeGreaterThan(
md.indexOf("## 1."),
);
}); });
it("omits the banner block when there is no banner", () => { it("omits the banner block when there is no banner", () => {
@@ -614,7 +671,9 @@ describe("buildChatMarkdown — fallback + banner", () => {
title: "t", title: "t",
chatId: "c", chatId: "c",
rows: [row({ role: "user", content: "q" })], rows: [row({ role: "user", content: "q" })],
live: [live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] })], live: [
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
],
isStreaming: false, isStreaming: false,
banner: null, banner: null,
t, t,
@@ -622,3 +681,67 @@ describe("buildChatMarkdown — fallback + banner", () => {
expect(md).not.toContain("_⚠️"); expect(md).not.toContain("_⚠️");
}); });
}); });
// #174: a brand-new, not-yet-persisted chat whose first turn is streaming (or was
// interrupted) has live messages but NO persisted rows yet, and its chat id is not
// known (the caller passes a placeholder). The export must still capture the
// on-screen thread WYSIWYG from the live messages alone.
describe("buildChatMarkdown — first-turn export with no persisted base (#174)", () => {
it("builds the document from live messages alone when rows are empty", () => {
const md = buildChatMarkdown({
title: null,
chatId: "unsaved",
rows: [],
live: [
live({
id: "u1",
role: "user",
parts: [{ type: "text", text: "hello" }],
}),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "partial reply" }],
}),
],
isStreaming: true,
t,
});
// Both on-screen messages are serialized, numbered from 1.
expect(md).toContain("## 1. You");
expect(md).toContain("hello");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("partial reply");
// The streaming tail assistant is flagged as in-progress.
expect(md).toContain("still being generated");
// The placeholder chat id and the live message count are recorded.
expect(md).toContain("- Chat ID: `unsaved`");
expect(md).toContain("- Messages: 2");
// No persisted timestamp exists for a current-turn live message.
expect(md).not.toContain("<!--");
});
it("captures an interrupted first turn (no rows, not streaming) without a generating note", () => {
const md = buildChatMarkdown({
title: null,
chatId: "unsaved",
rows: [],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "half an answer" }],
}),
],
isStreaming: false,
banner: "Connection dropped — the response was cut off.",
t,
});
expect(md).toContain("half an answer");
// An interrupted (non-streaming) partial is exported as-is, no generating note.
expect(md).not.toContain("still being generated");
// The on-screen banner records the interruption.
expect(md).toContain("_⚠️ Connection dropped — the response was cut off._");
});
});