diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 50ee10b4169..ca56e36e714 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"; @@ -260,6 +262,29 @@ 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("preserves intentional blank command output lines", () => { + expect(getRenderableCommandOutputLines("\nstdout\n \n\t\nstderr\n")).toEqual([ + "stdout", + " ", + "\t", + "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 1426f1deee2..6065088a75c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -114,6 +114,26 @@ 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 []; + } + 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) { 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 3207876f706..591215f7902 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -83,6 +83,8 @@ beforeAll(() => { documentElement: { classList, offsetHeight: 0, + removeAttribute: () => {}, + setAttribute: () => {}, }, }); }); @@ -259,6 +261,132 @@ 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 - vp test"'); + }); + + 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 - vp test"'); + }); + + 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( + , + ); + + 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 - apps/web/src/session-logic.ts"'); + }); + 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 b0d83be7b10..cf0c4ade92f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -22,8 +22,10 @@ import { } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; +import type { FileDiffMetadata, Hunk } from "@pierre/diffs/types"; import { deriveTimelineEntries, + formatDuration, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, @@ -66,6 +68,8 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, + getRenderableCommandOutputLines, + hasRenderableCommandOutput, MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, normalizeCompactToolLabel, @@ -140,6 +144,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) @@ -1537,6 +1542,356 @@ 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, + workspaceRoot: string | undefined, +): boolean { + if (hasCommandWorkEntryDetails(workEntry) || hasFileChangeWorkEntryDetails(workEntry)) { + return true; + } + return buildToolCallExpandedBody(workEntry, workspaceRoot) !== null; +} + +function ToolEntryDetails({ + workEntry, + workspaceRoot, +}: { + workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; +}) { + const showCommandDetails = hasCommandWorkEntryDetails(workEntry); + const showFileChangeDetails = hasFileChangeWorkEntryDetails(workEntry); + const supplementalDetails = + showCommandDetails || showFileChangeDetails + ? buildSupplementalToolDetailBody(workEntry, { + dedupeRenderedCommandOutput: showCommandDetails, + }) + : null; + if (showCommandDetails || showFileChangeDetails) { + return ( + <> + {showCommandDetails && } + {showFileChangeDetails && } + {supplementalDetails ? : null} + + ); + } + + const genericDetails = buildToolCallExpandedBody(workEntry, workspaceRoot); + return genericDetails ? : null; +} + +function buildSupplementalToolDetailBody( + workEntry: TimelineWorkEntry, + options: { dedupeRenderedCommandOutput: boolean }, +): string | null { + const detail = workEntry.detail?.trim(); + if (!detail) { + return null; + } + const command = workEntry.command?.trim(); + const rawCommand = workEntry.rawCommand?.trim(); + const renderedOutputMatchesDetail = + options.dedupeRenderedCommandOutput && commandOutputMatchesDetail(workEntry, detail); + if (detail === command || detail === rawCommand || renderedOutputMatchesDetail) { + return null; + } + return detail; +} + +function commandOutputMatchesDetail(workEntry: TimelineWorkEntry, detail: string): boolean { + const hasStreamOutput = + hasRenderableCommandOutput(workEntry.stdout) || hasRenderableCommandOutput(workEntry.stderr); + return [workEntry.stdout, workEntry.stderr, !hasStreamOutput ? workEntry.output : undefined].some( + (value) => getRenderableCommandOutputLines(value).join("\n") === detail, + ); +} + +function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + if (!hasCommandWorkEntryMetadata(workEntry)) { + return false; + } + if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { + return true; + } + if ( + workEntry.itemType === "file_change" || + workEntry.itemType === "collab_agent_tool_call" || + workEntry.requestKind === "file-change" + ) { + return false; + } + if (workEntry.itemType || workEntry.requestKind) { + return workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call"; + } + return Boolean(workEntry.command || workEntry.rawCommand); +} + +function hasCommandWorkEntryMetadata(workEntry: TimelineWorkEntry): boolean { + return Boolean( + workEntry.command || + workEntry.rawCommand || + workEntry.output || + workEntry.stdout || + workEntry.stderr || + workEntry.exitCode !== undefined || + workEntry.durationMs !== undefined, + ); +} + +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 === "collab_agent_tool_call") { + return false; + } + return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); +} + +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 && ( + + {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 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 GenericToolEntryDetails({ value }: { value: string }) { + return ( +
+
+        {value}
+      
+
+ ); +} + +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 stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { @@ -1558,8 +1913,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? null : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; - const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); - const canExpand = expandedBody !== null; + const canExpand = hasExpandableWorkEntryDetails(workEntry, workspaceRoot); const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && @@ -1588,7 +1942,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? { role: "button" as const, tabIndex: 0 as const, - "aria-label": displayText, + "aria-label": `${expanded ? "Collapse" : "Expand"} ${displayText}`, + "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -1683,15 +2038,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
- {expanded && canExpand && expandedBody ? ( -
-
-            {expandedBody}
-          
+ {expanded && canExpand ? ( +
+
) : null}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..51f776dc64e 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1114,11 +1114,552 @@ 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); + expect(entry).toMatchObject({ + command: "vp test", + stdout: "line 1\nline 2\n", + stderr: "warning\n", + exitCode: 1, + durationMs: 1250, + }); + }); + + 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); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + expect(entry?.stderr).toBe("warning 1\nwarning 2\n"); + }); + + it("uses later longer cumulative command output from updated snapshots", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + 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", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.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\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + }); + + it("keeps previously merged command output when completed output is a shorter snapshot", () => { + 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\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\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + }); + + it("keeps previously merged command output when updated output is a shorter line snapshot", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + 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\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.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", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + }); + + it("concatenates non-matching 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\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: "retrying", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("Error\nretrying"); + }); + + it("concatenates split incremental command output without adding separators", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + 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: "printf Downloading", + rawOutput: { + stdout: "Down", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf Downloading", + rawOutput: { + stdout: "loading", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("Downloading"); + }); + + it("keeps incremental command chunks that match the accumulated prefix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + 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: "printf aba", + rawOutput: { + stdout: "a\nb", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf aba", + rawOutput: { + stdout: "a", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("a\nba"); + }); + + it("preserves whitespace-only incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + 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: "printf hello", + rawOutput: { + stdout: "hello", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: " ", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-3", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: "world", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("hello world"); + }); + + it("preserves whitespace-only incremental raw output content chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + 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: "printf hello", + rawOutput: { + content: "hello", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: " ", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-3", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: "world", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.output).toBe("hello world"); + }); + + 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); + 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({ + 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); + 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("falls back to aggregated command output when stdout is blank-only", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-command-tool-blank-stdout", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + command: "vp test", + rawOutput: { + stdout: " \n\t ", + }, + item: { + aggregatedOutput: "tests passed\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "vp test", + output: "tests passed\n", + itemType: "command_execution", + }); + expect(entry?.stdout).toBeUndefined(); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1146,6 +1687,114 @@ 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); + 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); + 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("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); + 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"; + 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); + expect(entry?.patch).toBe(patch); + }); + 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 5d5051f748e..f1e3b18e14c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -68,6 +68,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; @@ -86,6 +92,11 @@ interface DerivedWorkLogEntry extends WorkLogEntry { toolCallId?: string; } +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]`; + export interface PendingApproval { requestId: ApprovalRequestId; requestKind: "command" | "file-read" | "file-change"; @@ -680,7 +691,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? (activity.payload as Record) : null; const commandPreview = extractToolCommand(payload); + const commandResult = extractCommandResult(payload, { + preserveBlankRawOutputStreams: activity.kind === "tool.updated", + }); const changedFiles = extractChangedFiles(payload); + const patch = extractToolPatch(payload); const title = extractToolTitle(payload); const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; const taskSummary = @@ -728,6 +743,34 @@ 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 && + !entry.output && + 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; } @@ -811,6 +854,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, next); + const stdout = mergeTextOutput(previous.stdout, next.stdout, next); + const stderr = mergeTextOutput(previous.stderr, next.stderr, next); + 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; @@ -824,6 +873,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 } : {}), @@ -835,6 +890,33 @@ function mergeDerivedWorkLogEntries( }; } +function mergeTextOutput( + previous: string | undefined, + next: string | undefined, + nextEntry: DerivedWorkLogEntry, +): string | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + if (previous === next) { + return next; + } + if (next.startsWith(previous)) { + return next; + } + if (previous.startsWith(next) && shouldKeepLongerOutputSnapshot(next, nextEntry)) { + return previous; + } + return `${previous}${next}`; +} + +function shouldKeepLongerOutputSnapshot(next: string, nextEntry: DerivedWorkLogEntry): boolean { + return nextEntry.activityKind === "tool.completed" || next.endsWith("\n"); +} + function mergeChangedFiles( previous: ReadonlyArray | undefined, next: ReadonlyArray | undefined, @@ -1077,6 +1159,108 @@ 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, + options: { + readonly preserveBlankRawOutputStreams?: boolean; + } = {}, +): { + 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 rawOutputStdout = options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["stdout"]) + : firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); + const stdout = + rawOutputStdout ?? + firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? + firstCommandOutputStringFromRecord(data, ["stdout"]) ?? + firstCommandOutputStringFromRecord(payload, ["stdout"]); + const stderr = + (options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["stderr"]) + : firstCommandOutputStringFromRecord(rawOutput, ["stderr"])) ?? + firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? + firstCommandOutputStringFromRecord(data, ["stderr"]) ?? + firstCommandOutputStringFromRecord(payload, ["stderr"]); + const rawOutputContent = options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) + : firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]); + const content = + stdout ?? + rawOutputContent ?? + firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? + firstCommandOutputStringFromRecord(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` is the legacy fallback stream; callers should prefer stdout/stderr when present. + output: normalizedOutput, + stdout: strippedStdout?.exitCode !== undefined ? strippedStdout.output : stdout, + stderr, + exitCode, + durationMs, + }; +} + function extractToolTitle(payload: Record | null): string | null { return asTrimmedString(payload?.title); } @@ -1210,6 +1394,164 @@ 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 firstCommandOutputStringFromRecord( + record: Record | null, + keys: ReadonlyArray, +): string | null { + const value = firstRawStringFromRecord(record, keys); + return value !== null && /\S/u.test(value) ? value : 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, includeNested); + 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, includeNested); + if (patches.length >= MAX_PATCH_STRINGS) { + return; + } + } +} + +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); + // 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( payload: Record | null, ): WorkLogEntry["itemType"] | undefined { @@ -1321,7 +1663,10 @@ function compareActivitiesByOrder( return lifecycleRankComparison; } - return left.id.localeCompare(right.id); + // Stable sort preserves arrival order for unsequenced same-timestamp events. + // Streaming text chunks can share millisecond timestamps; sorting those by + // random event ids can scramble the reconstructed output. + return 0; } function compareActivityLifecycleRank(kind: string): number {