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/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0dd10dcb7c..417e93c8d4 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -5,6 +5,7 @@ import { MessageId, type OrchestrationEvent, CheckpointRef, + isToolLifecycleItemType, ThreadId, TurnId, type OrchestrationThreadActivity, @@ -173,18 +174,6 @@ 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 runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { @@ -449,7 +438,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/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 7074f46019..dee42a8586 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,13 @@ describe("computeMessageDurationStart", () => { expect(computeMessageDurationStart([])).toEqual(new Map()); }); }); + +describe("normalizeCompactToolLabel", () => { + 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("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 45408468ca..726d61888e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -23,3 +23,7 @@ export function computeMessageDurationStart( return result; } + +export function normalizeCompactToolLabel(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0e6d470a02..e30801041f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -10,7 +10,20 @@ 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, + CircleAlertIcon, + EyeIcon, + GlobeIcon, + HammerIcon, + type LucideIcon, + SquarePenIcon, + TerminalIcon, + Undo2Icon, + WrenchIcon, + ZapIcon, +} from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; import { estimateTimelineMessageHeight } from "../timelineHeight"; @@ -19,7 +32,8 @@ 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 { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; @@ -289,72 +303,30 @@ 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 && ( - - )} -
-
+
+ {showHeader && ( +
+

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

+ {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) && ( -

- {workEntry.detail} -

- )} -
-
+ ))}
@@ -664,9 +636,148 @@ function formatMessageMeta( return `${formatTimestamp(createdAt, timestampFormat)} • ${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; + + 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 GlobeIcon; + if (workEntry.itemType === "image_view") return EyeIcon; + + switch (workEntry.itemType) { + case "mcp_tool_call": + return WrenchIcon; + case "dynamic_tool_call": + case "collab_agent_tool_call": + return HammerIcon; + } + + 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 toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { + if (!workEntry.toolTitle) { + return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); + } + return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); +} + +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 ? `${heading} - ${preview}` : heading; + const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; + const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + + return ( +
+
+ + + +
+

+ + {heading} + + {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..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: "Command run complete", + summary: "Ran command", 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", payload: { itemType: "command_execution", data: { @@ -453,12 +453,45 @@ describe("deriveWorkLogEntries", () => { expect(entry?.command).toBe("bun run lint"); }); + it("keeps compact Codex tool metadata used for icons and labels", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-with-metadata", + kind: "tool.completed", + summary: "bash", + 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({ + command: "bun run dev", + detail: '{ "dev": "vite dev --port 3000" }', + itemType: "command_execution", + toolTitle: "bash", + }); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ 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 4e395b8b26..e389f10e2d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,9 +1,11 @@ import { ApprovalRequestId, + isToolLifecycleItemType, type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, type ProviderKind, + type ToolLifecycleItemType, type UserInputQuestion, type TurnId, } from "@t3tools/contracts"; @@ -36,6 +38,9 @@ export interface WorkLogEntry { command?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; + toolTitle?: string; + itemType?: ToolLifecycleItemType; + requestKind?: PendingApproval["requestKind"]; } export interface PendingApproval { @@ -415,14 +420,20 @@ export function deriveWorkLogEntries( : null; const command = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); + const title = extractToolTitle(payload); const entry: WorkLogEntry = { id: activity.id, createdAt: activity.createdAt, label: activity.summary, tone: activity.tone === "approval" ? "info" : activity.tone, }; + 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; @@ -430,6 +441,15 @@ export function deriveWorkLogEntries( if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } + if (title) { + entry.toolTitle = title; + } + if (itemType) { + entry.itemType = itemType; + } + if (requestKind) { + entry.requestKind = requestKind; + } return entry; }); } @@ -474,6 +494,53 @@ 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 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 extractWorkLogItemType( + payload: Record | null, +): WorkLogEntry["itemType"] | undefined { + if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { + return payload.itemType; + } + 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)) { @@ -532,9 +599,9 @@ 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); return changedFiles; } diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 96edded851..d76475ab5a 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,21 @@ 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 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", + "reasoning", + "plan", + ...TOOL_LIFECYCLE_ITEM_TYPES, "review_entered", "review_exited", "context_compaction",