From 2327fac86524f7a36c709ccbace05a483526f5ab Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 12 Mar 2026 20:50:03 +0100 Subject: [PATCH 01/10] Add rich Codex tool call rows with icons and metadata - Gate enhanced tool-call rendering to Codex sessions in `ChatView` - Redesign work-log rows with icons, collapsible details, and file/output previews - Preserve tool lifecycle metadata in `session-logic` and extend tests for output/exit parsing --- apps/web/src/components/ChatView.tsx | 4 + .../src/components/chat/MessagesTimeline.tsx | 557 ++++++++++++++++-- apps/web/src/session-logic.test.ts | 38 ++ apps/web/src/session-logic.ts | 163 ++++- 4 files changed, 698 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 327789db65..74773482a1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3235,6 +3235,10 @@ export default function ChatView({ threadId }: ChatViewProps) { completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} + enableCodexToolCallUi={ + sessionProvider === "codex" || + (sessionProvider === null && selectedProvider === "codex") + } expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7a89e762e3..69a0aed040 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -10,7 +10,25 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; -import { Undo2Icon } from "lucide-react"; +import { + BotIcon, + CheckIcon, + ChevronRightIcon, + CircleAlertIcon, + DatabaseIcon, + EyeIcon, + FileIcon, + FolderIcon, + HammerIcon, + type LucideIcon, + SearchIcon, + SquarePenIcon, + TargetIcon, + TerminalIcon, + Undo2Icon, + WrenchIcon, + ZapIcon, +} from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; import { estimateTimelineMessageHeight } from "../timelineHeight"; @@ -20,6 +38,10 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart } from "./MessagesTimeline.logic"; +import { Badge } from "../ui/badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { cn } from "~/lib/utils"; +import { basenameOfPath } from "../../vscode-icons"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -35,6 +57,7 @@ interface MessagesTimelineProps { completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; + enableCodexToolCallUi: boolean; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; @@ -58,6 +81,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, + enableCodexToolCallUi, expandedWorkGroups, onToggleWorkGroup, onOpenTurnDiff, @@ -285,73 +309,91 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; + const showHeader = hasOverflow || !onlyToolEntries; + const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return ( -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -
-

- {workEntry.label} -

- {workEntry.command && ( -
-                          {workEntry.command}
-                        
- )} - {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( -
- {workEntry.changedFiles.slice(0, 6).map((filePath) => ( - - {filePath} - - ))} - {workEntry.changedFiles.length > 6 && ( - - +{workEntry.changedFiles.length - 6} more - - )} -
- )} - {workEntry.detail && - (!workEntry.command || workEntry.detail !== workEntry.command) && ( +
+ {showHeader && ( +
+

+ {groupLabel} ({groupedEntries.length}) +

+ {hasOverflow && ( + + )} +
+ )} +
+ {enableCodexToolCallUi + ? visibleEntries.map((workEntry, workEntryIndex) => + isRichToolWorkEntry(workEntry) ? ( + + ) : ( + + ), + ) + : visibleEntries.map((workEntry) => ( +
+ +

- {workEntry.detail} + {workEntry.label}

- )} -
-
- ))} + {workEntry.command && ( +
+                              {workEntry.command}
+                            
+ )} + {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( +
+ {workEntry.changedFiles.slice(0, 6).map((filePath) => ( + + {filePath} + + ))} + {workEntry.changedFiles.length > 6 && ( + + +{workEntry.changedFiles.length - 6} more + + )} +
+ )} + {workEntry.detail && + (!workEntry.command || workEntry.detail !== workEntry.command) && ( +

+ {workEntry.detail} +

+ )} +
+
+ ))}
); @@ -655,9 +697,398 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { return `${formatTimestamp(createdAt)} • ${duration}`; } +function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string } { + if (tone === "error") { + return { + icon: CircleAlertIcon, + className: "text-foreground/92", + }; + } + if (tone === "thinking") { + return { + icon: BotIcon, + className: "text-foreground/92", + }; + } + if (tone === "info") { + return { + icon: CheckIcon, + className: "text-foreground/92", + }; + } + return { + icon: ZapIcon, + className: "text-foreground/92", + }; +} + function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40"; } + +function workEntryPreview( + workEntry: Pick, +) { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; +} + +function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { + if (workEntry.requestKind === "command") return TerminalIcon; + if (workEntry.requestKind === "file-read") return EyeIcon; + if (workEntry.requestKind === "file-change") return SquarePenIcon; + + const haystack = [ + workEntry.label, + workEntry.toolTitle, + workEntry.detail, + workEntry.output, + workEntry.command, + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); + + if (haystack.includes("report_intent") || haystack.includes("intent logged")) { + return TargetIcon; + } + if ( + haystack.includes("bash") || + haystack.includes("read_bash") || + haystack.includes("write_bash") || + haystack.includes("stop_bash") || + haystack.includes("list_bash") + ) { + return TerminalIcon; + } + if (haystack.includes("sql")) return DatabaseIcon; + if (haystack.includes("view")) return EyeIcon; + if (haystack.includes("apply_patch")) return SquarePenIcon; + if (haystack.includes("rg") || haystack.includes("glob") || haystack.includes("search")) { + return SearchIcon; + } + if (haystack.includes("skill")) return ZapIcon; + if (haystack.includes("ask_user") || haystack.includes("approval")) return BotIcon; + if (haystack.includes("edit") || haystack.includes("patch")) return WrenchIcon; + if (haystack.includes("file")) return FileIcon; + if (haystack.includes("task")) return HammerIcon; + if (haystack.includes("store_memory")) return FolderIcon; + + switch (workEntry.itemType) { + case "command_execution": + return TerminalIcon; + case "file_change": + return SquarePenIcon; + case "mcp_tool_call": + return WrenchIcon; + case "dynamic_tool_call": + case "collab_agent_tool_call": + return HammerIcon; + case "web_search": + return SearchIcon; + case "image_view": + return EyeIcon; + } + + return workToneIcon(workEntry.tone).icon; +} + +function capitalizePhrase(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return value; + } + return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; +} + +function stripToolCompletionSuffix(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); +} + +function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { + if (!workEntry.toolTitle) { + return capitalizePhrase(stripToolCompletionSuffix(workEntry.label)); + } + + if (workEntry.toolStatus === "failed") { + return capitalizePhrase(`${workEntry.toolTitle} failed`); + } + if (workEntry.toolStatus === "declined") { + return capitalizePhrase(`${workEntry.toolTitle} declined`); + } + if (workEntry.toolStatus === "inProgress" || workEntry.activityKind === "tool.updated") { + return capitalizePhrase(`${workEntry.toolTitle} running`); + } + if (workEntry.activityKind === "tool.started") { + return capitalizePhrase(`${workEntry.toolTitle} started`); + } + return capitalizePhrase(workEntry.toolTitle); +} + +function primaryWorkEntryPath(workEntry: TimelineWorkEntry): string | null { + const [firstPath] = workEntry.changedFiles ?? []; + return firstPath ?? null; +} + +function summarizeToolOutput(value: string, limit = 96): string { + const singleLine = value.replace(/\s+/g, " ").trim(); + if (singleLine.length <= limit) { + return singleLine; + } + return `${singleLine.slice(0, Math.max(0, limit - 3))}...`; +} + +function collapsedToolWorkEntryPreview( + workEntry: TimelineWorkEntry, + primaryPath: string | null, +): string | null { + if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { + if (workEntry.output) { + return summarizeToolOutput(workEntry.output); + } + if (workEntry.command) { + return workEntry.command; + } + } + if (primaryPath) { + return primaryPath; + } + if (workEntry.command) { + return workEntry.command; + } + if (workEntry.output) { + return summarizeToolOutput(workEntry.output); + } + if (workEntry.detail) { + return summarizeToolOutput(workEntry.detail); + } + return null; +} + +function isRichToolWorkEntry(workEntry: TimelineWorkEntry): boolean { + return workEntry.activityKind === "tool.completed" || workEntry.activityKind === "tool.updated"; +} + +const ToolWorkEntryRow = memo(function ToolWorkEntryRow(props: { + workEntry: TimelineWorkEntry; + workEntryIndex: number; +}) { + const { workEntry, workEntryIndex } = props; + const [open, setOpen] = useState(false); + const iconConfig = workToneIcon(workEntry.tone); + const EntryIcon = workEntryIcon(workEntry); + const heading = toolWorkEntryHeading(workEntry); + const primaryPath = !workEntry.command ? primaryWorkEntryPath(workEntry) : null; + const additionalPaths = + workEntry.changedFiles?.slice(primaryPath ? 1 : 0, primaryPath ? 4 : 4) ?? []; + const hiddenPathCount = + (workEntry.changedFiles?.length ?? 0) - additionalPaths.length - (primaryPath ? 1 : 0); + const preview = collapsedToolWorkEntryPreview(workEntry, primaryPath); + const displayText = preview ? `${heading} - ${preview}` : heading; + const hasExpandedDetails = Boolean( + workEntry.command || + primaryPath || + additionalPaths.length > 0 || + workEntry.output || + typeof workEntry.exitCode === "number", + ); + + const summaryRow = ( +
+ + + + +
+

+ {heading} + {preview && - {preview}} +

+
+
+ ); + + const expandedDetails = ( +
+ {workEntry.command && ( +
+ + + {workEntry.command} + +
+ )} + + {!workEntry.command && primaryPath && ( +
+ + {basenameOfPath(primaryPath)} + + + {primaryPath} + +
+ )} + + {additionalPaths.length > 0 && ( +
+ {additionalPaths.map((filePath) => ( + + {filePath} + + ))} + {hiddenPathCount > 0 && ( + +{hiddenPathCount} + )} +
+ )} + + {workEntry.output && ( +
+
+            {workEntry.output}
+          
+
+ )} + + {typeof workEntry.exitCode === "number" && ( +
+ {workEntry.exitCode === 0 ? "Exited successfully" : `Exit ${workEntry.exitCode}`} +
+ )} +
+ ); + + if (!hasExpandedDetails) { + return ( +
+ {summaryRow} +
+ ); + } + + return ( +
+ + {summaryRow} + {expandedDetails} + +
+ ); +}); + +const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { + workEntry: TimelineWorkEntry; + workEntryIndex: number; +}) { + const { workEntry, workEntryIndex } = props; + const iconConfig = workToneIcon(workEntry.tone); + const EntryIcon = workEntryIcon(workEntry); + const preview = workEntryPreview(workEntry); + const displayText = preview ? `${workEntry.label} - ${preview}` : workEntry.label; + const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; + const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + + return ( +
+
+ + + +
+

+ + {workEntry.label} + + {preview && - {preview}} +

+
+
+ {hasChangedFiles && !previewIsChangedFiles && ( +
+ {workEntry.changedFiles?.slice(0, 4).map((filePath) => ( + + {filePath} + + ))} + {(workEntry.changedFiles?.length ?? 0) > 4 && ( + + +{(workEntry.changedFiles?.length ?? 0) - 4} + + )} +
+ )} +
+ ); +}); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d48..34dbaef15b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -453,6 +453,43 @@ describe("deriveWorkLogEntries", () => { expect(entry?.command).toBe("bun run lint"); }); + it("keeps tool lifecycle metadata for richer Codex tool rendering", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-with-metadata", + kind: "tool.completed", + summary: "bash complete", + payload: { + itemType: "command_execution", + title: "bash", + status: "completed", + detail: '{ "dev": "vite dev --port 3000" } ', + data: { + item: { + command: ["bun", "run", "dev"], + result: { + content: '{ "dev": "vite dev --port 3000" } ', + exitCode: 0, + }, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry).toMatchObject({ + activityKind: "tool.completed", + command: "bun run dev", + detail: '{ "dev": "vite dev --port 3000" }', + exitCode: 0, + itemType: "command_execution", + output: '{ "dev": "vite dev --port 3000" }', + toolStatus: "completed", + toolTitle: "bash", + }); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -508,6 +545,7 @@ describe("deriveTimelineEntries", () => { createdAt: "2026-02-23T00:00:03.000Z", label: "Ran tests", tone: "tool", + activityKind: "tool.completed", }, ], ); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index aa8f3ffc35..a2c21bba56 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -33,9 +33,23 @@ export interface WorkLogEntry { createdAt: string; label: string; detail?: string; + output?: string; command?: string; + exitCode?: number; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; + activityKind: OrchestrationThreadActivity["kind"]; + toolTitle?: string; + toolStatus?: "inProgress" | "completed" | "failed" | "declined"; + itemType?: + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view"; + requestKind?: PendingApproval["requestKind"]; } export interface PendingApproval { @@ -423,21 +437,48 @@ export function deriveWorkLogEntries( : null; const command = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); + const title = extractToolTitle(payload); + const status = extractToolStatus(payload); + const { output, exitCode } = extractToolOutputEnvelope(payload); const entry: WorkLogEntry = { id: activity.id, createdAt: activity.createdAt, label: activity.summary, tone: activity.tone === "approval" ? "info" : activity.tone, + activityKind: activity.kind, }; + const itemType = extractWorkLogItemType(payload); + const requestKind = extractWorkLogRequestKind(payload); if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { - entry.detail = payload.detail; + const detail = stripTrailingExitCode(payload.detail).output; + if (detail) { + entry.detail = detail; + } } if (command) { entry.command = command; } + if (output) { + entry.output = output; + } + if (exitCode !== undefined) { + entry.exitCode = exitCode; + } if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } + if (title) { + entry.toolTitle = title; + } + if (status) { + entry.toolStatus = status; + } + if (itemType) { + entry.itemType = itemType; + } + if (requestKind) { + entry.requestKind = requestKind; + } return entry; }); } @@ -482,6 +523,126 @@ function extractToolCommand(payload: Record | null): string | n return candidates.find((candidate) => candidate !== null) ?? null; } +function extractToolTitle(payload: Record | null): string | null { + return asTrimmedString(payload?.title); +} + +function extractToolStatus( + payload: Record | null, +): WorkLogEntry["toolStatus"] | undefined { + switch (payload?.status) { + case "in_progress": + return "inProgress"; + case "inProgress": + case "completed": + case "failed": + case "declined": + return payload.status; + default: + return undefined; + } +} + +function asInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +function extractToolExitCode(payload: Record | null): number | null { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const dataResult = asRecord(data?.result); + const candidates = [ + asInteger(itemResult?.exitCode), + asInteger(itemResult?.exit_code), + asInteger(dataResult?.exitCode), + asInteger(dataResult?.exit_code), + asInteger(data?.exitCode), + asInteger(data?.exit_code), + ]; + return candidates.find((candidate) => candidate !== null) ?? null; +} + +function stripTrailingExitCode(value: string): { + output: string | null; + exitCode?: number | undefined; +} { + const trimmed = value.trim(); + const match = /^(?[\s\S]*?)(?:\s*\d+)>)\s*$/i.exec( + trimmed, + ); + if (!match?.groups) { + return { + output: trimmed.length > 0 ? trimmed : null, + }; + } + const exitCode = Number.parseInt(match.groups.code ?? "", 10); + const normalizedOutput = match.groups.output?.trim() ?? ""; + return { + output: normalizedOutput.length > 0 ? normalizedOutput : null, + ...(Number.isInteger(exitCode) ? { exitCode } : {}), + }; +} + +function extractToolOutputEnvelope(payload: Record | null): { + output?: string | undefined; + exitCode?: number | undefined; +} { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const dataResult = asRecord(data?.result); + const outputCandidates = [ + asTrimmedString(itemResult?.content), + asTrimmedString(dataResult?.content), + asTrimmedString(data?.output), + asTrimmedString(data?.stdout), + asTrimmedString(data?.stderr), + asTrimmedString(payload?.detail), + ]; + const rawOutput = outputCandidates.find((candidate) => candidate !== null) ?? null; + if (!rawOutput) { + const exitCode = extractToolExitCode(payload); + return exitCode === null ? {} : { exitCode }; + } + const normalizedOutput = stripTrailingExitCode(rawOutput); + const exitCode = extractToolExitCode(payload) ?? normalizedOutput.exitCode; + return { + ...(normalizedOutput.output ? { output: normalizedOutput.output } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + }; +} + +function extractWorkLogItemType( + payload: Record | null, +): WorkLogEntry["itemType"] | undefined { + switch (payload?.itemType) { + case "command_execution": + case "file_change": + case "mcp_tool_call": + case "dynamic_tool_call": + case "collab_agent_tool_call": + case "web_search": + case "image_view": + return payload.itemType; + default: + return undefined; + } +} + +function extractWorkLogRequestKind( + payload: Record | null, +): WorkLogEntry["requestKind"] | undefined { + if ( + payload?.requestKind === "command" || + payload?.requestKind === "file-read" || + payload?.requestKind === "file-change" + ) { + return payload.requestKind; + } + return requestKindFromRequestType(payload?.requestType) ?? undefined; +} + function pushChangedFile(target: string[], seen: Set, value: unknown) { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { From b4fa9204193c6b890f3f473cedec828d20741d53 Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 12 Mar 2026 21:08:04 +0100 Subject: [PATCH 02/10] Refine tool call rows with clearer icons and compact labels - Normalize compact tool labels (e.g. "Command run" -> "Ran command") - Improve tool entry icon detection for command/file/web/image activity - Remove staggered row animations from tool work entry rendering and update tests --- .../chat/MessagesTimeline.logic.test.ts | 13 +++- .../components/chat/MessagesTimeline.logic.ts | 8 ++ .../src/components/chat/MessagesTimeline.tsx | 75 ++++++------------- 3 files changed, 42 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7074f46019..825e5e1df5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart } from "./MessagesTimeline.logic"; +import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -133,3 +133,14 @@ describe("computeMessageDurationStart", () => { expect(computeMessageDurationStart([])).toEqual(new Map()); }); }); + +describe("normalizeCompactToolLabel", () => { + it("renames command run labels to ran command", () => { + expect(normalizeCompactToolLabel("Command run")).toBe("Ran command"); + expect(normalizeCompactToolLabel("Command run complete")).toBe("Ran command"); + }); + + it("removes trailing completion wording from other labels", () => { + expect(normalizeCompactToolLabel("File read completed")).toBe("File read"); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 45408468ca..a6940d6263 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -23,3 +23,11 @@ export function computeMessageDurationStart( return result; } + +export function normalizeCompactToolLabel(value: string): string { + const trimmed = value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); + if (/^command run$/i.test(trimmed)) { + return "Ran command"; + } + return trimmed; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 69a0aed040..9b6ba9d21a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -37,7 +37,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart } from "./MessagesTimeline.logic"; +import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; import { Badge } from "../ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; @@ -332,18 +332,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
{enableCodexToolCallUi - ? visibleEntries.map((workEntry, workEntryIndex) => + ? visibleEntries.map((workEntry) => isRichToolWorkEntry(workEntry) ? ( - + ) : ( ), ) @@ -747,6 +742,15 @@ function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.requestKind === "file-read") return EyeIcon; if (workEntry.requestKind === "file-change") return SquarePenIcon; + if (workEntry.itemType === "command_execution" || workEntry.command) { + return TerminalIcon; + } + if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { + return SquarePenIcon; + } + if (workEntry.itemType === "web_search") return SearchIcon; + if (workEntry.itemType === "image_view") return EyeIcon; + const haystack = [ workEntry.label, workEntry.toolTitle, @@ -784,19 +788,11 @@ function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (haystack.includes("store_memory")) return FolderIcon; switch (workEntry.itemType) { - case "command_execution": - return TerminalIcon; - case "file_change": - return SquarePenIcon; case "mcp_tool_call": return WrenchIcon; case "dynamic_tool_call": case "collab_agent_tool_call": return HammerIcon; - case "web_search": - return SearchIcon; - case "image_view": - return EyeIcon; } return workToneIcon(workEntry.tone).icon; @@ -810,13 +806,9 @@ function capitalizePhrase(value: string): string { return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } -function stripToolCompletionSuffix(value: string): string { - return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); -} - function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { if (!workEntry.toolTitle) { - return capitalizePhrase(stripToolCompletionSuffix(workEntry.label)); + return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } if (workEntry.toolStatus === "failed") { @@ -831,7 +823,7 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { if (workEntry.activityKind === "tool.started") { return capitalizePhrase(`${workEntry.toolTitle} started`); } - return capitalizePhrase(workEntry.toolTitle); + return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } function primaryWorkEntryPath(workEntry: TimelineWorkEntry): string | null { @@ -878,11 +870,8 @@ function isRichToolWorkEntry(workEntry: TimelineWorkEntry): boolean { return workEntry.activityKind === "tool.completed" || workEntry.activityKind === "tool.updated"; } -const ToolWorkEntryRow = memo(function ToolWorkEntryRow(props: { - workEntry: TimelineWorkEntry; - workEntryIndex: number; -}) { - const { workEntry, workEntryIndex } = props; +const ToolWorkEntryRow = memo(function ToolWorkEntryRow(props: { workEntry: TimelineWorkEntry }) { + const { workEntry } = props; const [open, setOpen] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); @@ -1003,25 +992,11 @@ const ToolWorkEntryRow = memo(function ToolWorkEntryRow(props: { ); if (!hasExpandedDetails) { - return ( -
- {summaryRow} -
- ); + return
{summaryRow}
; } return ( -
+
{summaryRow} {expandedDetails} @@ -1032,9 +1007,8 @@ const ToolWorkEntryRow = memo(function ToolWorkEntryRow(props: { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; - workEntryIndex: number; }) { - const { workEntry, workEntryIndex } = props; + const { workEntry } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const preview = workEntryPreview(workEntry); @@ -1043,19 +1017,14 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; return ( -
+
-
+

{hasChangedFiles && !previewIsChangedFiles && ( -
+
{workEntry.changedFiles?.slice(0, 4).map((filePath) => ( Date: Thu, 12 Mar 2026 21:17:35 +0100 Subject: [PATCH 03/10] Propagate approval args and resolution for changed-file tracking - include `args` on `approval.requested` and `resolution` on `approval.resolved` activities - update work log file extraction to read changed paths from `payload.args` and `payload.resolution` - add server/web tests covering the new approval payload mapping and file path extraction --- .../Layers/ProviderRuntimeIngestion.test.ts | 10 ++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 4 ++++ apps/web/src/session-logic.test.ts | 20 +++++++++++++++++++ apps/web/src/session-logic.ts | 6 ++++-- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7edf..8255cbe15f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1007,6 +1007,12 @@ describe("ProviderRuntimeIngestion", () => { it("maps canonical request events into approval activities with requestKind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + const requestArgs = { + changes: [{ path: "apps/web/src/components/chat/MessagesTimeline.tsx" }], + }; + const resolution = { + files: [{ filename: "apps/web/src/components/chat/MessagesTimeline.tsx" }], + }; harness.emit({ type: "request.opened", @@ -1018,6 +1024,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { requestType: "command_execution_approval", detail: "pwd", + args: requestArgs, }, }); @@ -1031,6 +1038,7 @@ describe("ProviderRuntimeIngestion", () => { payload: { requestType: "command_execution_approval", decision: "accept", + resolution, }, }); @@ -1058,6 +1066,7 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(requestedPayload?.requestKind).toBe("command"); expect(requestedPayload?.requestType).toBe("command_execution_approval"); + expect(requestedPayload?.args).toEqual(requestArgs); const resolved = thread?.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-resolved", @@ -1068,6 +1077,7 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(resolvedPayload?.requestKind).toBe("command"); expect(resolvedPayload?.requestType).toBe("command_execution_approval"); + expect(resolvedPayload?.resolution).toEqual(resolution); }); it("maps runtime.error into errored session state", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0dd10dcb7c..4e6d44fd57 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -219,6 +219,7 @@ function runtimeEventToActivities( ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.args !== undefined ? { args: event.payload.args } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -243,6 +244,9 @@ function runtimeEventToActivities( ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.decision ? { decision: event.payload.decision } : {}), + ...(event.payload.resolution !== undefined + ? { resolution: event.payload.resolution } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 34dbaef15b..d3dbf06e1c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -516,6 +516,26 @@ describe("deriveWorkLogEntries", () => { "apps/web/src/session-logic.ts", ]); }); + + it("extracts changed file paths from file-change approval args", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "file-change-approval", + kind: "approval.requested", + summary: "File-change approval requested", + tone: "approval", + payload: { + requestKind: "file-change", + args: { + changes: [{ path: "apps/web/src/components/chat/MessagesTimeline.tsx" }], + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual(["apps/web/src/components/chat/MessagesTimeline.tsx"]); + }); }); describe("deriveTimelineEntries", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a2c21bba56..feb56c7191 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -701,9 +701,11 @@ function collectChangedFiles(value: unknown, target: string[], seen: Set } function extractChangedFiles(payload: Record | null): string[] { - const data = asRecord(payload?.data); const changedFiles: string[] = []; - collectChangedFiles(data, changedFiles, new Set(), 0); + const seen = new Set(); + collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); + collectChangedFiles(asRecord(payload?.args), changedFiles, seen, 0); + collectChangedFiles(asRecord(payload?.resolution), changedFiles, seen, 0); return changedFiles; } From 0795fca85d2f306cdb56ca2fe439d96af0f7bcd4 Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 12 Mar 2026 21:32:51 +0100 Subject: [PATCH 04/10] Simplify Codex tool call timeline Keep the compact Codex icon treatment while removing the extra expandable UI and approval metadata plumbing. This keeps the PR focused on the timeline scanability improvement without adding unnecessary parsing and server-side scope. --- .../Layers/ProviderRuntimeIngestion.test.ts | 10 - .../Layers/ProviderRuntimeIngestion.ts | 4 - .../src/components/chat/MessagesTimeline.tsx | 254 +----------------- apps/web/src/session-logic.test.ts | 27 +- apps/web/src/session-logic.ts | 83 ------ 5 files changed, 7 insertions(+), 371 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 8255cbe15f..b6b48c7edf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1007,12 +1007,6 @@ describe("ProviderRuntimeIngestion", () => { it("maps canonical request events into approval activities with requestKind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - const requestArgs = { - changes: [{ path: "apps/web/src/components/chat/MessagesTimeline.tsx" }], - }; - const resolution = { - files: [{ filename: "apps/web/src/components/chat/MessagesTimeline.tsx" }], - }; harness.emit({ type: "request.opened", @@ -1024,7 +1018,6 @@ describe("ProviderRuntimeIngestion", () => { payload: { requestType: "command_execution_approval", detail: "pwd", - args: requestArgs, }, }); @@ -1038,7 +1031,6 @@ describe("ProviderRuntimeIngestion", () => { payload: { requestType: "command_execution_approval", decision: "accept", - resolution, }, }); @@ -1066,7 +1058,6 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(requestedPayload?.requestKind).toBe("command"); expect(requestedPayload?.requestType).toBe("command_execution_approval"); - expect(requestedPayload?.args).toEqual(requestArgs); const resolved = thread?.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-resolved", @@ -1077,7 +1068,6 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(resolvedPayload?.requestKind).toBe("command"); expect(resolvedPayload?.requestType).toBe("command_execution_approval"); - expect(resolvedPayload?.resolution).toEqual(resolution); }); it("maps runtime.error into errored session state", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 4e6d44fd57..0dd10dcb7c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -219,7 +219,6 @@ function runtimeEventToActivities( ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.args !== undefined ? { args: event.payload.args } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -244,9 +243,6 @@ function runtimeEventToActivities( ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.decision ? { decision: event.payload.decision } : {}), - ...(event.payload.resolution !== undefined - ? { resolution: event.payload.resolution } - : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9b6ba9d21a..e183b76d80 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -13,17 +13,12 @@ import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, - ChevronRightIcon, CircleAlertIcon, - DatabaseIcon, EyeIcon, - FileIcon, - FolderIcon, HammerIcon, type LucideIcon, SearchIcon, SquarePenIcon, - TargetIcon, TerminalIcon, Undo2Icon, WrenchIcon, @@ -38,10 +33,7 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; -import { Badge } from "../ui/badge"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; -import { basenameOfPath } from "../../vscode-icons"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -332,16 +324,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
{enableCodexToolCallUi - ? visibleEntries.map((workEntry) => - isRichToolWorkEntry(workEntry) ? ( - - ) : ( - - ), - ) + ? visibleEntries.map((workEntry) => ( + + )) : visibleEntries.map((workEntry) => (
typeof value === "string" && value.length > 0) - .join(" ") - .toLowerCase(); - - if (haystack.includes("report_intent") || haystack.includes("intent logged")) { - return TargetIcon; - } - if ( - haystack.includes("bash") || - haystack.includes("read_bash") || - haystack.includes("write_bash") || - haystack.includes("stop_bash") || - haystack.includes("list_bash") - ) { - return TerminalIcon; - } - if (haystack.includes("sql")) return DatabaseIcon; - if (haystack.includes("view")) return EyeIcon; - if (haystack.includes("apply_patch")) return SquarePenIcon; - if (haystack.includes("rg") || haystack.includes("glob") || haystack.includes("search")) { - return SearchIcon; - } - if (haystack.includes("skill")) return ZapIcon; - if (haystack.includes("ask_user") || haystack.includes("approval")) return BotIcon; - if (haystack.includes("edit") || haystack.includes("patch")) return WrenchIcon; - if (haystack.includes("file")) return FileIcon; - if (haystack.includes("task")) return HammerIcon; - if (haystack.includes("store_memory")) return FolderIcon; - switch (workEntry.itemType) { case "mcp_tool_call": return WrenchIcon; @@ -810,209 +759,18 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } - - if (workEntry.toolStatus === "failed") { - return capitalizePhrase(`${workEntry.toolTitle} failed`); - } - if (workEntry.toolStatus === "declined") { - return capitalizePhrase(`${workEntry.toolTitle} declined`); - } - if (workEntry.toolStatus === "inProgress" || workEntry.activityKind === "tool.updated") { - return capitalizePhrase(`${workEntry.toolTitle} running`); - } - if (workEntry.activityKind === "tool.started") { - return capitalizePhrase(`${workEntry.toolTitle} started`); - } return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } -function primaryWorkEntryPath(workEntry: TimelineWorkEntry): string | null { - const [firstPath] = workEntry.changedFiles ?? []; - return firstPath ?? null; -} - -function summarizeToolOutput(value: string, limit = 96): string { - const singleLine = value.replace(/\s+/g, " ").trim(); - if (singleLine.length <= limit) { - return singleLine; - } - return `${singleLine.slice(0, Math.max(0, limit - 3))}...`; -} - -function collapsedToolWorkEntryPreview( - workEntry: TimelineWorkEntry, - primaryPath: string | null, -): string | null { - if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { - if (workEntry.output) { - return summarizeToolOutput(workEntry.output); - } - if (workEntry.command) { - return workEntry.command; - } - } - if (primaryPath) { - return primaryPath; - } - if (workEntry.command) { - return workEntry.command; - } - if (workEntry.output) { - return summarizeToolOutput(workEntry.output); - } - if (workEntry.detail) { - return summarizeToolOutput(workEntry.detail); - } - return null; -} - -function isRichToolWorkEntry(workEntry: TimelineWorkEntry): boolean { - return workEntry.activityKind === "tool.completed" || workEntry.activityKind === "tool.updated"; -} - -const ToolWorkEntryRow = memo(function ToolWorkEntryRow(props: { workEntry: TimelineWorkEntry }) { - const { workEntry } = props; - const [open, setOpen] = useState(false); - const iconConfig = workToneIcon(workEntry.tone); - const EntryIcon = workEntryIcon(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const primaryPath = !workEntry.command ? primaryWorkEntryPath(workEntry) : null; - const additionalPaths = - workEntry.changedFiles?.slice(primaryPath ? 1 : 0, primaryPath ? 4 : 4) ?? []; - const hiddenPathCount = - (workEntry.changedFiles?.length ?? 0) - additionalPaths.length - (primaryPath ? 1 : 0); - const preview = collapsedToolWorkEntryPreview(workEntry, primaryPath); - const displayText = preview ? `${heading} - ${preview}` : heading; - const hasExpandedDetails = Boolean( - workEntry.command || - primaryPath || - additionalPaths.length > 0 || - workEntry.output || - typeof workEntry.exitCode === "number", - ); - - const summaryRow = ( -
- - - - -
-

- {heading} - {preview && - {preview}} -

-
-
- ); - - const expandedDetails = ( -
- {workEntry.command && ( -
- - - {workEntry.command} - -
- )} - - {!workEntry.command && primaryPath && ( -
- - {basenameOfPath(primaryPath)} - - - {primaryPath} - -
- )} - - {additionalPaths.length > 0 && ( -
- {additionalPaths.map((filePath) => ( - - {filePath} - - ))} - {hiddenPathCount > 0 && ( - +{hiddenPathCount} - )} -
- )} - - {workEntry.output && ( -
-
-            {workEntry.output}
-          
-
- )} - - {typeof workEntry.exitCode === "number" && ( -
- {workEntry.exitCode === 0 ? "Exited successfully" : `Exit ${workEntry.exitCode}`} -
- )} -
- ); - - if (!hasExpandedDetails) { - return
{summaryRow}
; - } - - return ( -
- - {summaryRow} - {expandedDetails} - -
- ); -}); - const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; }) { const { workEntry } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); + const heading = toolWorkEntryHeading(workEntry); const preview = workEntryPreview(workEntry); - const displayText = preview ? `${workEntry.label} - ${preview}` : workEntry.label; + const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; @@ -1034,7 +792,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { title={displayText} > - {workEntry.label} + {heading} {preview && - {preview}}

diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index d3dbf06e1c..03b0f2b655 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -453,7 +453,7 @@ describe("deriveWorkLogEntries", () => { expect(entry?.command).toBe("bun run lint"); }); - it("keeps tool lifecycle metadata for richer Codex tool rendering", () => { + it("keeps compact Codex tool metadata used for icons and labels", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "tool-with-metadata", @@ -479,13 +479,9 @@ describe("deriveWorkLogEntries", () => { const [entry] = deriveWorkLogEntries(activities, undefined); expect(entry).toMatchObject({ - activityKind: "tool.completed", command: "bun run dev", detail: '{ "dev": "vite dev --port 3000" }', - exitCode: 0, itemType: "command_execution", - output: '{ "dev": "vite dev --port 3000" }', - toolStatus: "completed", toolTitle: "bash", }); }); @@ -516,26 +512,6 @@ describe("deriveWorkLogEntries", () => { "apps/web/src/session-logic.ts", ]); }); - - it("extracts changed file paths from file-change approval args", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "file-change-approval", - kind: "approval.requested", - summary: "File-change approval requested", - tone: "approval", - payload: { - requestKind: "file-change", - args: { - changes: [{ path: "apps/web/src/components/chat/MessagesTimeline.tsx" }], - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities, undefined); - expect(entry?.changedFiles).toEqual(["apps/web/src/components/chat/MessagesTimeline.tsx"]); - }); }); describe("deriveTimelineEntries", () => { @@ -565,7 +541,6 @@ describe("deriveTimelineEntries", () => { createdAt: "2026-02-23T00:00:03.000Z", label: "Ran tests", tone: "tool", - activityKind: "tool.completed", }, ], ); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index feb56c7191..f80293c079 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -33,14 +33,10 @@ export interface WorkLogEntry { createdAt: string; label: string; detail?: string; - output?: string; command?: string; - exitCode?: number; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; - activityKind: OrchestrationThreadActivity["kind"]; toolTitle?: string; - toolStatus?: "inProgress" | "completed" | "failed" | "declined"; itemType?: | "command_execution" | "file_change" @@ -438,14 +434,11 @@ export function deriveWorkLogEntries( const command = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); - const status = extractToolStatus(payload); - const { output, exitCode } = extractToolOutputEnvelope(payload); const entry: WorkLogEntry = { id: activity.id, createdAt: activity.createdAt, label: activity.summary, tone: activity.tone === "approval" ? "info" : activity.tone, - activityKind: activity.kind, }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); @@ -458,21 +451,12 @@ export function deriveWorkLogEntries( if (command) { entry.command = command; } - if (output) { - entry.output = output; - } - if (exitCode !== undefined) { - entry.exitCode = exitCode; - } if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } if (title) { entry.toolTitle = title; } - if (status) { - entry.toolStatus = status; - } if (itemType) { entry.itemType = itemType; } @@ -527,42 +511,6 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } -function extractToolStatus( - payload: Record | null, -): WorkLogEntry["toolStatus"] | undefined { - switch (payload?.status) { - case "in_progress": - return "inProgress"; - case "inProgress": - case "completed": - case "failed": - case "declined": - return payload.status; - default: - return undefined; - } -} - -function asInteger(value: unknown): number | null { - return typeof value === "number" && Number.isInteger(value) ? value : null; -} - -function extractToolExitCode(payload: Record | null): number | null { - const data = asRecord(payload?.data); - const item = asRecord(data?.item); - const itemResult = asRecord(item?.result); - const dataResult = asRecord(data?.result); - const candidates = [ - asInteger(itemResult?.exitCode), - asInteger(itemResult?.exit_code), - asInteger(dataResult?.exitCode), - asInteger(dataResult?.exit_code), - asInteger(data?.exitCode), - asInteger(data?.exit_code), - ]; - return candidates.find((candidate) => candidate !== null) ?? null; -} - function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -584,35 +532,6 @@ function stripTrailingExitCode(value: string): { }; } -function extractToolOutputEnvelope(payload: Record | null): { - output?: string | undefined; - exitCode?: number | undefined; -} { - const data = asRecord(payload?.data); - const item = asRecord(data?.item); - const itemResult = asRecord(item?.result); - const dataResult = asRecord(data?.result); - const outputCandidates = [ - asTrimmedString(itemResult?.content), - asTrimmedString(dataResult?.content), - asTrimmedString(data?.output), - asTrimmedString(data?.stdout), - asTrimmedString(data?.stderr), - asTrimmedString(payload?.detail), - ]; - const rawOutput = outputCandidates.find((candidate) => candidate !== null) ?? null; - if (!rawOutput) { - const exitCode = extractToolExitCode(payload); - return exitCode === null ? {} : { exitCode }; - } - const normalizedOutput = stripTrailingExitCode(rawOutput); - const exitCode = extractToolExitCode(payload) ?? normalizedOutput.exitCode; - return { - ...(normalizedOutput.output ? { output: normalizedOutput.output } : {}), - ...(exitCode !== undefined ? { exitCode } : {}), - }; -} - function extractWorkLogItemType( payload: Record | null, ): WorkLogEntry["itemType"] | undefined { @@ -704,8 +623,6 @@ function extractChangedFiles(payload: Record | null): string[] const changedFiles: string[] = []; const seen = new Set(); collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); - collectChangedFiles(asRecord(payload?.args), changedFiles, seen, 0); - collectChangedFiles(asRecord(payload?.resolution), changedFiles, seen, 0); return changedFiles; } From 92d5000bb45df33762b3b709f3c79073fe95f9f0 Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 12 Mar 2026 22:27:52 +0100 Subject: [PATCH 05/10] Rename command tool labels to Ran command - Replace "Command run" titles with "Ran command" across server fixtures and tests - Simplify compact tool label normalization to only strip trailing complete/completed text --- apps/server/integration/fixtures/providerRuntime.ts | 4 ++-- apps/server/src/provider/Layers/CodexAdapter.ts | 2 +- apps/server/src/provider/Layers/ProviderService.test.ts | 6 +++--- .../web/src/components/chat/MessagesTimeline.logic.test.ts | 7 +++---- apps/web/src/components/chat/MessagesTimeline.logic.ts | 6 +----- apps/web/src/session-logic.test.ts | 4 ++-- 6 files changed, 12 insertions(+), 17 deletions(-) diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index f56a587cb7..42e7ecd87d 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -73,7 +73,7 @@ export const codexTurnToolFixture = [ turnId: TURN_ID, payload: { itemType: "command_execution", - title: "Command run", + title: "Ran command", detail: "echo integration", }, }, @@ -85,7 +85,7 @@ export const codexTurnToolFixture = [ payload: { itemType: "command_execution", status: "completed", - title: "Command run", + title: "Ran command", detail: "echo integration", }, }, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 59eabbc628..1e4b80ae9c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -164,7 +164,7 @@ function itemTitle(itemType: CanonicalItemType): string | undefined { case "plan": return "Plan"; case "command_execution": - return "Command run"; + return "Ran command"; case "file_change": return "File change"; case "mcp_tool_call": diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 178f86916e..d5cf4424b1 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -703,7 +703,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", - title: "Command run", + title: "Ran command", }); fanout.codex.emit({ type: "tool.completed", @@ -713,7 +713,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", - title: "Command run", + title: "Ran command", }); fanout.codex.emit({ type: "turn.completed", @@ -768,7 +768,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), toolKind: "command", - title: "Command run", + title: "Ran command", detail: "echo one", }, { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 825e5e1df5..dee42a8586 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -135,12 +135,11 @@ describe("computeMessageDurationStart", () => { }); describe("normalizeCompactToolLabel", () => { - it("renames command run labels to ran command", () => { - expect(normalizeCompactToolLabel("Command run")).toBe("Ran command"); - expect(normalizeCompactToolLabel("Command run complete")).toBe("Ran command"); + it("removes trailing completion wording from command labels", () => { + expect(normalizeCompactToolLabel("Ran command complete")).toBe("Ran command"); }); it("removes trailing completion wording from other labels", () => { - expect(normalizeCompactToolLabel("File read completed")).toBe("File read"); + expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a6940d6263..726d61888e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -25,9 +25,5 @@ export function computeMessageDurationStart( } export function normalizeCompactToolLabel(value: string): string { - const trimmed = value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); - if (/^command run$/i.test(trimmed)) { - return "Ran command"; - } - return trimmed; + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 03b0f2b655..21c6934a14 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -400,7 +400,7 @@ describe("deriveWorkLogEntries", () => { makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command run complete", + summary: "Ran command complete", tone: "tool", kind: "tool.completed", }), @@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => { makeActivity({ id: "command-tool", kind: "tool.completed", - summary: "Command run complete", + summary: "Ran command complete", payload: { itemType: "command_execution", data: { From fd144d145ed789c0887c0bb04f8b32101c191ee5 Mon Sep 17 00:00:00 2001 From: Zortos Date: Fri, 13 Mar 2026 00:45:02 +0100 Subject: [PATCH 06/10] Remove duplicate import in MessagesTimeline.tsx Removed duplicate import of computeMessageDurationStart. --- apps/web/src/components/chat/MessagesTimeline.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 31b09611c8..884e156c22 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -34,7 +34,6 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; import { cn } from "~/lib/utils"; -import { computeMessageDurationStart } from "./MessagesTimeline.logic"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; From bc8da325a0d9f73181f3ba45e38ac67b264fa3d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 17:27:01 -0700 Subject: [PATCH 07/10] globe for search --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 884e156c22..b6a2f0ed2e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -15,9 +15,9 @@ import { CheckIcon, CircleAlertIcon, EyeIcon, + GlobeIcon, HammerIcon, type LucideIcon, - SearchIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -742,7 +742,7 @@ function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { return SquarePenIcon; } - if (workEntry.itemType === "web_search") return SearchIcon; + if (workEntry.itemType === "web_search") return GlobeIcon; if (workEntry.itemType === "image_view") return EyeIcon; switch (workEntry.itemType) { From d84cc905df7e21cf41ae44f929fd514bdc410c35 Mon Sep 17 00:00:00 2001 From: Zortos Date: Fri, 13 Mar 2026 01:42:45 +0100 Subject: [PATCH 08/10] Move tool lifecycle types --- .../Layers/ProviderRuntimeIngestion.ts | 16 ++--- apps/web/src/components/ChatView.tsx | 4 -- .../src/components/chat/MessagesTimeline.tsx | 61 +++---------------- apps/web/src/lib/browserStateStorage.ts | 24 ++++++++ apps/web/src/session-logic.test.ts | 8 +-- apps/web/src/session-logic.ts | 28 +++------ packages/contracts/src/providerRuntime.ts | 17 ++++-- 7 files changed, 61 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/lib/browserStateStorage.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0dd10dcb7c..c169f30ba9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -5,7 +5,9 @@ import { MessageId, type OrchestrationEvent, CheckpointRef, + TOOL_LIFECYCLE_ITEM_TYPES, ThreadId, + type ToolLifecycleItemType, TurnId, type OrchestrationThreadActivity, type ProviderRuntimeEvent, @@ -173,16 +175,8 @@ function requestKindFromCanonicalRequestType( } } -function isToolLifecycleItemType(itemType: string): boolean { - return ( - itemType === "command_execution" || - itemType === "file_change" || - itemType === "mcp_tool_call" || - itemType === "dynamic_tool_call" || - itemType === "collab_agent_tool_call" || - itemType === "web_search" || - itemType === "image_view" - ); +function isToolLifecycleItemType(itemType: string): itemType is ToolLifecycleItemType { + return TOOL_LIFECYCLE_ITEM_TYPES.includes(itemType as ToolLifecycleItemType); } function runtimeEventToActivities( @@ -449,7 +443,7 @@ function runtimeEventToActivities( createdAt: event.createdAt, tone: "tool", kind: "tool.completed", - summary: `${event.payload.title ?? "Tool"} complete`, + summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3e967a5da1..64c15ba2fc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3286,10 +3286,6 @@ export default function ChatView({ threadId }: ChatViewProps) { completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} - enableCodexToolCallUi={ - sessionProvider === "codex" || - (sessionProvider === null && selectedProvider === "codex") - } expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b6a2f0ed2e..e30801041f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -51,7 +51,6 @@ interface MessagesTimelineProps { completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; - enableCodexToolCallUi: boolean; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; @@ -76,7 +75,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, - enableCodexToolCallUi, expandedWorkGroups, onToggleWorkGroup, onOpenTurnDiff, @@ -327,57 +325,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)}
- {enableCodexToolCallUi - ? visibleEntries.map((workEntry) => ( - - )) - : visibleEntries.map((workEntry) => ( -
- -
-

- {workEntry.label} -

- {workEntry.command && ( -
-                              {workEntry.command}
-                            
- )} - {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( -
- {workEntry.changedFiles.slice(0, 6).map((filePath) => ( - - {filePath} - - ))} - {workEntry.changedFiles.length > 6 && ( - - +{workEntry.changedFiles.length - 6} more - - )} -
- )} - {workEntry.detail && - (!workEntry.command || workEntry.detail !== workEntry.command) && ( -

- {workEntry.detail} -

- )} -
-
- ))} + {visibleEntries.map((workEntry) => ( + + ))}
); @@ -686,7 +636,10 @@ function formatMessageMeta( return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } -function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string } { +function workToneIcon(tone: TimelineWorkEntry["tone"]): { + icon: LucideIcon; + className: string; +} { if (tone === "error") { return { icon: CircleAlertIcon, diff --git a/apps/web/src/lib/browserStateStorage.ts b/apps/web/src/lib/browserStateStorage.ts new file mode 100644 index 0000000000..0b1d2b21bd --- /dev/null +++ b/apps/web/src/lib/browserStateStorage.ts @@ -0,0 +1,24 @@ +import type { StateStorage } from "zustand/middleware"; + +const NOOP_STATE_STORAGE: StateStorage = Object.freeze({ + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}); + +function isStateStorage(value: unknown): value is StateStorage { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.getItem === "function" && + typeof candidate.setItem === "function" && + typeof candidate.removeItem === "function" + ); +} + +export function getSafeLocalStorage(): StateStorage { + if (typeof globalThis === "undefined") { + return NOOP_STATE_STORAGE; + } + return isStateStorage(globalThis.localStorage) ? globalThis.localStorage : NOOP_STATE_STORAGE; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 21c6934a14..74ba3a814f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -400,7 +400,7 @@ describe("deriveWorkLogEntries", () => { makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Ran command complete", + summary: "Ran command", tone: "tool", kind: "tool.completed", }), @@ -437,7 +437,7 @@ describe("deriveWorkLogEntries", () => { makeActivity({ id: "command-tool", kind: "tool.completed", - summary: "Ran command complete", + summary: "Ran command", payload: { itemType: "command_execution", data: { @@ -458,7 +458,7 @@ describe("deriveWorkLogEntries", () => { makeActivity({ id: "tool-with-metadata", kind: "tool.completed", - summary: "bash complete", + summary: "bash", payload: { itemType: "command_execution", title: "bash", @@ -491,7 +491,7 @@ describe("deriveWorkLogEntries", () => { makeActivity({ id: "file-tool", kind: "tool.completed", - summary: "File change complete", + summary: "File change", payload: { itemType: "file_change", data: { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 78e2652d2a..5b58ee5a83 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,9 +1,11 @@ import { ApprovalRequestId, + TOOL_LIFECYCLE_ITEM_TYPES, type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, type ProviderKind, + type ToolLifecycleItemType, type UserInputQuestion, type TurnId, } from "@t3tools/contracts"; @@ -37,14 +39,7 @@ export interface WorkLogEntry { changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; - itemType?: - | "command_execution" - | "file_change" - | "mcp_tool_call" - | "dynamic_tool_call" - | "collab_agent_tool_call" - | "web_search" - | "image_view"; + itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; } @@ -527,18 +522,13 @@ function stripTrailingExitCode(value: string): { function extractWorkLogItemType( payload: Record | null, ): WorkLogEntry["itemType"] | undefined { - switch (payload?.itemType) { - case "command_execution": - case "file_change": - case "mcp_tool_call": - case "dynamic_tool_call": - case "collab_agent_tool_call": - case "web_search": - case "image_view": - return payload.itemType; - default: - return undefined; + if (typeof payload?.itemType === "string") { + const itemType = payload.itemType as ToolLifecycleItemType; + if (TOOL_LIFECYCLE_ITEM_TYPES.includes(itemType)) { + return itemType; + } } + return undefined; } function extractWorkLogRequestKind( diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 96edded851..16f88cae31 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -93,11 +93,7 @@ const RuntimeErrorClass = Schema.Literals([ ]); export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; -export const CanonicalItemType = Schema.Literals([ - "user_message", - "assistant_message", - "reasoning", - "plan", +export const TOOL_LIFECYCLE_ITEM_TYPES = [ "command_execution", "file_change", "mcp_tool_call", @@ -105,6 +101,17 @@ export const CanonicalItemType = Schema.Literals([ "collab_agent_tool_call", "web_search", "image_view", +] as const; + +export const ToolLifecycleItemType = Schema.Literals(TOOL_LIFECYCLE_ITEM_TYPES); +export type ToolLifecycleItemType = typeof ToolLifecycleItemType.Type; + +export const CanonicalItemType = Schema.Literals([ + "user_message", + "assistant_message", + "reasoning", + "plan", + ...TOOL_LIFECYCLE_ITEM_TYPES, "review_entered", "review_exited", "context_compaction", From f079363edfbc4d6353d22f6a12f77543d501bfbe Mon Sep 17 00:00:00 2001 From: Zortos Date: Fri, 13 Mar 2026 02:01:37 +0100 Subject: [PATCH 09/10] Review tool lifecycle schema --- .../src/orchestration/Layers/ProviderRuntimeIngestion.ts | 7 +------ apps/web/src/session-logic.ts | 9 +++------ packages/contracts/src/providerRuntime.ts | 4 ++++ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index c169f30ba9..417e93c8d4 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -5,9 +5,8 @@ import { MessageId, type OrchestrationEvent, CheckpointRef, - TOOL_LIFECYCLE_ITEM_TYPES, + isToolLifecycleItemType, ThreadId, - type ToolLifecycleItemType, TurnId, type OrchestrationThreadActivity, type ProviderRuntimeEvent, @@ -175,10 +174,6 @@ function requestKindFromCanonicalRequestType( } } -function isToolLifecycleItemType(itemType: string): itemType is ToolLifecycleItemType { - return TOOL_LIFECYCLE_ITEM_TYPES.includes(itemType as ToolLifecycleItemType); -} - function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5b58ee5a83..e389f10e2d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,6 +1,6 @@ import { ApprovalRequestId, - TOOL_LIFECYCLE_ITEM_TYPES, + isToolLifecycleItemType, type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, @@ -522,11 +522,8 @@ function stripTrailingExitCode(value: string): { function extractWorkLogItemType( payload: Record | null, ): WorkLogEntry["itemType"] | undefined { - if (typeof payload?.itemType === "string") { - const itemType = payload.itemType as ToolLifecycleItemType; - if (TOOL_LIFECYCLE_ITEM_TYPES.includes(itemType)) { - return itemType; - } + if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { + return payload.itemType; } return undefined; } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 16f88cae31..d76475ab5a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -106,6 +106,10 @@ export const TOOL_LIFECYCLE_ITEM_TYPES = [ export const ToolLifecycleItemType = Schema.Literals(TOOL_LIFECYCLE_ITEM_TYPES); export type ToolLifecycleItemType = typeof ToolLifecycleItemType.Type; +export function isToolLifecycleItemType(value: string): value is ToolLifecycleItemType { + return TOOL_LIFECYCLE_ITEM_TYPES.includes(value as ToolLifecycleItemType); +} + export const CanonicalItemType = Schema.Literals([ "user_message", "assistant_message", From a4b67821350759a53e478dc3de4e58de1f8f3b73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 19:37:40 -0700 Subject: [PATCH 10/10] rm --- apps/web/src/lib/browserStateStorage.ts | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 apps/web/src/lib/browserStateStorage.ts diff --git a/apps/web/src/lib/browserStateStorage.ts b/apps/web/src/lib/browserStateStorage.ts deleted file mode 100644 index 0b1d2b21bd..0000000000 --- a/apps/web/src/lib/browserStateStorage.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { StateStorage } from "zustand/middleware"; - -const NOOP_STATE_STORAGE: StateStorage = Object.freeze({ - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, -}); - -function isStateStorage(value: unknown): value is StateStorage { - if (typeof value !== "object" || value === null) return false; - const candidate = value as Record; - return ( - typeof candidate.getItem === "function" && - typeof candidate.setItem === "function" && - typeof candidate.removeItem === "function" - ); -} - -export function getSafeLocalStorage(): StateStorage { - if (typeof globalThis === "undefined") { - return NOOP_STATE_STORAGE; - } - return isStateStorage(globalThis.localStorage) ? globalThis.localStorage : NOOP_STATE_STORAGE; -}