diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96e9b..0c2c6620d885 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -18,6 +18,175 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +export const MAX_PROJECTED_TOOL_RESULT_CHARS = 50_000; + +const PROJECTED_TOOL_RESULT_TRUNCATION_MARKER = "…[truncated]"; + +type ValuePath = ReadonlyArray; + +function replaceStringAtPath(value: unknown, path: ValuePath, replacement: string): unknown { + if (path.length === 0) { + return replacement; + } + const [head, ...tail] = path; + if (typeof head === "number" && Array.isArray(value)) { + return value.map((entry, index) => + index === head ? replaceStringAtPath(entry, tail, replacement) : entry, + ); + } + const record = asRecord(value); + if (typeof head === "string" && record) { + return { + ...record, + [head]: replaceStringAtPath(record[head], tail, replacement), + }; + } + return value; +} + +function findDominantTextPath( + value: unknown, + path: ValuePath = [], +): { readonly path: ValuePath; readonly text: string } | null { + if (typeof value === "string") { + return { path, text: value }; + } + + let dominant: { readonly path: ValuePath; readonly text: string } | null = null; + const entries: ReadonlyArray = Array.isArray(value) + ? value.map((entry, index) => [index, entry] as const) + : Object.entries(asRecord(value) ?? {}); + for (const [key, entry] of entries) { + const candidate = findDominantTextPath(entry, [...path, key]); + if (candidate && (!dominant || candidate.text.length > dominant.text.length)) { + dominant = candidate; + } + } + return dominant; +} + +function truncateTextInValue( + value: unknown, + path: ValuePath, + text: string, + serializedTotal: string, +): unknown | null { + let keep = Math.max( + 0, + text.length - + (serializedTotal.length - MAX_PROJECTED_TOOL_RESULT_CHARS) - + PROJECTED_TOOL_RESULT_TRUNCATION_MARKER.length, + ); + let candidate = replaceStringAtPath( + value, + path, + `${text.slice(0, keep)}${PROJECTED_TOOL_RESULT_TRUNCATION_MARKER}`, + ); + + let serializedCandidate: string | undefined; + try { + serializedCandidate = JSON.stringify(candidate); + } catch { + return null; + } + if ( + serializedCandidate !== undefined && + serializedCandidate.length <= MAX_PROJECTED_TOOL_RESULT_CHARS + ) { + return candidate; + } + + const remainingOvershoot = + (serializedCandidate?.length ?? MAX_PROJECTED_TOOL_RESULT_CHARS + 1) - + MAX_PROJECTED_TOOL_RESULT_CHARS; + keep = Math.max(0, keep - remainingOvershoot); + candidate = replaceStringAtPath( + value, + path, + `${text.slice(0, keep)}${PROJECTED_TOOL_RESULT_TRUNCATION_MARKER}`, + ); + try { + serializedCandidate = JSON.stringify(candidate); + } catch { + return null; + } + return serializedCandidate !== undefined && + serializedCandidate.length <= MAX_PROJECTED_TOOL_RESULT_CHARS + ? candidate + : null; +} + +function truncateSerializedValue(serialized: string): string { + const serializedTotal = JSON.stringify(serialized); + const keep = Math.max( + 0, + serialized.length - + (serializedTotal.length - MAX_PROJECTED_TOOL_RESULT_CHARS) - + PROJECTED_TOOL_RESULT_TRUNCATION_MARKER.length, + ); + const candidate = `${serialized.slice(0, keep)}${PROJECTED_TOOL_RESULT_TRUNCATION_MARKER}`; + return JSON.stringify(candidate).length <= MAX_PROJECTED_TOOL_RESULT_CHARS + ? candidate + : PROJECTED_TOOL_RESULT_TRUNCATION_MARKER; +} + +function capProjectedToolValue(value: unknown): { + readonly value: unknown; + readonly truncated: boolean; +} { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + return { value, truncated: false }; + } + if (serialized === undefined || serialized.length <= MAX_PROJECTED_TOOL_RESULT_CHARS) { + return { value, truncated: false }; + } + + const dominant = findDominantTextPath(value); + const truncatedValue = dominant + ? truncateTextInValue(value, dominant.path, dominant.text, serialized) + : null; + if (truncatedValue !== null) { + return { value: truncatedValue, truncated: true }; + } + + return { + value: truncateSerializedValue(serialized), + truncated: true, + }; +} + +const OUTPUT_TOOL_FIELD_KEYS = new Set(["result", "rawOutput", "state", "aggregatedOutput"]); + +function capToolFields( + source: Record, + target: Record, + options: { + readonly capKeys: ReadonlyArray; + readonly copyKeys: ReadonlyArray; + }, +): boolean { + let outputTruncated = false; + for (const key of options.copyKeys) { + if (key in source) { + target[key] = source[key]; + } + } + for (const key of options.capKeys) { + if (!(key in source)) { + continue; + } + const capped = capProjectedToolValue(source[key]); + target[key] = capped.value; + if (OUTPUT_TOOL_FIELD_KEYS.has(key)) { + outputTruncated ||= capped.truncated; + } + } + return outputTruncated; +} + function pushChangedFile(target: string[], seen: Set, value: unknown): void { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { @@ -80,54 +249,30 @@ function collectChangedFiles( } } -function projectCommandData(data: Record): Record | undefined { +function projectCommandData(data: Record): { + readonly item: Record | undefined; + readonly resultTruncated: boolean; +} { const item = asRecord(data.item); if (!item) { - return undefined; + return { item: undefined, resultTruncated: false }; } const projectedItem: Record = {}; - if ("command" in item) { - projectedItem.command = item.command; - } - - const input = asRecord(item.input); - if (input && "command" in input) { - projectedItem.input = { command: input.command }; - } - - const result = asRecord(item.result); - if (result && "command" in result) { - projectedItem.result = { command: result.command }; - } - - return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; -} - -function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); - if (line.length > 0) { - lines.push(line); - } - } + const resultTruncated = capToolFields(item, projectedItem, { + capKeys: ["toolName", "input", "result", "aggregatedOutput"], + copyKeys: ["command", "exitCode", "status"], + }); - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return { + item: Object.keys(projectedItem).length > 0 ? projectedItem : undefined, + resultTruncated, + }; } /** - * Fields of an MCP tool-call item both clients render in the expanded - * work-log row. Everything else — notably `result`, which carries the full - * tool output and dominates wire size on MCP-heavy threads — is summarized - * or dropped. Full payloads remain in persistence. + * Fields of an MCP tool-call item clients use for identity and presentation. + * Result content is retained separately under the tool-output cap. */ const MCP_ITEM_KEPT_FIELDS = [ "type", @@ -141,122 +286,37 @@ const MCP_ITEM_KEPT_FIELDS = [ "durationMs", ] as const; -/** - * Pulls renderable text out of an MCP tool result: either a Codex-style - * `{content: [{type: "text", text}, ...]}` record or a raw Claude - * `tool_result` block whose `content` is a string or block array. - */ -function extractMcpResultText(result: unknown): string | null { - const record = asRecord(result); - if (!record) { - return typeof result === "string" ? result : null; - } - if (typeof record.content === "string") { - return record.content; - } - if (Array.isArray(record.content)) { - const texts: string[] = []; - for (const entry of record.content) { - const text = asRecord(entry)?.text; - if (typeof text === "string" && text.trim().length > 0) { - texts.push(text); - } - } - if (texts.length > 0) { - return texts.join("\n"); - } - } - return null; -} - -function summarizeMcpResult(result: unknown): Record | undefined { - if (result === undefined || result === null) { - return undefined; - } - const text = extractMcpResultText(result); - const summary = text ? summarizeToolTextOutput(text) : null; - return summary ? { content: summary } : undefined; -} - -/** - * MCP tool calls carry full tool results (`data.item.result` on Codex, - * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to - * keep the expanded-row UI working. Keep the fields the UI actually renders - * and summarize the result like regular tool output. - */ function projectMcpToolCallData(data: Record): Record { const projectedData: Record = {}; + let resultTruncated = false; const item = asRecord(data.item); if (item) { const projectedItem: Record = {}; - for (const key of MCP_ITEM_KEPT_FIELDS) { - if (key in item) { - projectedItem[key] = item[key]; - } - } - const result = summarizeMcpResult(item.result); - if (result) { - projectedItem.result = result; - } + resultTruncated ||= capToolFields(item, projectedItem, { + capKeys: ["result"], + copyKeys: MCP_ITEM_KEPT_FIELDS, + }); projectedData.item = projectedItem; } - if ("toolName" in data) { - projectedData.toolName = data.toolName; - } - if ("input" in data) { - projectedData.input = data.input; - } - if (!item) { - const result = summarizeMcpResult(data.result); - if (result) { - projectedData.result = result; - } - } - - if ("toolCallId" in data) { - projectedData.toolCallId = data.toolCallId; - } - if ("kind" in data) { - projectedData.kind = data.kind; - } + resultTruncated ||= capToolFields(data, projectedData, { + capKeys: item + ? ["toolName", "input", "rawOutput", "state"] + : ["toolName", "input", "result", "rawOutput", "state"], + copyKeys: ["tool", "toolCallId", "kind"], + }); const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); if (changedFiles.length > 0) { projectedData.files = changedFiles.map((path) => ({ path })); } - - return projectedData; -} - -function projectRawOutput(value: unknown): Record | undefined { - const rawOutput = asRecord(value); - if (!rawOutput) { - return undefined; + if (resultTruncated) { + projectedData.resultTruncated = true; } - if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) { - return { - totalFiles: rawOutput.totalFiles, - ...(rawOutput.truncated === true ? { truncated: true } : {}), - }; - } - - const content = asTrimmedString(rawOutput.content); - if (content) { - const summary = summarizeToolTextOutput(content); - return summary ? { content: summary } : undefined; - } - - const stdout = asTrimmedString(rawOutput.stdout); - if (stdout) { - const summary = summarizeToolTextOutput(stdout); - return summary ? { content: summary } : undefined; - } - - return undefined; + return projectedData; } /** @@ -283,13 +343,20 @@ export function projectActivityPayload( } const projectedData: Record = {}; - const item = projectCommandData(data); + let resultTruncated = false; + const projectedCommandData = projectCommandData(data); + const item = projectedCommandData.item; if (item) { projectedData.item = item; } - if ("command" in data) { - projectedData.command = data.command; - } + resultTruncated ||= projectedCommandData.resultTruncated; + const itemResultWasRetained = item !== undefined && "result" in item; + resultTruncated ||= capToolFields(data, projectedData, { + capKeys: itemResultWasRetained + ? ["toolName", "input", "rawOutput", "state"] + : ["toolName", "input", "result", "rawOutput", "state"], + copyKeys: ["command", "tool", "toolCallId", "kind"], + }); const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); @@ -298,16 +365,8 @@ export function projectActivityPayload( projectedData.files = changedFiles.map((path) => ({ path })); } - if ("toolCallId" in data) { - projectedData.toolCallId = data.toolCallId; - } - if ("kind" in data) { - projectedData.kind = data.kind; - } - - const rawOutput = projectRawOutput(data.rawOutput); - if (rawOutput) { - projectedData.rawOutput = rawOutput; + if (resultTruncated) { + projectedData.resultTruncated = true; } return { @@ -394,7 +453,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | if (itemType.length === 0 && label.length === 0 && detail.length === 0) { return null; } - return [itemType, label, detail].join(""); + return [itemType, label, detail].join("\u001f"); } /** @@ -441,7 +500,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -461,7 +520,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 49f1b532a53a..103fa16ec376 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -14,6 +14,7 @@ import { buildThreadFeed, type ThreadFeedActivity } from "../../mobile/src/lib/t import { deriveLatestContextWindowSnapshot } from "../../web/src/lib/contextWindow.ts"; import { deriveWorkLogEntries } from "../../web/src/session-logic.ts"; import { + MAX_PROJECTED_TOOL_RESULT_CHARS, projectActivityEvent, projectActivityPayload, projectThreadDetailSnapshot, @@ -79,6 +80,13 @@ const fixtures = [ commandActions: [{ type: "unknown", output: "y".repeat(5_000) }], }, command: "fallback data", + toolName: "Bash", + input: { command: "pnpm test", cwd: "/repo" }, + result: { + type: "tool_result", + content: "full command output\nsecond line", + is_error: false, + }, kind: "execute", toolCallId: "tool-command", rawOutput: { @@ -117,8 +125,15 @@ const fixtures = [ server: "repository", tool: "search", arguments: { query: "activity projection" }, + result: { + content: [ + { type: "text", text: "first MCP result line" }, + { type: "text", text: "second MCP result line" }, + ], + }, aggregatedOutput: "mcp bulk is dropped", }, + result: { content: "duplicate top-level MCP result" }, ignored: "top-level bulk", }), makeActivity("search", "web_search", { @@ -156,7 +171,7 @@ describe("projectActivityPayload", () => { ); } - it("drops unread bulk while retaining command, file, tool, and summary inputs", () => { + it("drops unread bulk while retaining full command results and tool inputs", () => { const projected = projectActivityPayload(fixtures[0]!); expect(projected.payload).toEqual({ itemType: "command_execution", @@ -167,13 +182,19 @@ describe("projectActivityPayload", () => { data: { item: { command: ["bash", "-lc", "pnpm test"], - input: { command: "fallback input" }, - result: { command: "fallback result" }, + input: { command: "fallback input", ignored: "input bulk" }, + result: { command: "fallback result", aggregatedOutput: "x".repeat(10_000) }, }, command: "fallback data", + toolName: "Bash", + input: { command: "pnpm test", cwd: "/repo" }, toolCallId: "tool-command", kind: "execute", - rawOutput: { content: "first useful line" }, + rawOutput: { + content: "\n```\nfirst useful line\nsecond line", + stdout: "unused stdout", + ignored: "raw bulk", + }, }, }); @@ -184,7 +205,55 @@ describe("projectActivityPayload", () => { }); }); - it("slims MCP tool data to the fields the expanded row renders", () => { + it("retains Codex command output and completion metadata", () => { + const projected = projectActivityPayload( + makeActivity("codex-command", "command_execution", { + item: { + type: "commandExecution", + command: "pnpm test", + aggregatedOutput: "output text", + exitCode: 0, + status: "completed", + }, + }), + ); + + expect(projected.payload).toMatchObject({ + data: { + item: { + command: "pnpm test", + aggregatedOutput: "output text", + exitCode: 0, + status: "completed", + }, + }, + }); + }); + + it("caps oversized Codex command output and marks it truncated", () => { + const projected = projectActivityPayload( + makeActivity("codex-command-oversized", "command_execution", { + item: { + type: "commandExecution", + command: "pnpm test", + aggregatedOutput: "x".repeat(MAX_PROJECTED_TOOL_RESULT_CHARS + 1_000), + exitCode: 0, + status: "completed", + }, + }), + ); + const payload = projected.payload as Record; + const data = payload.data as Record; + const item = data.item as Record; + + expect(item.aggregatedOutput).toEqual(expect.stringMatching(/…\[truncated\]$/)); + expect(JSON.stringify(item.aggregatedOutput).length).toBeLessThanOrEqual( + MAX_PROJECTED_TOOL_RESULT_CHARS, + ); + expect(data.resultTruncated).toBe(true); + }); + + it("slims MCP tool data while retaining its full result", () => { expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ itemType: "mcp_tool_call", title: "mcp_tool_call", @@ -196,27 +265,103 @@ describe("projectActivityPayload", () => { server: "repository", tool: "search", arguments: { query: "activity projection" }, + result: { + content: [ + { type: "text", text: "first MCP result line" }, + { type: "text", text: "second MCP result line" }, + ], + }, }, }, }); }); - it("keeps current web and mobile derived output identical for every tool item type", () => { + it("truncates oversized result text and marks the projected data", () => { + const escapedOutput = '"\n\\'.repeat(MAX_PROJECTED_TOOL_RESULT_CHARS); + const projected = projectActivityPayload( + makeActivity("oversized", "command_execution", { + toolName: "Bash", + result: { + type: "tool_result", + content: escapedOutput, + }, + }), + ); + const payload = projected.payload as Record; + const data = payload.data as Record; + const result = data.result as { readonly content: string }; + + expect(result.content.endsWith("…[truncated]")).toBe(true); + expect(JSON.stringify(result).length).toBeLessThanOrEqual(MAX_PROJECTED_TOOL_RESULT_CHARS); + expect(data.resultTruncated).toBe(true); + }); + + it("caps oversized values without text under the serialized limit", () => { + const projected = projectActivityPayload( + makeActivity("oversized-no-text", "command_execution", { + result: Array.from({ length: MAX_PROJECTED_TOOL_RESULT_CHARS }, () => 0), + }), + ); + const payload = projected.payload as Record; + const data = payload.data as Record; + + expect(JSON.stringify(data.result).length).toBeLessThanOrEqual(MAX_PROJECTED_TOOL_RESULT_CHARS); + expect(data.resultTruncated).toBe(true); + }); + + it("leaves unserializable live values unchanged without throwing", () => { + const circular: Record = { content: "live output" }; + circular.self = circular; + + const projected = projectActivityPayload( + makeActivity("circular", "command_execution", { result: circular }), + ); + const payload = projected.payload as Record; + const data = payload.data as Record; + + expect(data.result).toBe(circular); + expect(data).not.toHaveProperty("resultTruncated"); + }); + + it("does not duplicate item results at the top level", () => { + const commandData = (projectActivityPayload(fixtures[0]!).payload as Record) + .data as Record; + const mcpData = (projectActivityPayload(fixtures[4]!).payload as Record) + .data as Record; + + expect(commandData.item).toHaveProperty("result"); + expect(commandData).not.toHaveProperty("result"); + expect(mcpData.item).toHaveProperty("result"); + expect(mcpData).not.toHaveProperty("result"); + }); + + it("does not mark input-only truncation as truncated output", () => { + const projected = projectActivityPayload( + makeActivity("oversized-input", "command_execution", { + input: "x".repeat(MAX_PROJECTED_TOOL_RESULT_CHARS + 1_000), + }), + ); + const payload = projected.payload as Record; + const data = payload.data as Record; + + expect(JSON.stringify(data.input).length).toBeLessThanOrEqual(MAX_PROJECTED_TOOL_RESULT_CHARS); + expect(data).not.toHaveProperty("resultTruncated"); + }); + + it("keeps current web presentation and mobile derived output equivalent", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity); - if (activity === fixtures[4]) { - // MCP is the one deliberate difference: the expanded row's toolData - // loses result bulk but keeps the rendered identity fields. - const [entry] = deriveWorkLogEntries([projected]); - expect(entry?.toolData).toEqual({ - server: "repository", - tool: "search", - arguments: { query: "activity projection" }, - }); - continue; + const [before] = deriveWorkLogEntries([activity]); + const [after] = deriveWorkLogEntries([projected]); + const { toolData: _beforeToolData, ...beforePresentation } = before ?? {}; + const { toolData: _afterToolData, ...afterPresentation } = after ?? {}; + expect(afterPresentation).toEqual(beforePresentation); + expect(after?.toolData).toEqual((projected.payload as { readonly data?: unknown }).data); + // Mobile serializes every MCP item field in its debug expansion, so + // dropping unrelated item bulk is intentionally not byte-equivalent. + if (activity !== fixtures[4]) { + expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); } - expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); - expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); } }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8dc545d31eb0..249b10968ee5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,6 +54,7 @@ import { import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; +import { CopyTextButton } from "./ui/copy-text-button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; @@ -127,7 +128,10 @@ interface MarkdownActionFailureContext { readonly copyTarget?: string; } -function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { +export function reportMarkdownActionFailure( + context: MarkdownActionFailureContext, + cause: unknown, +): void { console.error("[chat-markdown] action failed", context, cause); } @@ -596,49 +600,8 @@ function MarkdownCodeBlock({ theme: "light" | "dark"; children: ReactNode; }) { - const [copied, setCopied] = useState(false); const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); - const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; - const copyLabel = copied ? "Copied" : "Copy code"; - - const handleCopy = useCallback(() => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure( - { - operation: "copy-code-block", - language, - ...(fenceTitle ? { fenceTitle } : {}), - }, - cause, - ); - }); - }, [code, fenceTitle, language]); - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); return (
{wrapLabel} - - - } - > - {copied ? : } - - {copyLabel} - + { + reportMarkdownActionFailure( + { + operation: "copy-code-block", + language, + ...(fenceTitle ? { fenceTitle } : {}), + }, + cause, + ); + }} + />
{children} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f22130906ebf..b82ef362cade 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -125,6 +125,14 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("../ui/copy-text-button", () => ({ + CopyTextButton: ({ text, label }: { text: string; label: string }) => ( + + ), +})); + function matchMedia() { return { matches: false, @@ -134,6 +142,8 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let buildToolCallExpandedBody: typeof import("./MessagesTimeline").buildToolCallExpandedBody; +let WorkEntryExpandedBody: typeof import("./MessagesTimeline").WorkEntryExpandedBody; beforeAll(async () => { const classList = { @@ -167,7 +177,8 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, WorkEntryExpandedBody, buildToolCallExpandedBody } = + await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -226,6 +237,375 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("extracts full provider tool output for expanded work rows", () => { + const makeEntry = (toolData: unknown) => ({ + id: "work-output", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool" as const, + itemType: "command_execution" as const, + toolData, + }); + + expect( + buildToolCallExpandedBody( + makeEntry({ + result: { + type: "tool_result", + content: [ + { type: "text", text: "first Claude line" }, + { type: "text", text: "second Claude line" }, + ], + is_error: true, + }, + resultTruncated: true, + }), + undefined, + ), + ).toEqual({ + blocks: [ + { + kind: "output", + text: "first Claude line\nsecond Claude line", + isError: true, + truncated: true, + }, + ], + }); + expect( + buildToolCallExpandedBody( + makeEntry({ rawOutput: { stdout: "ACP stdout\nsecond line" } }), + undefined, + ), + ).toEqual({ + blocks: [ + { + kind: "output", + text: "ACP stdout\nsecond line", + isError: false, + truncated: false, + }, + ], + }); + expect( + buildToolCallExpandedBody( + makeEntry({ tool: "bash", state: { status: "completed", output: "OpenCode output" } }), + undefined, + ), + ).toEqual({ + blocks: [ + { + kind: "output", + text: "OpenCode output", + isError: false, + truncated: false, + }, + ], + }); + expect( + buildToolCallExpandedBody(makeEntry({ result: { values: [1, 2] } }), undefined), + ).toMatchObject({ blocks: [{ kind: "output", text: expect.stringContaining('"values": [') }] }); + }); + + it("uses the first tool result candidate with renderable text", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-priority", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { + result: { command: "pnpm test", exitCode: 0 }, + rawOutput: { stdout: "real stdout" }, + state: { status: "error", error: "unrelated state error" }, + }, + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "output", text: "real stdout", isError: false, truncated: false }], + }); + }); + + it("extracts Codex command output and its error state", () => { + const makeEntry = (exitCode: number) => ({ + id: `work-codex-output-${exitCode}`, + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool" as const, + itemType: "command_execution" as const, + toolData: { + item: { + command: "x", + aggregatedOutput: "hello", + exitCode, + status: "completed", + }, + }, + }); + + expect(buildToolCallExpandedBody(makeEntry(0), undefined)).toEqual({ + blocks: [{ kind: "output", text: "hello", isError: false, truncated: false }], + }); + expect(buildToolCallExpandedBody(makeEntry(1), undefined)).toEqual({ + blocks: [{ kind: "output", text: "hello", isError: true, truncated: false }], + }); + }); + + it("prefers Claude result output over Codex item output", () => { + const body = buildToolCallExpandedBody( + { + id: "work-provider-output-priority", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { + result: { content: "Claude output", is_error: false }, + item: { + command: "x", + aggregatedOutput: "Codex output", + exitCode: 1, + status: "failed", + }, + }, + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "output", text: "Claude output", isError: false, truncated: false }], + }); + }); + + it("treats a null state error as successful output", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-null-error", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "dynamic_tool_call", + toolData: { state: { status: "completed", error: null, output: "successful output" } }, + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "output", text: "successful output", isError: false, truncated: false }], + }); + }); + + it("deduplicates expanded blocks already visible in the row", () => { + const command = 'Bash: uname -a && echo "---" && date'; + const body = buildToolCallExpandedBody( + { + id: "work-output-deduplicated", + createdAt: MESSAGE_CREATED_AT, + label: command, + tone: "tool", + itemType: "command_execution", + command, + rawCommand: command, + detail: command, + toolData: { rawOutput: { stdout: "Linux example output" } }, + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "output", text: "Linux example output", isError: false, truncated: false }], + copyableCommand: command, + }); + expect( + buildToolCallExpandedBody( + { + id: "work-output-fully-deduplicated", + createdAt: MESSAGE_CREATED_AT, + label: command, + tone: "tool", + itemType: "command_execution", + command, + detail: command, + }, + undefined, + ), + ).toEqual({ + blocks: [{ kind: "empty" }], + copyableCommand: command, + }); + }); + + it("keeps command and detail text out of command execution bodies", () => { + const body = buildToolCallExpandedBody( + { + id: "work-raw-command-visible", + createdAt: MESSAGE_CREATED_AT, + label: "Bash", + tone: "tool", + itemType: "command_execution", + command: "short preview", + rawCommand: "the complete raw command", + detail: "short preview", + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "empty" }], + copyableCommand: "the complete raw command", + }); + expect(body?.blocks).not.toContainEqual(expect.objectContaining({ kind: "text" })); + }); + + it("keeps command and detail blocks for non-command entries", () => { + const body = buildToolCallExpandedBody( + { + id: "work-mcp-call", + createdAt: MESSAGE_CREATED_AT, + label: "MCP call", + tone: "tool", + itemType: "mcp_tool_call", + command: "short preview", + rawCommand: "the complete raw command", + detail: "README.md contents", + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [ + { kind: "text", text: "the complete raw command" }, + { kind: "text", text: "README.md contents" }, + ], + }); + expect( + buildToolCallExpandedBody( + { + id: "work-empty-non-command", + createdAt: MESSAGE_CREATED_AT, + label: "Web search", + tone: "tool", + itemType: "web_search", + }, + undefined, + ), + ).toBeNull(); + }); + + it("keeps command executions without output expandable", () => { + const body = buildToolCallExpandedBody( + { + id: "work-command-no-output", + createdAt: MESSAGE_CREATED_AT, + label: "Bash", + tone: "tool", + itemType: "command_execution", + command: "pwd", + }, + undefined, + ); + + expect(body).not.toBeNull(); + expect(body).toEqual({ + blocks: [{ kind: "empty" }], + copyableCommand: "pwd", + }); + + const markup = renderToStaticMarkup(); + expect(markup).toContain("(No output)"); + expect(markup).not.toContain("Copy output"); + }); + + it("labels only error output and retains the truncation notice", () => { + const normalBody = buildToolCallExpandedBody( + { + id: "work-output-normal-label", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { rawOutput: { stdout: "normal text" } }, + }, + undefined, + ); + const errorBody = buildToolCallExpandedBody( + { + id: "work-output-error-label", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { + result: { content: "failed text", is_error: true }, + resultTruncated: true, + }, + }, + undefined, + ); + expect(normalBody).not.toBeNull(); + expect(errorBody).not.toBeNull(); + + const normalMarkup = renderToStaticMarkup(); + const errorMarkup = renderToStaticMarkup(); + + expect(normalMarkup).toContain("normal text"); + expect(normalMarkup).not.toContain("Output\nnormal text"); + expect(errorMarkup).toContain("Error output\nfailed text\n\nOutput truncated"); + }); + + it("passes only the raw extracted output to the copy button", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-copy", + createdAt: MESSAGE_CREATED_AT, + label: "Bash", + tone: "tool", + itemType: "command_execution", + command: "short preview", + rawCommand: "a much longer command", + detail: "auxiliary detail", + toolData: { rawOutput: { stdout: "raw output text" } }, + }, + undefined, + ); + expect(body).toEqual({ + blocks: [{ kind: "output", text: "raw output text", isError: false, truncated: false }], + copyableCommand: "a much longer command", + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-copy-text="raw output text"'); + expect(markup).toContain("Copy output"); + expect(markup).not.toContain('data-copy-text="a much longer command"'); + expect(markup).not.toContain("Copy command"); + expect(markup).not.toContain("a much longer command"); + expect(markup).not.toContain("auxiliary detail"); + expect(markup).not.toContain('data-copy-text="auxiliary detail"'); + }); + + it("offers command copy only for command executions", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-search", + createdAt: MESSAGE_CREATED_AT, + label: "Web search", + tone: "tool", + itemType: "web_search", + command: "some query", + toolData: { rawOutput: { content: "search results" } }, + }, + undefined, + ); + expect(body?.copyableCommand).toBeUndefined(); + + const markup = renderToStaticMarkup(); + expect(markup).not.toContain("Copy command"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c6e28dcef5c5..7d398090e38c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,7 +44,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; -import ChatMarkdown from "../ChatMarkdown"; +import ChatMarkdown, { reportMarkdownActionFailure } from "../ChatMarkdown"; import { BotIcon, CheckIcon, @@ -66,6 +66,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { CopyTextButton } from "../ui/copy-text-button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; @@ -2025,6 +2026,22 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } +function workEntryDisplayText(workEntry: TimelineWorkEntry, workspaceRoot: string | undefined) { + const heading = toolWorkEntryHeading(workEntry); + const rawPreview = workEntryPreview(workEntry, workspaceRoot); + const preview = + rawPreview && + normalizeCompactToolLabel(rawPreview).toLowerCase() === + normalizeCompactToolLabel(heading).toLowerCase() + ? null + : rawPreview; + return { + heading, + preview, + displayText: preview ? `${heading} - ${preview}` : heading, + }; +} + function workEntryRawCommand( workEntry: Pick, ): string | null { @@ -2035,34 +2052,264 @@ function workEntryRawCommand( return rawCommand === workEntry.command.trim() ? null : rawCommand; } -function buildToolCallExpandedBody( +function asToolDataRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function prettyPrintToolValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} + +function extractKnownToolText(value: unknown): string | null { + if (typeof value === "string") { + return value.trim().length > 0 ? value : null; + } + if (Array.isArray(value)) { + const texts = value + .map((entry) => extractKnownToolText(entry)) + .filter((entry): entry is string => entry !== null); + return texts.length > 0 ? texts.join("\n") : null; + } + const record = asToolDataRecord(value); + if (!record) { + return null; + } + for (const key of [ + "content", + "stdout", + "output", + "aggregatedOutput", + "text", + "diff", + "patch", + "error", + "message", + ]) { + if (!(key in record)) { + continue; + } + const text = extractKnownToolText(record[key]); + if (text) { + return text; + } + } + return null; +} + +function extractToolResultOutput(toolData: unknown): { + readonly text: string; + readonly isError: boolean; + readonly truncated: boolean; +} | null { + const data = asToolDataRecord(toolData); + if (!data) { + return null; + } + const item = asToolDataRecord(data.item); + const rawOutput = data.rawOutput; + const state = data.state; + const result = data.result ?? item?.result; + const content = data.content; + const candidates = [ + { kind: "result", value: result }, + { kind: "rawOutput", value: rawOutput }, + { kind: "state", value: state }, + { kind: "content", value: content }, + { kind: "item", value: item }, + ] as const; + for (const candidate of candidates) { + const text = extractKnownToolText(candidate.value); + if (!text) { + continue; + } + const record = asToolDataRecord(candidate.value); + return { + text, + isError: + candidate.kind === "result" + ? record?.is_error === true + : candidate.kind === "state" + ? record?.status === "error" || record?.error != null + : candidate.kind === "item" + ? record?.status === "failed" || + (typeof record?.exitCode === "number" && record.exitCode !== 0) + : false, + truncated: data.resultTruncated === true, + }; + } + + const fallback = result ?? content; + if (fallback === undefined || fallback === null) { + return null; + } + const text = prettyPrintToolValue(fallback); + if (text.trim().length === 0) { + return null; + } + return { + text, + isError: fallback === result && asToolDataRecord(result)?.is_error === true, + truncated: data.resultTruncated === true, + }; +} + +function mcpToolCallMetadata(toolData: unknown): unknown { + const data = asToolDataRecord(toolData); + const source = asToolDataRecord(data?.item) ?? data; + if (!source) { + return toolData; + } + const { + result: _result, + rawOutput: _rawOutput, + state: _state, + resultTruncated: _resultTruncated, + ...metadata + } = source; + return metadata; +} + +type ToolCallExpandedBodyBlock = + | { + readonly kind: "output"; + readonly text: string; + readonly isError: boolean; + readonly truncated: boolean; + } + | { readonly kind: "empty" } + | { readonly kind: "text"; readonly text: string }; + +export interface ToolCallExpandedBody { + readonly blocks: readonly ToolCallExpandedBodyBlock[]; + readonly copyableCommand?: string; +} + +export function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, -): string | null { - const blocks: string[] = []; +): ToolCallExpandedBody | null { + const { heading, preview, displayText } = workEntryDisplayText(workEntry, workspaceRoot); + const seen = new Set( + [heading, preview, displayText] + .filter((value): value is string => value !== null) + .map((value) => value.trim()) + .filter(Boolean), + ); + const blocks: ToolCallExpandedBodyBlock[] = []; + const copyableCommand = + workEntry.itemType === "command_execution" + ? (workEntry.rawCommand ?? workEntry.command)?.trim() || undefined + : undefined; + const addTextBlock = (text: string) => { + const trimmed = text.trim(); + if (!trimmed || seen.has(trimmed)) { + return; + } + seen.add(trimmed); + blocks.push({ kind: "text", text: trimmed }); + }; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { - blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); + addTextBlock(`MCP call\n${prettyPrintToolValue(mcpToolCallMetadata(workEntry.toolData))}`); } - const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - blocks.push(raw.trim()); - } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); + let hasOutput = false; + const resultOutput = extractToolResultOutput(workEntry.toolData); + if (resultOutput) { + const trimmed = resultOutput.text.trim(); + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed); + if (resultOutput.truncated) { + seen.add("Output truncated"); + } + blocks.push({ kind: "output", ...resultOutput }); + hasOutput = true; + } } - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + if (workEntry.itemType === "command_execution") { + if (!hasOutput) { + blocks.push({ kind: "empty" }); + } + } else { + const raw = workEntryRawCommand(workEntry); + if (raw?.trim()) { + addTextBlock(raw); + } else if (workEntry.command?.trim()) { + addTextBlock(workEntry.command); + } + if (workEntry.detail?.trim()) { + addTextBlock(workEntry.detail); + } } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { - blocks.push( + addTextBlock( changedFiles .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) .join("\n"), ); } - return blocks.length > 0 ? blocks.join("\n\n") : null; + if (blocks.length === 0) { + return null; + } + return copyableCommand ? { blocks, copyableCommand } : { blocks }; } +export const WorkEntryExpandedBody = memo(function WorkEntryExpandedBody(props: { + body: ToolCallExpandedBody; +}) { + const { blocks } = props.body; + return ( +
+ {blocks.map((block) => + block.kind === "output" ? ( +
+
+              {block.isError ? "Error output\n" : null}
+              {block.text}
+              {block.truncated ? "\n\nOutput truncated" : null}
+            
+
+ { + reportMarkdownActionFailure({ operation: "copy-tool-output" }, cause); + }} + /> +
+
+ ) : block.kind === "empty" ? ( +

+ (No output) +

+ ) : ( +
+            {block.text}
+          
+ ), + )} +
+ ); +}); + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2233,17 +2480,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); - const preview = - rawPreview && - normalizeCompactToolLabel(rawPreview).toLowerCase() === - normalizeCompactToolLabel(heading).toLowerCase() - ? null - : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; - const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); + const { heading, preview, displayText } = workEntryDisplayText(workEntry, workspaceRoot); + const previewIsCommand = preview !== null && preview === workEntry.command; + const expandedBody = useMemo( + () => buildToolCallExpandedBody(workEntry, workspaceRoot), + [workEntry, workspaceRoot], + ); const canExpand = expandedBody !== null; + // Expanding the row also unfurls the truncated header command: one gesture + // reveals full detail (command + output) without a separate toggle. + const commandUnfurled = previewIsCommand && expanded; const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && @@ -2286,29 +2532,69 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
-
+
-
+

{heading} {preview && ( - {preview} + + {preview} + )}

+ {expandedBody?.copyableCommand ? ( + { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + > + { + reportMarkdownActionFailure({ operation: "copy-tool-command" }, cause); + }} + /> + + ) : null}
- {expanded && canExpand && expandedBody ? ( -
-
-            {expandedBody}
-          
-
- ) : null} + {expanded && expandedBody ? : null}
); }); diff --git a/apps/web/src/components/ui/copy-text-button.tsx b/apps/web/src/components/ui/copy-text-button.tsx new file mode 100644 index 000000000000..3ead8c4542e7 --- /dev/null +++ b/apps/web/src/components/ui/copy-text-button.tsx @@ -0,0 +1,70 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { cn } from "~/lib/utils"; +import { Button } from "./button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip"; + +export const CopyTextButton = memo(function CopyTextButton(props: { + text: string; + label: string; + copiedLabel?: string; + className?: string; + icon?: ReactNode; + onCopyError?: (cause: unknown) => void; +}) { + const { text, label, copiedLabel = "Copied", className, icon, onCopyError } = props; + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + const currentLabel = copied ? copiedLabel : label; + + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return; + } + void navigator.clipboard + .writeText(text) + .then(() => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 1200); + }) + .catch((cause) => { + onCopyError?.(cause); + }); + }, [onCopyError, text]); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + }, + [], + ); + + return ( + + + } + > + {copied ? : (icon ?? )} + + {currentLabel} + + ); +}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4ae476c1d11f..20315c8c1c82 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -55,8 +55,11 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); // exists for rollback safety: a pre-pagination client would decode a windowed // v2 record, silently drop the unknown `page` field, and treat the partial // thread as complete forever. Older entries fail to decode → cold cache. +// v4 invalidates snapshots cached before the wire projection retained tool +// output fields (result/aggregatedOutput); a warm cache resumes via +// `afterSequence` and would otherwise show stripped payloads forever. const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(3), + schemaVersion: Schema.Literal(4), environmentId: EnvironmentId, threadId: ThreadId, snapshot: OrchestrationThreadDetailSnapshot, @@ -564,7 +567,7 @@ export const connectionStorageLayer = Layer.effectContext( saveThread: (environmentId, snapshot) => Effect.gen(function* () { const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: 3, + schemaVersion: 4, environmentId, threadId: snapshot.thread.id, snapshot, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8600d0048546..2fc90adc09c3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -399,6 +399,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil var(--app-scrollbar-width) 100%; } + /* Truncated work-log previews fade out instead of ellipsizing: CSS + text-overflow keeps a trailing space at arbitrary clip points, which + reads as a "…" detached from the text at most zoom levels. */ + .work-entry-preview-clip { + -webkit-mask-image: linear-gradient(to right, black calc(100% - 1.75rem), transparent); + mask-image: linear-gradient(to right, black calc(100% - 1.75rem), transparent); + } + /* The pull request list sits directly under its topbar, so the tall band the chat and settings pages fade under would read as empty padding here. A shorter band keeps the fade while letting the controls start near the chrome. */ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f5effff6602c..f1ea28dda0ae 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -928,6 +928,37 @@ describe("deriveWorkLogEntries", () => { expect(entry?.command).toBe("bun run lint"); }); + it("prefers the structured input command and never the Bash-prefixed detail", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "claude-command-tool", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: "Bash: uname -a && date", + data: { + toolName: "Bash", + input: { command: "uname -a && date" }, + }, + }, + }), + makeActivity({ + id: "detail-only-command-tool", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: "Bash: ls -la ", + }, + }), + ]; + + const [structured, detailOnly] = deriveWorkLogEntries(activities); + expect(structured?.command).toBe("uname -a && date"); + expect(detailOnly?.command).toBe("ls -la"); + }); + it("extracts failed tool lifecycle status from item payloads", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -989,7 +1020,7 @@ describe("deriveWorkLogEntries", () => { const [entry] = deriveWorkLogEntries(activities); expect(entry?.toolTitle).toBe("t3-code · preview_status"); - expect(entry?.toolData).toEqual(item); + expect(entry?.toolData).toEqual({ item }); }); it("keeps MCP payloads while collapsing lifecycle updates", () => { @@ -1023,7 +1054,79 @@ describe("deriveWorkLogEntries", () => { ]; const [entry] = deriveWorkLogEntries(activities); - expect(entry?.toolData).toEqual(item); + expect(entry?.toolData).toEqual({ item }); + }); + + it("keeps the latest non-MCP tool result while collapsing lifecycle updates", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-progress", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + toolCallId: "command-1", + rawOutput: { stdout: "partial output" }, + }, + }, + }), + makeActivity({ + id: "command-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + toolCallId: "command-1", + rawOutput: { stdout: "full output" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolData).toEqual({ + toolCallId: "command-1", + rawOutput: { stdout: "full output" }, + }); + }); + + it("keeps rich update tool data when the completion payload is sparse", () => { + const updateToolData = { + toolCallId: "command-sparse", + rawOutput: { stdout: "complete output from update" }, + }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-sparse-progress", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: updateToolData, + }, + }), + makeActivity({ + id: "command-sparse-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + toolCallId: "command-sparse", + toolName: "Bash", + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolData).toEqual(updateToolData); }); it("unwraps PowerShell command wrappers for displayed command text", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..325ea47cacc7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -862,10 +862,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } - if (itemType === "mcp_tool_call") { + if (itemType) { const data = asRecord(payload?.data); - if (data?.item !== undefined) { - entry.toolData = data.item; + if (data) { + entry.toolData = data; } } if (itemType) { @@ -1042,7 +1042,11 @@ function mergeDerivedWorkLogEntries( const collapseKey = next.collapseKey ?? previous.collapseKey; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; - const toolData = next.toolData ?? previous.toolData; + const nextToolData = asRecord(next.toolData); + const nextToolDataHasContent = + nextToolData !== null && + ["item", "result", "rawOutput", "state"].some((key) => key in nextToolData); + const toolData = nextToolDataHasContent ? next.toolData : previous.toolData; return { ...previous, ...next, @@ -1078,7 +1082,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un entry.taskId && (entry.activityKind === "task.progress" || entry.activityKind === "task.completed") ) { - return `task${entry.taskId}`; + return `task\u001f${entry.taskId}`; } if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; @@ -1283,14 +1287,18 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + const dataInput = asRecord(data?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ item?.command, itemInput?.command, + dataInput?.command, itemResult?.command, data?.command, - itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, + itemType === "command_execution" && detail + ? stripToolNamePrefix(stripTrailingExitCode(detail).output) + : null, ]; for (const candidate of candidates) { @@ -1422,6 +1430,12 @@ function extractToolDetail( return null; } +// Claude's command_execution detail is "Bash: "; the prefix must not +// leak into the extracted command (previews, copy-command). +function stripToolNamePrefix(value: string | null): string | null { + return value ? value.replace(/^bash:\s+/i, "") : value; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined;