From 10a7ded8d9f97ba66424c829b889e3bfe0c3d5e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 8 Jun 2026 11:00:34 +0100 Subject: [PATCH 01/10] Render command and file-change details inline - Preserve command output, exit code, duration, and patches - Add expandable timeline detail rows for command and file changes - Document debug browser launch workflow --- .../chat/MessagesTimeline.logic.test.ts | 23 ++ .../components/chat/MessagesTimeline.logic.ts | 11 + .../components/chat/MessagesTimeline.test.tsx | 65 +++ .../src/components/chat/MessagesTimeline.tsx | 374 +++++++++++++++++- apps/web/src/session-logic.test.ts | 183 +++++++++ apps/web/src/session-logic.ts | 296 ++++++++++++++ 6 files changed, 949 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5b643263fe0..76ff6a1f1f2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3,6 +3,8 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, + getRenderableCommandOutputLines, + hasRenderableCommandOutput, normalizeCompactToolLabel, resolveAssistantMessageCopyState, } from "./MessagesTimeline.logic"; @@ -204,6 +206,27 @@ describe("resolveAssistantMessageCopyState", () => { }); }); +describe("hasRenderableCommandOutput", () => { + it("hides nullish and empty command output streams", () => { + expect(hasRenderableCommandOutput(undefined)).toBe(false); + expect(hasRenderableCommandOutput(null)).toBe(false); + expect(hasRenderableCommandOutput("")).toBe(false); + }); + + it("renders command output streams when the provider emitted content", () => { + expect(hasRenderableCommandOutput("stdout\n")).toBe(true); + expect(hasRenderableCommandOutput(" ")).toBe(false); + expect(hasRenderableCommandOutput("\n\t\n")).toBe(false); + }); + + it("removes empty and whitespace-only command output lines", () => { + expect(getRenderableCommandOutputLines("\nstdout\n \n\t\nstderr\n")).toEqual([ + "stdout", + "stderr", + ]); + }); +}); + describe("deriveMessagesTimelineRows", () => { it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ad54dd8bdeb..2d8065a5a6c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -84,6 +84,17 @@ export function resolveAssistantMessageCopyState({ }; } +export function hasRenderableCommandOutput(value: string | null | undefined): value is string { + return getRenderableCommandOutputLines(value).length > 0; +} + +export function getRenderableCommandOutputLines(value: string | null | undefined): string[] { + if (typeof value !== "string" || value.length === 0) { + return []; + } + return value.split(/\r?\n/u).filter((line) => line.trim().length > 0); +} + function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { const lastAssistantMessageIdByResponseKey = new Map(); let nullTurnResponseIndex = 0; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f5d483aa4ce..d9e6c8d3d33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -258,6 +258,71 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("renders command work entries as expandable rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const stdout = Array.from({ length: 45 }, (_, index) => `stdout ${index + 1}`).join("\n"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ran command"); + expect(markup).toContain("vp test"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Ran command"'); + }); + + it("renders file-change work entries as expandable rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Changed files"); + expect(markup).toContain("apps/web/src/session-logic.ts"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Changed files"'); + }); + it("renders review comment contexts as structured cards instead of raw tags", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c2fb44c80a9..0b997ef10d5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -13,11 +13,13 @@ import { useMemo, useRef, useState, + type KeyboardEvent, type ReactNode, } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; -import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; +import type { FileDiffMetadata, Hunk } from "@pierre/diffs/types"; +import { deriveTimelineEntries, formatDuration, formatElapsed } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { @@ -29,6 +31,8 @@ import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, + ChevronDownIcon, + ChevronRightIcon, CircleAlertIcon, EyeIcon, GlobeIcon, @@ -48,6 +52,8 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, + getRenderableCommandOutputLines, + hasRenderableCommandOutput, MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, normalizeCompactToolLabel, @@ -109,6 +115,7 @@ const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const COMMAND_OUTPUT_TAIL_LINES = 40; // --------------------------------------------------------------------------- // Props (public API) @@ -1172,11 +1179,322 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } +function ToolDetailBlock(props: { + title: string; + children: ReactNode; + mono?: boolean; + tone?: "default" | "error"; +}) { + return ( +
+

+ {props.title} +

+
+ {props.children} +
+
+ ); +} + +function hasExpandableWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + if (isCommandWorkEntry(workEntry)) { + return Boolean( + workEntry.command || + workEntry.rawCommand || + workEntry.output || + workEntry.stdout || + workEntry.stderr || + workEntry.exitCode !== undefined || + workEntry.durationMs !== undefined, + ); + } + if (isFileChangeWorkEntry(workEntry)) { + return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); + } + return false; +} + +function ToolEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { + if (isCommandWorkEntry(workEntry)) { + return ; + } + if (isFileChangeWorkEntry(workEntry)) { + return ; + } + return null; +} + +function isCommandWorkEntry(workEntry: TimelineWorkEntry): boolean { + if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { + return true; + } + if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { + return false; + } + if (workEntry.itemType || workEntry.requestKind) { + return ( + (workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call") && + hasCommandWorkEntryCommand(workEntry) + ); + } + return hasCommandWorkEntryCommand(workEntry); +} + +function hasCommandWorkEntryCommand(workEntry: TimelineWorkEntry): boolean { + return Boolean(workEntry.command || workEntry.rawCommand); +} + +function isFileChangeWorkEntry(workEntry: TimelineWorkEntry): boolean { + if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { + return true; + } + if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { + return false; + } + return Boolean(workEntry.patch); +} + +function CommandEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { + const command = workEntry.command ?? workEntry.rawCommand ?? null; + const rawCommand = + workEntry.rawCommand && workEntry.rawCommand !== command ? workEntry.rawCommand : null; + const hasStreamOutput = + hasRenderableCommandOutput(workEntry.stdout) || hasRenderableCommandOutput(workEntry.stderr); + + return ( +
+ {command && ( + + {command} + + )} + {rawCommand && } +
+ + Exit code {workEntry.exitCode ?? "unknown"} + + + Duration{" "} + {workEntry.durationMs !== undefined ? formatDuration(workEntry.durationMs) : "unknown"} + +
+ {hasRenderableCommandOutput(workEntry.stdout) ? ( + + ) : null} + {hasRenderableCommandOutput(workEntry.stderr) ? ( + + ) : null} + {!hasStreamOutput && hasRenderableCommandOutput(workEntry.output) ? ( + + ) : null} +
+ ); +} + +function CollapsedRawCommandBlock({ value }: { value: string }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded ? ( +
+ {value} +
+ ) : null} +
+ ); +} + +function CommandOutputBlock(props: { title: string; value: string; tone?: "default" | "error" }) { + const [showFull, setShowFull] = useState(false); + const lines = useMemo(() => getRenderableCommandOutputLines(props.value), [props.value]); + const isTruncated = lines.length > COMMAND_OUTPUT_TAIL_LINES; + const toggleLabel = `${showFull ? "Collapse" : "Expand"} ${props.title}`; + const visibleValue = + showFull || !isTruncated + ? lines.join("\n") + : lines.slice(-COMMAND_OUTPUT_TAIL_LINES).join("\n"); + const suffix = isTruncated + ? showFull + ? `${lines.length.toLocaleString()} lines` + : `last ${COMMAND_OUTPUT_TAIL_LINES} of ${lines.length.toLocaleString()} lines` + : `${lines.length.toLocaleString()} line${lines.length === 1 ? "" : "s"}`; + + return ( +
+ + +
+ ); +} + +function FileChangeEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { + const ctx = use(TimelineRowCtx); + const renderablePatch = getRenderablePatch( + workEntry.patch, + `tool-file-change:${workEntry.id}:${ctx.resolvedTheme}`, + ); + const hasInlineDiff = renderablePatch?.kind === "files"; + + return ( +
+ {!hasInlineDiff && (workEntry.changedFiles?.length ?? 0) > 0 && ( +
+ {workEntry.changedFiles?.map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, ctx.workspaceRoot); + return ( + + {displayPath} + + ); + })} +
+ )} + {hasInlineDiff && + renderablePatch.files.map((fileDiff) => ( + ( + + )} + options={{ + collapsed: false, + diffStyle: "unified", + theme: resolveDiffThemeName(ctx.resolvedTheme), + }} + /> + ))} + {renderablePatch?.kind === "raw" && ( + + {renderablePatch.text} + + )} +
+ ); +} + +function InlineFileDiffHeader({ + fileDiff, + changedFiles, + workspaceRoot, +}: { + fileDiff: FileDiffMetadata; + changedFiles: ReadonlyArray | undefined; + workspaceRoot: string | undefined; +}) { + const displayPath = resolveInlineFileDiffDisplayPath(fileDiff, changedFiles, workspaceRoot); + const additions = countDiffHunkChangedLines(fileDiff.hunks, "additionLines"); + const deletions = countDiffHunkChangedLines(fileDiff.hunks, "deletionLines"); + + return ( +
+ + {displayPath} + + + + +
+ ); +} + +function resolveInlineFileDiffDisplayPath( + fileDiff: FileDiffMetadata, + changedFiles: ReadonlyArray | undefined, + workspaceRoot: string | undefined, +): string { + const rawPath = resolveFileDiffPath(fileDiff); + const normalizedRawPath = rawPath.replace(/\\/gu, "/"); + const matchedChangedFile = changedFiles?.find((filePath) => { + const normalizedChangedFile = filePath.replace(/\\/gu, "/"); + return ( + normalizedChangedFile === normalizedRawPath || + normalizedChangedFile.endsWith(`/${normalizedRawPath}`) || + normalizedRawPath.endsWith(`/${normalizedChangedFile.replace(/^\/+/u, "")}`) + ); + }); + + return formatWorkspaceRelativePath(matchedChangedFile ?? rawPath, workspaceRoot); +} + +function countDiffHunkChangedLines( + hunks: ReadonlyArray, + lineCountKey: "additionLines" | "deletionLines", +): number { + let count = 0; + for (const hunk of hunks) { + count += hunk[lineCountKey]; + } + return count; +} + const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; + const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); @@ -1191,10 +1509,45 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + const canExpand = hasExpandableWorkEntryDetails(workEntry); + const ToggleIcon = expanded ? ChevronDownIcon : ChevronRightIcon; + const toggleExpanded = useCallback(() => { + if (!canExpand) { + return; + } + setExpanded((value) => !value); + }, [canExpand]); + const handleSummaryKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!canExpand || (event.key !== "Enter" && event.key !== " ")) { + return; + } + event.preventDefault(); + toggleExpanded(); + }, + [canExpand, toggleExpanded], + ); return ( -
-
+
+
@@ -1267,6 +1620,20 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { )}
+ {canExpand && ( + + )}
{hasChangedFiles && !previewIsChangedFiles && (
@@ -1289,6 +1656,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { )}
)} + {canExpand && expanded && }
); }); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index d3fccb3d421..362f447aa89 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -890,11 +890,78 @@ describe("deriveWorkLogEntries", () => { expect(entry).toMatchObject({ command: "bun run dev", detail: '{ "dev": "vite dev --port 3000" }', + output: '{ "dev": "vite dev --port 3000" }', + exitCode: 0, itemType: "command_execution", toolTitle: "bash", }); }); + it("keeps command stdout, stderr, exit code, and duration for expanded command details", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + stderr: "warning\n", + exitCode: 1, + durationMs: 1250, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry).toMatchObject({ + command: "vp test", + stdout: "line 1\nline 2\n", + stderr: "warning\n", + exitCode: 1, + durationMs: 1250, + }); + }); + + it("keeps Codex command execution item output, exit code, and duration", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-command-tool-output", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: `/opt/homebrew/bin/bash -lc "printf 'stdout ui smoke test\\\\n'"`, + data: { + item: { + aggregatedOutput: "stdout ui smoke test\n", + command: `/opt/homebrew/bin/bash -lc "printf 'stdout ui smoke test\\\\n'"`, + commandActions: [{ command: "printf 'stdout ui smoke test\\n'", type: "unknown" }], + durationMs: 0, + exitCode: 0, + type: "commandExecution", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry).toMatchObject({ + command: "printf 'stdout ui smoke test\\\\n'", + rawCommand: `/opt/homebrew/bin/bash -lc "printf 'stdout ui smoke test\\\\n'"`, + output: "stdout ui smoke test\n", + exitCode: 0, + durationMs: 0, + }); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -922,6 +989,122 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("keeps file-change patches for inline expanded diff rendering", () => { + const patch = + "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n"; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + path: "app.ts", + patch, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual(["app.ts"]); + expect(entry?.patch).toBe(patch); + }); + + it("normalizes Codex file-change content diffs into unified patches", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + changes: [ + { + path: "/Users/example/t3code/SMOKE_TEST_CHANGE.md", + kind: { type: "add" }, + diff: "Smoke test file-change row.\n", + }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual(["/Users/example/t3code/SMOKE_TEST_CHANGE.md"]); + expect(entry?.patch).toContain( + "diff --git a/Users/example/t3code/SMOKE_TEST_CHANGE.md b/Users/example/t3code/SMOKE_TEST_CHANGE.md", + ); + expect(entry?.patch).toContain("--- /dev/null"); + expect(entry?.patch).toContain("+Smoke test file-change row."); + }); + + it("normalizes Codex file-change diffs for gitignored paths when the provider emits a patch", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-ignored-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + changes: [ + { + path: "apps/web/dist/ignored.txt", + kind: { type: "add" }, + diff: "ignored file content\n", + }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual(["apps/web/dist/ignored.txt"]); + expect(entry?.patch).toContain( + "diff --git a/apps/web/dist/ignored.txt b/apps/web/dist/ignored.txt", + ); + expect(entry?.patch).toContain("+ignored file content"); + }); + + it("normalizes Codex hunk-only diffs into unified patches", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-hunk", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + changes: [ + { + path: "SMOKE_TEST_CHANGE.md", + diff: "@@ -1 +1,2 @@\n Smoke test file-change row.\n+Smoke test file-change row rerun.", + }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.patch).toContain("diff --git a/SMOKE_TEST_CHANGE.md b/SMOKE_TEST_CHANGE.md"); + expect(entry?.patch).toContain("--- a/SMOKE_TEST_CHANGE.md"); + expect(entry?.patch).toContain("+++ b/SMOKE_TEST_CHANGE.md"); + }); + it("drops duplicated tool detail when it only repeats the title", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5feffe15b09..7e52f64a418 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -54,6 +54,12 @@ export interface WorkLogEntry { detail?: string; command?: string; rawCommand?: string; + output?: string; + stdout?: string; + stderr?: string; + exitCode?: number; + durationMs?: number; + patch?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; @@ -61,6 +67,11 @@ export interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; } +const MAX_PATCH_SEARCH_DEPTH = 4; +const MAX_PATCH_STRINGS = 4; +const MAX_INLINE_PATCH_CHARS = 200_000; +const PATCH_TOO_LARGE_MESSAGE = `[patch omitted: exceeds ${MAX_INLINE_PATCH_CHARS} characters]`; + interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; @@ -515,7 +526,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? (activity.payload as Record) : null; const commandPreview = extractToolCommand(payload); + const commandResult = extractCommandResult(payload); const changedFiles = extractChangedFiles(payload); + const patch = extractToolPatch(payload); const title = extractToolTitle(payload); const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; const taskSummary = @@ -562,6 +575,28 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (commandPreview.rawCommand) { entry.rawCommand = commandPreview.rawCommand; } + const isCommandEntry = + itemType === "command_execution" || + requestKind === "command" || + Boolean(commandPreview.command || commandPreview.rawCommand); + if (commandResult.output && !commandResult.stdout && !commandResult.stderr && isCommandEntry) { + entry.output = commandResult.output; + } + if (commandResult.stdout) { + entry.stdout = commandResult.stdout; + } + if (commandResult.stderr) { + entry.stderr = commandResult.stderr; + } + if (commandResult.exitCode !== null) { + entry.exitCode = commandResult.exitCode; + } + if (commandResult.durationMs !== null) { + entry.durationMs = commandResult.durationMs; + } + if (patch) { + entry.patch = patch; + } if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } @@ -632,6 +667,12 @@ function mergeDerivedWorkLogEntries( const detail = next.detail ?? previous.detail; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; + const output = mergeTextOutput(previous.output, next.output); + const stdout = mergeTextOutput(previous.stdout, next.stdout); + const stderr = mergeTextOutput(previous.stderr, next.stderr); + const exitCode = next.exitCode ?? previous.exitCode; + const durationMs = next.durationMs ?? previous.durationMs; + const patch = next.patch ?? previous.patch; const toolTitle = next.toolTitle ?? previous.toolTitle; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; @@ -643,6 +684,12 @@ function mergeDerivedWorkLogEntries( ...(detail ? { detail } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), + ...(output ? { output } : {}), + ...(stdout ? { stdout } : {}), + ...(stderr ? { stderr } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(patch ? { patch } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), ...(toolTitle ? { toolTitle } : {}), ...(itemType ? { itemType } : {}), @@ -663,6 +710,22 @@ function mergeChangedFiles( return [...new Set(merged)]; } +function mergeTextOutput( + previous: string | undefined, + next: string | undefined, +): string | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + if (previous === next) { + return next; + } + return `${previous}${previous.endsWith("\n") ? "" : "\n"}${next}`; +} + function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; @@ -894,6 +957,94 @@ function extractToolCommand(payload: Record | null): { }; } +function firstNumberFromRecord( + record: Record | null, + keys: ReadonlyArray, +): number | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = asNumber(record[key]); + if (value !== null) { + return value; + } + } + return null; +} + +function firstIntegerFromRecord( + record: Record | null, + keys: ReadonlyArray, +): number | null { + const value = firstNumberFromRecord(record, keys); + return value !== null && Number.isInteger(value) ? value : null; +} + +function extractCommandResult(payload: Record | null): { + output: string | null; + stdout: string | null; + stderr: string | null; + exitCode: number | null; + durationMs: number | null; +} { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput); + const stdout = + firstRawStringFromRecord(rawOutput, ["stdout"]) ?? + firstRawStringFromRecord(itemResult, ["stdout"]) ?? + firstRawStringFromRecord(data, ["stdout"]) ?? + firstRawStringFromRecord(payload, ["stdout"]); + const stderr = + firstRawStringFromRecord(rawOutput, ["stderr"]) ?? + firstRawStringFromRecord(itemResult, ["stderr"]) ?? + firstRawStringFromRecord(data, ["stderr"]) ?? + firstRawStringFromRecord(payload, ["stderr"]); + const content = + stdout ?? + firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + firstRawStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? + firstRawStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); + const strippedContent = content ? stripTrailingExitCode(content) : null; + const detailExit = + typeof payload?.detail === "string" ? stripTrailingExitCode(payload.detail) : null; + const exitCode = + firstIntegerFromRecord(rawOutput, ["exitCode", "code"]) ?? + firstIntegerFromRecord(itemResult, ["exitCode", "code"]) ?? + firstIntegerFromRecord(item, ["exitCode", "code"]) ?? + firstIntegerFromRecord(data, ["exitCode", "code"]) ?? + firstIntegerFromRecord(payload, ["exitCode", "code"]) ?? + strippedContent?.exitCode ?? + detailExit?.exitCode ?? + null; + const elapsedSeconds = + firstNumberFromRecord(rawOutput, ["elapsedSeconds"]) ?? + firstNumberFromRecord(itemResult, ["elapsedSeconds"]) ?? + firstNumberFromRecord(item, ["elapsedSeconds"]) ?? + firstNumberFromRecord(data, ["elapsedSeconds"]) ?? + firstNumberFromRecord(payload, ["elapsedSeconds"]); + const durationMs = + firstNumberFromRecord(rawOutput, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(itemResult, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(item, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(data, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(payload, ["durationMs", "elapsedMs"]) ?? + (elapsedSeconds !== null ? elapsedSeconds * 1000 : null); + const strippedStdout = stdout ? stripTrailingExitCode(stdout) : null; + const normalizedOutput = + strippedContent?.exitCode !== undefined ? strippedContent.output : (content ?? null); + + return { + output: normalizedOutput, + stdout: strippedStdout?.exitCode !== undefined ? strippedStdout.output : stdout, + stderr, + exitCode, + durationMs, + }; +} + function extractToolTitle(payload: Record | null): string | null { return asTrimmedString(payload?.title); } @@ -1027,6 +1178,151 @@ function stripTrailingExitCode(value: string): { }; } +function firstRawStringFromRecord( + record: Record | null, + keys: ReadonlyArray, +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return null; +} + +function looksLikeUnifiedDiff(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed.startsWith("diff --git ") || + trimmed.startsWith("--- ") || + trimmed.startsWith("@@ ") || + /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/u.test(trimmed) + ); +} + +function codexChangeKindType(record: Record): string | null { + const kind = record.kind; + if (typeof kind === "string") { + return kind; + } + const kindRecord = asRecord(kind); + return asTrimmedString(kindRecord?.type); +} + +function patchPathFromRecord(record: Record): string | null { + return ( + asTrimmedString(record.path) ?? + asTrimmedString(record.filePath) ?? + asTrimmedString(record.relativePath) ?? + asTrimmedString(record.filename) ?? + asTrimmedString(record.newPath) ?? + asTrimmedString(record.oldPath) + ); +} + +function normalizeDiffHeaderPath(path: string): string { + return path.replace(/\\/gu, "/"); +} + +function toUnifiedPatchFromRecordDiff( + record: Record, + diff: string, +): string | null { + if (diff.startsWith("diff --git ") || diff.startsWith("--- ")) { + return diff; + } + const trimmed = diff.trimEnd(); + if (trimmed.length === 0) { + return null; + } + + const rawPath = patchPathFromRecord(record); + if (!rawPath) { + return looksLikeUnifiedDiff(trimmed) ? trimmed : null; + } + const path = normalizeDiffHeaderPath(rawPath); + + if (codexChangeKindType(record) === "add") { + if (trimmed.startsWith("@@ ")) { + return `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\n${trimmed}`; + } + const lines = trimmed.length > 0 ? trimmed.split(/\r?\n/u) : []; + const addedLines = lines.map((line) => `+${line}`).join("\n"); + return `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\n@@ -0,0 +1,${lines.length} @@\n${addedLines}`; + } + + if (trimmed.startsWith("@@ ")) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n${trimmed}`; + } + + return null; +} + +function collectPatchStrings( + value: unknown, + patches: string[], + seen: Set, + depth: number, + includeNested = true, +): void { + if (depth > MAX_PATCH_SEARCH_DEPTH || patches.length >= MAX_PATCH_STRINGS) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectPatchStrings(entry, patches, seen, depth + 1); + if (patches.length >= MAX_PATCH_STRINGS) { + return; + } + } + return; + } + const record = asRecord(value); + if (!record) { + return; + } + for (const key of ["patch", "diff", "unifiedDiff"]) { + const rawCandidate = typeof record[key] === "string" ? record[key] : null; + const candidate = rawCandidate ? toUnifiedPatchFromRecordDiff(record, rawCandidate) : null; + if (!candidate || seen.has(candidate)) { + continue; + } + if (candidate.length > MAX_INLINE_PATCH_CHARS) { + seen.add(candidate); + patches.push(PATCH_TOO_LARGE_MESSAGE); + continue; + } + if (!looksLikeUnifiedDiff(candidate)) { + continue; + } + seen.add(candidate); + patches.push(candidate); + } + if (!includeNested) { + return; + } + for (const nestedKey of ["item", "result", "input", "data", "changes", "files", "edits"]) { + if (!(nestedKey in record)) { + continue; + } + collectPatchStrings(record[nestedKey], patches, seen, depth + 1); + if (patches.length >= MAX_PATCH_STRINGS) { + return; + } + } +} + +function extractToolPatch(payload: Record | null): string | null { + const patches: string[] = []; + const data = asRecord(payload?.data); + collectPatchStrings(data, patches, new Set(), 0, false); + collectPatchStrings(asRecord(data?.item), patches, new Set(patches), 0); + return patches.length > 0 ? patches.join("\n\n") : null; +} function extractWorkLogItemType( payload: Record | null, ): WorkLogEntry["itemType"] | undefined { From ff0ba8e8899f5dd193b9cd07c62845be6ebf1d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 8 Jun 2026 11:20:55 +0100 Subject: [PATCH 02/10] Fix tool output and patch rendering - Preserve command output whitespace and accessible toggles - Tighten work entry guards and patch extraction behavior - Add regression coverage for reviewed edge cases --- .../chat/MessagesTimeline.logic.test.ts | 4 +- .../components/chat/MessagesTimeline.logic.ts | 11 ++- .../src/components/chat/MessagesTimeline.tsx | 2 +- apps/web/src/session-logic.test.ts | 73 ++++++++++++++++++- apps/web/src/session-logic.ts | 22 ++++-- 5 files changed, 103 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 76ff6a1f1f2..035a44d23e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -219,9 +219,11 @@ describe("hasRenderableCommandOutput", () => { expect(hasRenderableCommandOutput("\n\t\n")).toBe(false); }); - it("removes empty and whitespace-only command output lines", () => { + it("preserves intentional blank command output lines", () => { expect(getRenderableCommandOutputLines("\nstdout\n \n\t\nstderr\n")).toEqual([ "stdout", + " ", + "\t", "stderr", ]); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 2d8065a5a6c..c5d456369a5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -92,7 +92,16 @@ export function getRenderableCommandOutputLines(value: string | null | undefined if (typeof value !== "string" || value.length === 0) { return []; } - return value.split(/\r?\n/u).filter((line) => line.trim().length > 0); + const lines = value.split(/\r?\n/u); + let startIndex = 0; + let endIndex = lines.length; + while (startIndex < endIndex && (lines[startIndex]?.trim().length ?? 0) === 0) { + startIndex += 1; + } + while (endIndex > startIndex && (lines[endIndex - 1]?.trim().length ?? 0) === 0) { + endIndex -= 1; + } + return lines.slice(startIndex, endIndex); } function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0b997ef10d5..827572a14c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1419,7 +1419,7 @@ function FileChangeEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) /> )} options={{ - collapsed: false, + collapsed: true, diffStyle: "unified", theme: resolveDiffThemeName(ctx.resolvedTheme), }} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 362f447aa89..160a682a5b2 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -962,6 +962,30 @@ describe("deriveWorkLogEntries", () => { }); }); + it("does not overwrite subagent output with command result fallbacks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "subagent-with-output", + kind: "tool.completed", + summary: "Subagent", + payload: { + itemType: "collab_agent_tool_call", + data: { + rawOutput: { + content: "Subagent result", + }, + item: { + aggregatedOutput: "Command-like fallback", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.output).toBe("Subagent result"); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1040,12 +1064,59 @@ describe("deriveWorkLogEntries", () => { const [entry] = deriveWorkLogEntries(activities, undefined); expect(entry?.changedFiles).toEqual(["/Users/example/t3code/SMOKE_TEST_CHANGE.md"]); expect(entry?.patch).toContain( - "diff --git a/Users/example/t3code/SMOKE_TEST_CHANGE.md b/Users/example/t3code/SMOKE_TEST_CHANGE.md", + "diff --git a//Users/example/t3code/SMOKE_TEST_CHANGE.md b//Users/example/t3code/SMOKE_TEST_CHANGE.md", ); expect(entry?.patch).toContain("--- /dev/null"); expect(entry?.patch).toContain("+Smoke test file-change row."); }); + it("extracts top-level tool patches", () => { + const patch = + "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n"; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "top-level-file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + patch, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.patch).toBe(patch); + }); + + it("keeps add hunk-only diffs rooted at dev null", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-add-hunk", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + changes: [ + { + path: "SMOKE_TEST_ADD.md", + kind: { type: "add" }, + diff: "@@ -0,0 +1 @@\n+Smoke test file-change row.", + }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.patch).toContain("--- /dev/null"); + expect(entry?.patch).toContain("+++ b/SMOKE_TEST_ADD.md"); + }); + it("normalizes Codex file-change diffs for gitignored paths when the provider emits a patch", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7e52f64a418..ffcdf21271c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -579,7 +579,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo itemType === "command_execution" || requestKind === "command" || Boolean(commandPreview.command || commandPreview.rawCommand); - if (commandResult.output && !commandResult.stdout && !commandResult.stderr && isCommandEntry) { + if ( + commandResult.output && + !commandResult.stdout && + !commandResult.stderr && + !entry.output && + isCommandEntry + ) { entry.output = commandResult.output; } if (commandResult.stdout) { @@ -992,8 +998,9 @@ function extractCommandResult(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); + const rawOutputStdout = firstRawStringFromRecord(rawOutput, ["stdout"]); const stdout = - firstRawStringFromRecord(rawOutput, ["stdout"]) ?? + rawOutputStdout ?? firstRawStringFromRecord(itemResult, ["stdout"]) ?? firstRawStringFromRecord(data, ["stdout"]) ?? firstRawStringFromRecord(payload, ["stdout"]); @@ -1032,11 +1039,12 @@ function extractCommandResult(payload: Record | null): { firstNumberFromRecord(data, ["durationMs", "elapsedMs"]) ?? firstNumberFromRecord(payload, ["durationMs", "elapsedMs"]) ?? (elapsedSeconds !== null ? elapsedSeconds * 1000 : null); - const strippedStdout = stdout ? stripTrailingExitCode(stdout) : null; + const strippedStdout = rawOutputStdout ? stripTrailingExitCode(rawOutputStdout) : null; const normalizedOutput = strippedContent?.exitCode !== undefined ? strippedContent.output : (content ?? null); return { + // `output` is the legacy fallback stream; callers should prefer stdout/stderr when present. output: normalizedOutput, stdout: strippedStdout?.exitCode !== undefined ? strippedStdout.output : stdout, stderr, @@ -1318,9 +1326,13 @@ function collectPatchStrings( function extractToolPatch(payload: Record | null): string | null { const patches: string[] = []; + const seen = new Set(); + if (payload) { + collectPatchStrings(payload, patches, seen, 0, false); + } const data = asRecord(payload?.data); - collectPatchStrings(data, patches, new Set(), 0, false); - collectPatchStrings(asRecord(data?.item), patches, new Set(patches), 0); + // Keep traversal bounded; provider payloads can nest raw tool data deeply. + collectPatchStrings(data, patches, seen, 0); return patches.length > 0 ? patches.join("\n\n") : null; } function extractWorkLogItemType( From 6b22cf92d1858e19ce55ed1ec740e0fb3983e5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 8 Jun 2026 12:12:21 +0100 Subject: [PATCH 03/10] Fix tool detail expansion and patch extraction - Preserve command stdout stripping across fallback payloads - Keep dynamic and MCP tool details classified by actual metadata - Preserve top-level and nested patch extraction coverage --- .../components/chat/MessagesTimeline.test.tsx | 124 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 17 ++- apps/web/src/session-logic.test.ts | 82 ++++++++++++ apps/web/src/session-logic.ts | 2 +- 4 files changed, 221 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index d9e6c8d3d33..55f91c5305a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -292,6 +292,98 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Expand Ran command"'); }); + it("renders dynamic tool command metadata as expandable command rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Dynamic tool"); + expect(markup).toContain("vp test"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Dynamic tool"'); + }); + + it("renders MCP tool command metadata as expandable command rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("MCP tool"); + expect(markup).toContain("rg TODO"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand MCP tool"'); + }); + + it("does not render typed non-command stdout as command details", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Web search"); + expect(markup).not.toContain('aria-expanded="false"'); + expect(markup).not.toContain('aria-label="Expand Web search"'); + }); + it("renders file-change work entries as expandable rows", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -323,6 +415,38 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Expand Changed files"'); }); + it("renders dynamic tool patch metadata as expandable file-change rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Dynamic patch tool"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Dynamic patch tool"'); + }); + it("renders review comment contexts as structured cards instead of raw tags", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 827572a14c0..2b7c1f32922 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1236,7 +1236,11 @@ function isCommandWorkEntry(workEntry: TimelineWorkEntry): boolean { if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { return true; } - if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { + if ( + workEntry.itemType === "file_change" || + workEntry.itemType === "collab_agent_tool_call" || + workEntry.requestKind === "file-change" + ) { return false; } if (workEntry.itemType || workEntry.requestKind) { @@ -1256,10 +1260,17 @@ function isFileChangeWorkEntry(workEntry: TimelineWorkEntry): boolean { if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { return true; } - if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { + if ( + workEntry.itemType === "command_execution" || + workEntry.itemType === "collab_agent_tool_call" || + workEntry.requestKind === "command" + ) { return false; } - return Boolean(workEntry.patch); + if (workEntry.patch) { + return true; + } + return false; } function CommandEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 160a682a5b2..f2dc9ed563a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -929,6 +929,34 @@ describe("deriveWorkLogEntries", () => { }); }); + it("strips fallback stdout exit-code metadata", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-result-stdout-exit-code", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + item: { + command: "node script.js", + result: { + stdout: "done\n", + }, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry).toMatchObject({ + command: "node script.js", + stdout: "done", + exitCode: 7, + }); + }); + it("keeps Codex command execution item output, exit code, and duration", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1070,6 +1098,38 @@ describe("deriveWorkLogEntries", () => { expect(entry?.patch).toContain("+Smoke test file-change row."); }); + it("keeps nested result file-change patches within the traversal budget", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-result-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + result: { + changes: [ + { + path: "apps/web/src/session-logic.ts", + diff: "@@ -1 +1 @@\n-old\n+new", + }, + ], + }, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual(["apps/web/src/session-logic.ts"]); + expect(entry?.patch).toContain( + "diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts", + ); + expect(entry?.patch).toContain("+new"); + }); + it("extracts top-level tool patches", () => { const patch = "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n"; @@ -1089,6 +1149,28 @@ describe("deriveWorkLogEntries", () => { expect(entry?.patch).toBe(patch); }); + it("normalizes top-level hunk-only diffs with sibling path metadata", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "top-level-file-tool-hunk", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + path: "apps/web/src/session-logic.ts", + diff: "@@ -1 +1 @@\n-old\n+new", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.patch).toContain( + "diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts", + ); + expect(entry?.patch).toContain("--- a/apps/web/src/session-logic.ts"); + expect(entry?.patch).toContain("+++ b/apps/web/src/session-logic.ts"); + }); + it("keeps add hunk-only diffs rooted at dev null", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index ffcdf21271c..2d762328dbf 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1039,7 +1039,7 @@ function extractCommandResult(payload: Record | null): { firstNumberFromRecord(data, ["durationMs", "elapsedMs"]) ?? firstNumberFromRecord(payload, ["durationMs", "elapsedMs"]) ?? (elapsedSeconds !== null ? elapsedSeconds * 1000 : null); - const strippedStdout = rawOutputStdout ? stripTrailingExitCode(rawOutputStdout) : null; + const strippedStdout = stdout ? stripTrailingExitCode(stdout) : null; const normalizedOutput = strippedContent?.exitCode !== undefined ? strippedContent.output : (content ?? null); From 489473aeed296d4aeea95bbb190d6f879a9f4ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 8 Jun 2026 19:22:17 +0100 Subject: [PATCH 04/10] Show inline file diffs expanded - Keep changed-file chips only when no inline diff exists - Render inline file diffs expanded so the patch body stays visible --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2b7c1f32922..05fef7858ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1430,7 +1430,7 @@ function FileChangeEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) /> )} options={{ - collapsed: true, + collapsed: false, diffStyle: "unified", theme: resolveDiffThemeName(ctx.resolvedTheme), }} From d7df3f55b00108c10792d5b5ec4f90222112d8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 8 Jun 2026 20:00:48 +0100 Subject: [PATCH 05/10] Drop unrelated subagent activity test --- apps/web/src/session-logic.test.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f2dc9ed563a..e8c043d9916 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -990,30 +990,6 @@ describe("deriveWorkLogEntries", () => { }); }); - it("does not overwrite subagent output with command result fallbacks", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "subagent-with-output", - kind: "tool.completed", - summary: "Subagent", - payload: { - itemType: "collab_agent_tool_call", - data: { - rawOutput: { - content: "Subagent result", - }, - item: { - aggregatedOutput: "Command-like fallback", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities, undefined); - expect(entry?.output).toBe("Subagent result"); - }); - it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ From f53baeec803f86685d90e61cef274355730e12e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 9 Jun 2026 10:27:12 +0100 Subject: [PATCH 06/10] Fix duplicated work log output and nested patch capture - Merge cumulative command output instead of reappending it - Keep file change details from showing alongside command details - Stop nested patch extraction from overriding the top-level patch --- .../src/components/chat/MessagesTimeline.tsx | 71 ++++++++----------- apps/web/src/session-logic.test.ts | 71 +++++++++++++++++++ apps/web/src/session-logic.ts | 32 ++++++++- 3 files changed, 131 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 05fef7858ac..cd2df4a0c6b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1205,34 +1205,27 @@ function ToolDetailBlock(props: { } function hasExpandableWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { - if (isCommandWorkEntry(workEntry)) { - return Boolean( - workEntry.command || - workEntry.rawCommand || - workEntry.output || - workEntry.stdout || - workEntry.stderr || - workEntry.exitCode !== undefined || - workEntry.durationMs !== undefined, - ); - } - if (isFileChangeWorkEntry(workEntry)) { - return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); - } - return false; + return hasCommandWorkEntryDetails(workEntry) || hasFileChangeWorkEntryDetails(workEntry); } function ToolEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { - if (isCommandWorkEntry(workEntry)) { - return ; - } - if (isFileChangeWorkEntry(workEntry)) { - return ; + const showCommandDetails = hasCommandWorkEntryDetails(workEntry); + const showFileChangeDetails = hasFileChangeWorkEntryDetails(workEntry); + if (!showCommandDetails && !showFileChangeDetails) { + return null; } - return null; + return ( + <> + {showCommandDetails && } + {showFileChangeDetails && } + + ); } -function isCommandWorkEntry(workEntry: TimelineWorkEntry): boolean { +function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + if (!hasCommandWorkEntryMetadata(workEntry)) { + return false; + } if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { return true; } @@ -1244,33 +1237,29 @@ function isCommandWorkEntry(workEntry: TimelineWorkEntry): boolean { return false; } if (workEntry.itemType || workEntry.requestKind) { - return ( - (workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call") && - hasCommandWorkEntryCommand(workEntry) - ); + return workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call"; } return hasCommandWorkEntryCommand(workEntry); } +function hasCommandWorkEntryMetadata(workEntry: TimelineWorkEntry): boolean { + return Boolean( + workEntry.command || + workEntry.rawCommand || + workEntry.output || + workEntry.stdout || + workEntry.stderr || + workEntry.exitCode !== undefined || + workEntry.durationMs !== undefined, + ); +} + function hasCommandWorkEntryCommand(workEntry: TimelineWorkEntry): boolean { return Boolean(workEntry.command || workEntry.rawCommand); } -function isFileChangeWorkEntry(workEntry: TimelineWorkEntry): boolean { - if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { - return true; - } - if ( - workEntry.itemType === "command_execution" || - workEntry.itemType === "collab_agent_tool_call" || - workEntry.requestKind === "command" - ) { - return false; - } - if (workEntry.patch) { - return true; - } - return false; +function hasFileChangeWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); } function CommandEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e8c043d9916..7a71f304c5c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -929,6 +929,52 @@ describe("deriveWorkLogEntries", () => { }); }); + it("uses completed cumulative command output instead of duplicating updated output", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\n", + stderr: "warning 1\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + stderr: "warning 1\nwarning 2\n", + exitCode: 0, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + expect(entry?.stderr).toBe("warning 1\nwarning 2\n"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1125,6 +1171,31 @@ describe("deriveWorkLogEntries", () => { expect(entry?.patch).toBe(patch); }); + it("does not traverse nested patch keys when extracting top-level array patches", () => { + const patch = + "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n"; + const nestedPatch = + "diff --git a/nested.ts b/nested.ts\n--- a/nested.ts\n+++ b/nested.ts\n@@ -1 +1 @@\n-old\n+nested\n"; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "top-level-array-file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: [ + { + patch, + item: { + patch: nestedPatch, + }, + }, + ] as unknown as Record, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.patch).toBe(patch); + }); + it("normalizes top-level hunk-only diffs with sibling path metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 2d762328dbf..0e55e21595b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -729,7 +729,35 @@ function mergeTextOutput( if (previous === next) { return next; } - return `${previous}${previous.endsWith("\n") ? "" : "\n"}${next}`; + if (next.startsWith(previous)) { + return next; + } + if (previous.startsWith(next)) { + return previous; + } + const overlap = findSuffixPrefixOverlap(previous, next); + if (overlap > 0) { + return `${previous}${next.slice(overlap)}`; + } + const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; + return `${previous}${separator}${next}`; +} + +function findSuffixPrefixOverlap(previous: string, next: string): number { + const maxLength = Math.min(previous.length, next.length); + const candidate = `${next.slice(0, maxLength)}\u0000${previous.slice(-maxLength)}`; + const prefixLengths = Array.from({ length: candidate.length }, () => 0); + for (let index = 1; index < candidate.length; index += 1) { + let fallbackIndex = prefixLengths[index - 1] ?? 0; + while (fallbackIndex > 0 && candidate[index] !== candidate[fallbackIndex]) { + fallbackIndex = prefixLengths[fallbackIndex - 1] ?? 0; + } + if (candidate[index] === candidate[fallbackIndex]) { + fallbackIndex += 1; + } + prefixLengths[index] = fallbackIndex; + } + return Math.min(prefixLengths.at(-1) ?? 0, maxLength); } function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { @@ -1282,7 +1310,7 @@ function collectPatchStrings( } if (Array.isArray(value)) { for (const entry of value) { - collectPatchStrings(entry, patches, seen, depth + 1); + collectPatchStrings(entry, patches, seen, depth + 1, includeNested); if (patches.length >= MAX_PATCH_STRINGS) { return; } From 45371d68570db8ee42c265b6c15e92d19b718e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 9 Jun 2026 10:37:44 +0100 Subject: [PATCH 07/10] Preserve incremental command output chunks - Keep cumulative output de-dupe without arbitrary overlap trimming - Cover overlapping delta chunks in session logic tests --- apps/web/src/session-logic.test.ts | 42 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 21 --------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 7a71f304c5c..c31bd0b01da 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -975,6 +975,48 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stderr).toBe("warning 1\nwarning 2\n"); }); + it("preserves overlapping incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "Error", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "retrying", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.stdout).toBe("Error\nretrying"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 0e55e21595b..c6c38a2af02 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -735,31 +735,10 @@ function mergeTextOutput( if (previous.startsWith(next)) { return previous; } - const overlap = findSuffixPrefixOverlap(previous, next); - if (overlap > 0) { - return `${previous}${next.slice(overlap)}`; - } const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; return `${previous}${separator}${next}`; } -function findSuffixPrefixOverlap(previous: string, next: string): number { - const maxLength = Math.min(previous.length, next.length); - const candidate = `${next.slice(0, maxLength)}\u0000${previous.slice(-maxLength)}`; - const prefixLengths = Array.from({ length: candidate.length }, () => 0); - for (let index = 1; index < candidate.length; index += 1) { - let fallbackIndex = prefixLengths[index - 1] ?? 0; - while (fallbackIndex > 0 && candidate[index] !== candidate[fallbackIndex]) { - fallbackIndex = prefixLengths[fallbackIndex - 1] ?? 0; - } - if (candidate[index] === candidate[fallbackIndex]) { - fallbackIndex += 1; - } - prefixLengths[index] = fallbackIndex; - } - return Math.min(prefixLengths.at(-1) ?? 0, maxLength); -} - function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; From 9224f42b723e9d8cd20fba54eb37cf027308ca64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 9 Jun 2026 10:43:37 +0100 Subject: [PATCH 08/10] Fix work log detail review issues - Restore command and file detail precedence - Cover incremental output merge edge cases --- .../src/components/chat/MessagesTimeline.tsx | 31 ++-- apps/web/src/session-logic.test.ts | 135 +++++++++++++++++- apps/web/src/session-logic.ts | 11 +- 3 files changed, 162 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index cd2df4a0c6b..a61a8045e13 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1209,17 +1209,13 @@ function hasExpandableWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { } function ToolEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { - const showCommandDetails = hasCommandWorkEntryDetails(workEntry); - const showFileChangeDetails = hasFileChangeWorkEntryDetails(workEntry); - if (!showCommandDetails && !showFileChangeDetails) { - return null; + if (hasCommandWorkEntryDetails(workEntry)) { + return ; } - return ( - <> - {showCommandDetails && } - {showFileChangeDetails && } - - ); + if (hasFileChangeWorkEntryDetails(workEntry)) { + return ; + } + return null; } function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { @@ -1237,7 +1233,10 @@ function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { return false; } if (workEntry.itemType || workEntry.requestKind) { - return workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call"; + return ( + (workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call") && + hasCommandWorkEntryCommand(workEntry) + ); } return hasCommandWorkEntryCommand(workEntry); } @@ -1259,6 +1258,16 @@ function hasCommandWorkEntryCommand(workEntry: TimelineWorkEntry): boolean { } function hasFileChangeWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { + return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); + } + if ( + workEntry.itemType === "command_execution" || + workEntry.itemType === "collab_agent_tool_call" || + workEntry.requestKind === "command" + ) { + return false; + } return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c31bd0b01da..ad166b40b30 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -30,7 +30,7 @@ function makeActivity(overrides: { kind?: string; summary?: string; tone?: OrchestrationThreadActivity["tone"]; - payload?: Record; + payload?: OrchestrationThreadActivity["payload"]; turnId?: string; sequence?: number; }): OrchestrationThreadActivity { @@ -975,7 +975,7 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stderr).toBe("warning 1\nwarning 2\n"); }); - it("preserves overlapping incremental command output chunks", () => { + it("concatenates non-matching incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "command-tool-output-update", @@ -1017,6 +1017,135 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); + it("keeps accumulated command output when a later chunk repeats its prefix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.stdout).toBe("line 1\nline 2"); + }); + + it("deduplicates suffix-overlapping incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "foo", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "oobar", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.stdout).toBe("foobar"); + }); + + it("preserves existing newlines between incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "Error", + stderr: "warning\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "\nretrying", + stderr: "done", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.stdout).toBe("Error\nretrying"); + expect(entry?.stderr).toBe("warning\ndone"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1230,7 +1359,7 @@ describe("deriveWorkLogEntries", () => { patch: nestedPatch, }, }, - ] as unknown as Record, + ], }), ]; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index c6c38a2af02..bb79c471b90 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -735,6 +735,15 @@ function mergeTextOutput( if (previous.startsWith(next)) { return previous; } + for ( + let overlapLength = Math.min(previous.length, next.length); + overlapLength > 1; + overlapLength -= 1 + ) { + if (previous.endsWith(next.slice(0, overlapLength))) { + return `${previous}${next.slice(overlapLength)}`; + } + } const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; return `${previous}${separator}${next}`; } @@ -1324,7 +1333,7 @@ function collectPatchStrings( if (!(nestedKey in record)) { continue; } - collectPatchStrings(record[nestedKey], patches, seen, depth + 1); + collectPatchStrings(record[nestedKey], patches, seen, depth + 1, includeNested); if (patches.length >= MAX_PATCH_STRINGS) { return; } From b36b7800075adbfa5e16ad14ec2a3cd7aae413ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 9 Jun 2026 10:56:08 +0100 Subject: [PATCH 09/10] Fix work log detail rendering - Render command and file-change details together - Preserve distinct overlapping command-output chunks --- .../components/chat/MessagesTimeline.test.tsx | 33 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 16 +++++---- apps/web/src/session-logic.test.ts | 4 +-- apps/web/src/session-logic.ts | 9 ----- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 55f91c5305a..0259c00f2fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -447,6 +447,39 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Expand Dynamic patch tool"'); }); + it("renders mixed dynamic tool command and patch metadata", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("apply_patch"); + expect(markup).toContain("apps/web/src/session-logic.ts"); + expect(markup).toContain('aria-label="Expand Dynamic edit tool"'); + }); + it("renders review comment contexts as structured cards instead of raw tags", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a61a8045e13..ea92ac01094 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1209,13 +1209,17 @@ function hasExpandableWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { } function ToolEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { - if (hasCommandWorkEntryDetails(workEntry)) { - return ; - } - if (hasFileChangeWorkEntryDetails(workEntry)) { - return ; + const hasCommandDetails = hasCommandWorkEntryDetails(workEntry); + const hasFileChangeDetails = hasFileChangeWorkEntryDetails(workEntry); + if (!hasCommandDetails && !hasFileChangeDetails) { + return null; } - return null; + return ( + <> + {hasCommandDetails ? : null} + {hasFileChangeDetails ? : null} + + ); } function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index ad166b40b30..dc655d31cb8 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1059,7 +1059,7 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("line 1\nline 2"); }); - it("deduplicates suffix-overlapping incremental command output chunks", () => { + it("keeps distinct suffix-overlapping command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "command-tool-output-update", @@ -1098,7 +1098,7 @@ describe("deriveWorkLogEntries", () => { ]; const [entry] = deriveWorkLogEntries(activities, undefined); - expect(entry?.stdout).toBe("foobar"); + expect(entry?.stdout).toBe("foo\noobar"); }); it("preserves existing newlines between incremental command output chunks", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index bb79c471b90..82bc0583b0c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -735,15 +735,6 @@ function mergeTextOutput( if (previous.startsWith(next)) { return previous; } - for ( - let overlapLength = Math.min(previous.length, next.length); - overlapLength > 1; - overlapLength -= 1 - ) { - if (previous.endsWith(next.slice(0, overlapLength))) { - return `${previous}${next.slice(overlapLength)}`; - } - } const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; return `${previous}${separator}${next}`; } From 8883b9022392ea5db1fb1e438a4cc0e466405b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 9 Jun 2026 11:16:04 +0100 Subject: [PATCH 10/10] Fix work log detail routing - Render command and file-change details independently - Cover dynamic output-only and command-patch rows --- .../components/chat/MessagesTimeline.test.tsx | 60 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 11 +--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0259c00f2fe..fdab62686ba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -447,6 +447,66 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Expand Dynamic patch tool"'); }); + it("renders dynamic tool output metadata as expandable command rows without a command", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Dynamic output tool"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Dynamic output tool"'); + }); + + it("renders command execution patch metadata as expandable file-change rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ran command"); + expect(markup).toContain("apps/web/src/session-logic.ts"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Ran command"'); + }); + it("renders mixed dynamic tool command and patch metadata", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ea92ac01094..e035058bf86 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1237,10 +1237,7 @@ function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { return false; } if (workEntry.itemType || workEntry.requestKind) { - return ( - (workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call") && - hasCommandWorkEntryCommand(workEntry) - ); + return workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call"; } return hasCommandWorkEntryCommand(workEntry); } @@ -1265,11 +1262,7 @@ function hasFileChangeWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); } - if ( - workEntry.itemType === "command_execution" || - workEntry.itemType === "collab_agent_tool_call" || - workEntry.requestKind === "command" - ) { + if (workEntry.itemType === "collab_agent_tool_call") { return false; } return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0);