diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5b643263fe0..035a44d23e6 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,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 ad54dd8bdeb..c5d456369a5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -84,6 +84,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 f5d483aa4ce..fdab62686ba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -258,6 +258,288 @@ 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 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( + , + ); + + 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 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 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( + , + ); + + 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 c2fb44c80a9..e035058bf86 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,328 @@ 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 { + return hasCommandWorkEntryDetails(workEntry) || hasFileChangeWorkEntryDetails(workEntry); +} + +function ToolEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { + const hasCommandDetails = hasCommandWorkEntryDetails(workEntry); + const hasFileChangeDetails = hasFileChangeWorkEntryDetails(workEntry); + if (!hasCommandDetails && !hasFileChangeDetails) { + return null; + } + return ( + <> + {hasCommandDetails ? : null} + {hasFileChangeDetails ? : null} + + ); +} + +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 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 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 && } +
+ + 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 +1515,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 +1626,20 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { )}
+ {canExpand && ( + + )}
{hasChangedFiles && !previewIsChangedFiles && (
@@ -1289,6 +1662,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..dc655d31cb8 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 { @@ -890,11 +890,323 @@ 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("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("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", + }, + }, + }, + }), + 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("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("keeps distinct suffix-overlapping 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("foo\noobar"); + }); + + 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({ + 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({ + 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 +1234,248 @@ 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("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"; + 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("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, + }, + }, + ], + }), + ]; + + 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({ + 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({ + 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({ + 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..82bc0583b0c 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,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; } @@ -632,6 +673,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 +690,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 +716,29 @@ 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; + } + if (next.startsWith(previous)) { + return next; + } + if (previous.startsWith(next)) { + return previous; + } + const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; + return `${previous}${separator}${next}`; +} + function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; @@ -894,6 +970,96 @@ 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 rawOutputStdout = firstRawStringFromRecord(rawOutput, ["stdout"]); + const stdout = + rawOutputStdout ?? + 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` 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); } @@ -1027,6 +1193,155 @@ 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, 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 {