From e6a58ac131c925222a07106fa0b6df1da131a9bc Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 3 Aug 2026 13:43:42 +0800 Subject: [PATCH] fix(web): render the tool-card subtitle only once its field is complete (no streaming jitter) (#156) Co-authored-by: Claude Fable 5 --- .../web/src/features/chat/tool-call-card.tsx | 46 ++++++++++++++----- packages/web/test/tool-call-preview.test.ts | 35 +++++++++++++- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/web/src/features/chat/tool-call-card.tsx b/packages/web/src/features/chat/tool-call-card.tsx index f35dce7..5da2346 100644 --- a/packages/web/src/features/chat/tool-call-card.tsx +++ b/packages/web/src/features/chat/tool-call-card.tsx @@ -75,35 +75,49 @@ export function shortenPath(p: string): string { export function previewArguments(name: string, argsJson: string): string { if (name === "exec_command") { const cmd = extractStringField(argsJson, "cmd"); - if (cmd !== null) return `$ ${cmd.replace(/\s+/g, " ").trim()}`; + if (cmd !== null) return `$ ${cmd.value.replace(/\s+/g, " ").trim()}`; } if (FILE_TOOLS.has(name)) { const filePath = extractStringField(argsJson, "file_path"); - if (filePath !== null) return shortenPath(filePath.replace(/\s+/g, " ").trim()); + if (filePath !== null) return shortenPath(filePath.value.replace(/\s+/g, " ").trim()); } return argsJson.replace(/\s+/g, " ").trim(); } /** * Collapsed-header subtitle: the human-readable line next to the tool name — the - * model-written `description` argument for the command/subagent tools (declared in their + * model-written `description` argument when the call carries one (declared in the tool's * config schema; per-tool `call_description: false` removes it, in which case the model * never sends it), or the shortened file path for the file tools. Null when there is * nothing beyond the raw arguments. + * + * The subtitle appears once, fully formed, never mid-stream (#137): a growing description + * re-solves the header's flex line every frame, and `shortenPath` on a still-growing path + * rewrites non-monotonically (`/ho` → `…/cc/dev` → `…/dev/x`) — so a field renders only + * after its closing quote. `settled` (arguments finished streaming) lifts that gate: the + * text cannot change anymore, which also covers a call that never closed the string + * (aborted / malformed). Same rule as the CLI's tool-render. The wait is short by + * construction — `description` is required first in schema order, and `file_path` is the + * first file-tool argument — while `write_file`'s `content` may stream long after. */ -export function headerSubtitle(name: string, argsJson: string): string | null { - if (DESCRIBED_TOOLS.has(name)) { +export function headerSubtitle(name: string, argsJson: string, settled = true): string | null { + // The description wins whenever the call carries one: schemas are user-editable, so a + // file tool may have `description` enabled even though the default schema leaves it out + // (the CLI derives the same rule from the session's schemas). + if (DESCRIBED_TOOLS.has(name) || FILE_TOOLS.has(name)) { const desc = extractStringField(argsJson, "description"); if (desc !== null) { - const line = desc.replace(/\s+/g, " ").trim(); + if (!desc.complete && !settled) return null; + const line = desc.value.replace(/\s+/g, " ").trim(); if (line) return line; } - return null; + if (DESCRIBED_TOOLS.has(name)) return null; } if (FILE_TOOLS.has(name)) { const filePath = extractStringField(argsJson, "file_path"); if (filePath !== null) { - const line = filePath.replace(/\s+/g, " ").trim(); + if (!filePath.complete && !settled) return null; + const line = filePath.value.replace(/\s+/g, " ").trim(); if (line) return shortenPath(line); } } @@ -149,8 +163,14 @@ export function pendingFilePayload(name: string, argsJson: string): string | nul return sections.join("\n"); } +/** A string field read from possibly-incomplete JSON: the value seen so far, and whether its closing quote has arrived (mirrors the CLI's PartialField). */ +interface PartialField { + value: string; + complete: boolean; +} + /** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */ -function extractStringField(argsJson: string, field: string): string | null { +function extractStringField(argsJson: string, field: string): PartialField | null { const key = `"${field}"`; const keyIndex = argsJson.indexOf(key); if (keyIndex === -1) return null; @@ -174,10 +194,10 @@ function extractStringField(argsJson: string, field: string): string | null { escaped = true; continue; } - if (ch === '"') return out; + if (ch === '"') return { value: out, complete: true }; out += ch; } - return out; + return { value: out, complete: false }; } export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRenderContext }) { @@ -187,7 +207,9 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen const pending = ctx.pendingApprovals.get(approvalKey(ctx.origin, item.toolCallId)); const preview = previewArguments(item.name, item.argumentsText); - const subtitle = headerSubtitle(item.name, item.argumentsText); + // Settled once argument streaming stopped (or the complete call arrived): the subtitle's + // completeness gate is lifted — whatever is there is final. + const subtitle = headerSubtitle(item.name, item.argumentsText, !item.callStreaming); // Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution). const executing = item.callComplete && !item.outputComplete && !pending; // Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives. diff --git a/packages/web/test/tool-call-preview.test.ts b/packages/web/test/tool-call-preview.test.ts index 9e6807e..f931daf 100644 --- a/packages/web/test/tool-call-preview.test.ts +++ b/packages/web/test/tool-call-preview.test.ts @@ -3,7 +3,8 @@ * the approval row), headerSubtitle surfaces the model-written `description` argument for * the command/subagent tools and the shortened file path for the file tools, and * pendingFilePayload decodes the file-tool arguments so a pending approval shows the actual - * rewrite. All must tolerate incomplete mid-stream JSON. + * rewrite. All must tolerate incomplete mid-stream JSON; headerSubtitle additionally holds a + * still-streaming field back until its closing quote so the header never jitters (#137). */ import { describe, expect, it } from "vitest"; import { @@ -89,6 +90,38 @@ describe("headerSubtitle", () => { "one two", ); }); + + it("holds a still-streaming description back until its closing quote (#137)", () => { + expect(headerSubtitle("exec_command", '{"description":"Read the con', false)).toBeNull(); + // Closing quote arrived: renders even though later arguments are still streaming. + expect( + headerSubtitle("exec_command", '{"description":"Read the config","cmd":"cat co', false), + ).toBe("Read the config"); + }); + + it("holds a still-streaming file path back (shortenPath would rewrite non-monotonically)", () => { + expect(headerSubtitle("read_file", '{"file_path":"/home/us', false)).toBeNull(); + expect(headerSubtitle("write_file", '{"file_path":"/a/b/c.txt","content":"xx', false)).toBe( + "…/b/c.txt", + ); + }); + + it("renders whatever is there once the arguments settled, even unterminated (aborted call)", () => { + expect(headerSubtitle("exec_command", '{"description":"half', true)).toBe("half"); + expect(headerSubtitle("read_file", '{"file_path":"/a/b/c.txt', true)).toBe("…/b/c.txt"); + }); + + it("prefers a complete description over the file path when a file tool carries one (user-enabled schema)", () => { + expect( + headerSubtitle("read_file", '{"description":"Check the config","file_path":"/a/b/c.ts"}'), + ).toBe("Check the config"); + // Description still streaming: nothing renders yet — no path-then-description swap. + expect(headerSubtitle("read_file", '{"description":"Check the co', false)).toBeNull(); + // Empty description falls back to the path. + expect(headerSubtitle("read_file", '{"description":"","file_path":"/a/b/c.ts"}')).toBe( + "…/b/c.ts", + ); + }); }); describe("pendingFilePayload", () => {