diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..9b27c2980c8c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -150,6 +150,56 @@ function makeThread( }; } +function makeToolLifecycleActivity( + id: string, + sequence: number, + turnId: ReturnType, + kind: "tool.updated" | "tool.completed", + toolCallId?: string, +) { + const title = "Run tests"; + return makeActivity({ + id: EventId.make(id), + kind, + tone: "tool", + summary: title, + createdAt: `2026-04-01T00:00:0${sequence}.000Z`, + turnId, + payload: { + title, + itemType: "command_execution", + ...(toolCallId ? { toolCallId } : {}), + }, + }); +} + +function makeToolLifecycleThread( + id: string, + activities: ReadonlyArray< + readonly [ + id: string, + turnId: ReturnType, + kind: "tool.updated" | "tool.completed", + toolCallId?: string, + ] + >, +) { + return makeThread({ + id: ThreadId.make(`thread-${id}`), + projectId: ProjectId.make("project-1"), + title: id, + activities: activities.map(([activityId, turnId, kind, toolCallId], index) => + makeToolLifecycleActivity(activityId, index + 1, turnId, kind, toolCallId), + ), + }); +} + +function threadActivities(thread: OrchestrationThread): ThreadFeedActivity[] { + return buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); +} + describe("buildThreadFeed", () => { it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ @@ -227,6 +277,7 @@ describe("buildThreadFeed", () => { payload: { title: "Run tests", itemType: "command_execution", + toolCallId: "call-1", detail: "/bin/zsh -lc 'bun run test'", }, }), @@ -274,6 +325,157 @@ describe("buildThreadFeed", () => { ); }); + it("keeps ambiguous id-less mobile completions separate", () => { + const turnId = TurnId.make("turn-ambiguous-tools"); + const thread = makeToolLifecycleThread("ambiguous-tools", [ + ["tool-a-updated", turnId, "tool.updated", "call-a"], + ["tool-b-updated", turnId, "tool.updated", "call-b"], + ["tool-completed", turnId, "tool.completed"], + ]); + + expect(threadActivities(thread).map((entry) => entry.id)).toEqual([ + "tool-a-updated", + "tool-b-updated", + "tool-completed", + ]); + }); + + it("ignores prior turns when matching an id-less mobile completion", () => { + const previousTurnId = TurnId.make("turn-previous-tool"); + const currentTurnId = TurnId.make("turn-current-tool"); + const thread = makeToolLifecycleThread("prior-turn-tool", [ + ["prior-tool-updated", previousTurnId, "tool.updated", "prior-call"], + ["current-tool-updated", currentTurnId, "tool.updated", "current-call"], + ["current-tool-completed", currentTurnId, "tool.completed"], + ]); + + expect(threadActivities(thread).map((entry) => entry.id)).toEqual([ + "prior-tool-updated", + "current-tool-completed", + ]); + }); + + it("keeps an id-less mobile completion terminal after a late keyed update", () => { + const turnId = TurnId.make("turn-late-tool-update"); + const thread = makeToolLifecycleThread("late-tool-update", [ + ["tool-updated", turnId, "tool.updated", "call-1"], + ["tool-completed", turnId, "tool.completed"], + ["tool-late-update", turnId, "tool.updated", "call-1"], + ]); + + expect(threadActivities(thread)).toMatchObject([{ id: "tool-completed", status: "success" }]); + }); + + it("collapses interleaved and late lifecycle rows by top-level tool identity", () => { + const turnId = TurnId.make("turn-interleaved-tools"); + const lifecycleActivity = ( + id: string, + createdAt: string, + kind: "tool.updated" | "tool.completed", + toolCallId: string, + title: string, + status?: "inProgress" | "completed" | "failed" | "declined" | "stopped", + ) => + makeActivity({ + id: EventId.make(id), + kind, + tone: "tool", + summary: title, + createdAt, + turnId, + payload: { + itemType: "command_execution", + toolCallId, + title, + detail: title, + ...(status ? { status } : {}), + }, + }); + const thread = makeThread({ + id: ThreadId.make("thread-interleaved-tools"), + projectId: ProjectId.make("project-1"), + title: "Interleaved tools", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:05.000Z", + assistantMessageId: null, + }, + activities: [ + lifecycleActivity( + "tool-a-updated", + "2026-04-01T00:00:01.000Z", + "tool.updated", + "call-a", + "Preparing first call", + ), + lifecycleActivity( + "tool-b-updated", + "2026-04-01T00:00:02.000Z", + "tool.updated", + "call-b", + "Preparing second call", + ), + lifecycleActivity( + "tool-a-completed", + "2026-04-01T00:00:03.000Z", + "tool.completed", + "call-a", + "First call complete", + ), + lifecycleActivity( + "tool-a-late-updated", + "2026-04-01T00:00:03.500Z", + "tool.updated", + "call-a", + "Late first update", + ), + lifecycleActivity( + "tool-a-completed-duplicate", + "2026-04-01T00:00:03.750Z", + "tool.completed", + "call-a", + "First call complete", + ), + lifecycleActivity( + "tool-c-failed", + "2026-04-01T00:00:03.875Z", + "tool.updated", + "call-c", + "Third call failed", + "failed", + ), + lifecycleActivity( + "tool-c-late-updated", + "2026-04-01T00:00:03.900Z", + "tool.updated", + "call-c", + "Late third update", + "inProgress", + ), + lifecycleActivity( + "tool-b-completed", + "2026-04-01T00:00:04.000Z", + "tool.completed", + "call-b", + "Second call complete", + ), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group?.type).toBe("activity-group"); + if (!group || group.type !== "activity-group") return; + expect(group.activities.map((activity) => activity.id)).toEqual([ + "tool-a-completed-duplicate", + "tool-c-failed", + "tool-b-completed", + ]); + expect(group.activities[1]?.status).toBe("failure"); + }); + it("keeps MCP inputs available to expanded mobile work rows", () => { const turnId = TurnId.make("turn-mcp"); const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..debacb6a01e1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -64,6 +64,7 @@ interface WorkLogEntry { id: string; createdAt: string; turnId: TurnId | null; + toolCallId?: string; label: string; detail?: string; command?: string; @@ -352,6 +353,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const toolCallId = extractToolCallId(payload); // task.updated included: terminal bypassed updates (Codex children's only // terminal signal) must carry task identity so they collapse per child // instead of stacking anonymous "Task idle" rows. @@ -426,6 +428,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + if (toolCallId) { + entry.toolCallId = toolCallId; + } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; @@ -447,6 +452,7 @@ function collapseDerivedWorkLogEntries( // Subagent rows collapse by identity, not adjacency (quiet-timeline // guarantee; mirrors web's session-logic). const taskRowIndex = new Map(); + const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -463,12 +469,59 @@ function collapseDerivedWorkLogEntries( collapsed.push(entry); continue; } + const lifecycleKey = entry.toolCallId ? entry.collapseKey : undefined; + if (lifecycleKey !== undefined) { + const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey); + const matchingEntry = matchingIndex !== undefined ? collapsed[matchingIndex] : undefined; + if (matchingIndex !== undefined && matchingEntry) { + if (workLogEntryHasTerminalToolLifecycle(matchingEntry)) { + collapsed[matchingIndex] = workLogEntryHasTerminalToolLifecycle(entry) + ? mergeDerivedWorkLogEntries(matchingEntry, entry) + : mergeDerivedWorkLogEntries(entry, matchingEntry); + continue; + } + if (shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { + collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry); + continue; + } + toolLifecycleRowIndex.delete(lifecycleKey); + } + } const previous = collapsed.at(-1); - if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const hasCompetingIdlessCompletionTarget = + previous?.activityKind === "tool.updated" && + entry.activityKind === "tool.completed" && + entry.toolCallId === undefined && + collapsed + .slice(0, -1) + .some( + (candidate) => + candidate.activityKind !== "tool.completed" && + candidate.turnId === previous.turnId && + candidate.itemType === previous.itemType && + normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) === + normalizeCompactToolLabel(previous.toolTitle ?? previous.label), + ); + if ( + previous && + !hasCompetingIdlessCompletionTarget && + shouldCollapseToolLifecycleEntries(previous, entry) + ) { + const previousIndex = collapsed.length - 1; + if (previous.toolCallId && previous.collapseKey) { + toolLifecycleRowIndex.delete(previous.collapseKey); + } + const merged = mergeDerivedWorkLogEntries(previous, entry); + collapsed[previousIndex] = merged; + if (merged.toolCallId && merged.collapseKey) { + toolLifecycleRowIndex.set(merged.collapseKey, previousIndex); + } continue; } collapsed.push(entry); + if (lifecycleKey !== undefined) { + toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); + } } return collapsed; } @@ -486,7 +539,29 @@ function shouldCollapseToolLifecycleEntries( if (previous.activityKind === "tool.completed") { return false; } - return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; + if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { + return true; + } + return ( + previous.activityKind === "tool.updated" && + next.activityKind === "tool.completed" && + previous.toolCallId !== undefined && + next.toolCallId === undefined && + previous.turnId === next.turnId && + previous.itemType === next.itemType && + normalizeCompactToolLabel(previous.toolTitle ?? previous.label) === + normalizeCompactToolLabel(next.toolTitle ?? next.label) + ); +} + +function workLogEntryHasTerminalToolLifecycle(entry: DerivedWorkLogEntry): boolean { + return ( + entry.activityKind === "tool.completed" || + entry.toolLifecycleStatus === "completed" || + entry.toolLifecycleStatus === "failed" || + entry.toolLifecycleStatus === "declined" || + entry.toolLifecycleStatus === "stopped" + ); } function mergeDerivedWorkLogEntries( @@ -500,7 +575,11 @@ function mergeDerivedWorkLogEntries( const toolTitle = next.toolTitle ?? previous.toolTitle; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; - const collapseKey = next.collapseKey ?? previous.collapseKey; + const collapseKey = + previous.toolCallId !== undefined && next.toolCallId === undefined + ? previous.collapseKey + : (next.collapseKey ?? previous.collapseKey); + const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; return { @@ -514,6 +593,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), }; @@ -534,6 +614,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } + if (entry.toolCallId) { + return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; + } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); const detail = entry.detail?.trim() ?? ""; const itemType = entry.itemType ?? ""; @@ -915,6 +998,11 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractToolCallId(payload: Record | null): string | null { + const data = asRecord(payload?.data); + return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); +} + function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 336f7ed828c1..6bbfd9ce75ba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,7 +89,6 @@ import { derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, - deriveActivePlanState, deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, @@ -561,8 +560,10 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); - const latestUserMessageId = - input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; + const latestUserMessage = input.activeThread?.messages.findLast( + (message) => message.role === "user", + ); + const latestUserMessageId = latestUserMessage?.id ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -612,6 +613,7 @@ function useLocalDispatchState(input: { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, + latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, }; @@ -2296,25 +2298,6 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); - // Current step for the in-chat working row: only for the running turn's own - // plan (deriveActivePlanState falls back to older turns' plans, which must - // not label fresh work). Falls back to the first pending step so an - // all-pending freshly written plan labels the row, matching the chip and - // the server's planProgress. - const workingStepLabel = useMemo(() => { - if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { - return null; - } - return ( - activePlan.steps.find((step) => step.status === "inProgress")?.step ?? - activePlan.steps.find((step) => step.status === "pending")?.step ?? - null - ); - }, [activeLatestTurn?.turnId, activePlan]); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2325,6 +2308,7 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt, + latestUserMessageAt, isPreparingWorktree, isSendBusy, } = useLocalDispatchState({ @@ -2340,6 +2324,7 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn, activeThread?.session ?? null, localDispatchStartedAt, + latestUserMessageAt, ); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; @@ -4188,8 +4173,8 @@ function ChatViewContent(props: ChatViewProps) { ? (activeThreadShell?.planProgress ?? null) : null; const activeComposerTaskSteps = - activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId - ? activePlan.steps + activeComposerTasksProgress && activeLatestTurn + ? (turnPlans.findLast((plan) => plan.turnId === activeLatestTurn.turnId)?.plan.steps ?? null) : null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); @@ -6388,8 +6373,6 @@ function ChatViewContent(props: ChatViewProps) { onOpenAgents={addAgentsSurface} key={activeThread.id} isWorking={isWorking} - workingStepLabel={workingStepLabel} - activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 70a330d46303..54ff82b16ded 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5,6 +5,7 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + summarizeToolGroup, shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; @@ -218,6 +219,74 @@ describe("normalizeCompactToolLabel", () => { }); }); +describe("summarizeToolGroup", () => { + it("distinguishes local grep from web search", () => { + expect( + summarizeToolGroup([ + { + id: "grep", + createdAt: "2026-01-01T00:00:00Z", + label: "grep", + toolTitle: "grep", + tone: "tool", + itemType: "web_search", + }, + ]), + ).toBe("Searched code 1 time"); + expect( + summarizeToolGroup([ + { + id: "web-search", + createdAt: "2026-01-01T00:00:00Z", + label: "Web search", + toolTitle: "Web search", + tone: "tool", + itemType: "web_search", + }, + ]), + ).toBe("Searched the web 1 time"); + }); + + it("recognizes provider-neutral read tool calls", () => { + expect( + summarizeToolGroup([ + { + id: "read-file", + createdAt: "2026-01-01T00:00:00Z", + label: "Read File", + toolTitle: "Read File", + tone: "tool", + itemType: "dynamic_tool_call", + }, + ]), + ).toBe("Read 1 file"); + }); + + it("counts id-less lifecycle markers as one completed tool", () => { + expect( + summarizeToolGroup([ + { + id: "legacy-update", + createdAt: "2026-01-01T00:00:00Z", + label: "Glob", + tone: "tool", + itemType: "mcp_tool_call", + sourceActivityKind: "tool.updated", + }, + { + id: "legacy-complete", + createdAt: "2026-01-01T00:00:01Z", + label: "Glob", + tone: "tool", + itemType: "mcp_tool_call", + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "completed", + }, + ]), + ).toBe("Used 1 tool"); + }); +}); + describe("resolveAssistantMessageCopyState", () => { it("returns enabled copy state for completed assistant messages", () => { expect( @@ -545,7 +614,7 @@ describe("deriveMessagesTimelineRows", () => { "user-entry", "turn-fold:turn-1", "assistant-thought-entry", - "work-entry-1", + "work-toggle:work-entry-1", "assistant-final-entry", ]); expect( @@ -650,6 +719,99 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 12s"); }); + it("keeps a collapsed superseded turn fold by the response and expands it downward", () => { + const timelineEntries = [ + { + id: "initial-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "initial-user" as never, + role: "user", + text: "Start the work", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "superseded-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:10Z", + entry: { + id: "superseded-work", + createdAt: "2026-01-01T00:00:10Z", + turnId: "turn-1" as never, + label: "Ran command", + tone: "tool", + }, + }, + { + id: "steer-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:12Z", + message: { + id: "steer-user" as never, + role: "user", + text: "Change the approach", + turnId: null, + createdAt: "2026-01-01T00:00:12Z", + updatedAt: "2026-01-01T00:00:12Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Implemented locally, uncommitted.", + turnId: "turn-2" as never, + createdAt: "2026-01-01T00:00:20Z", + updatedAt: "2026-01-01T00:00:21Z", + streaming: false, + }, + }, + ] satisfies Parameters[0]["timelineEntries"]; + const input = { + timelineEntries, + latestTurn: { + turnId: "turn-2" as never, + state: "completed" as const, + startedAt: "2026-01-01T00:00:12Z", + completedAt: "2026-01-01T00:00:21Z", + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + const rows = deriveMessagesTimelineRows(input); + + expect(rows.map((row) => row.id)).toEqual([ + "initial-user-entry", + "steer-user-entry", + "turn-fold:turn-1", + "assistant-final-entry", + ]); + + const expandedRows = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set(["turn-1" as never]), + }); + + expect(expandedRows.map((row) => row.id)).toEqual([ + "initial-user-entry", + "turn-fold:turn-1", + "work-toggle:superseded-work-entry", + "steer-user-entry", + "assistant-final-entry", + ]); + }); + it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -756,33 +918,49 @@ describe("deriveMessagesTimelineRows", () => { expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); }); - it("does not fold the active in-progress turn", () => { + it("does not claim an unkeyed plan for the active turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { - id: "assistant-thought-entry", + id: "user-entry", kind: "message", - createdAt: "2026-01-01T00:00:05Z", + createdAt: "2026-01-01T00:00:00Z", message: { - id: "assistant-thought" as never, - role: "assistant", - text: "Working on it.", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:05Z", - updatedAt: "2026-01-01T00:00:06Z", + id: "user" as never, + role: "user", + text: "Make a plan", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, { - id: "work-entry-1", - kind: "work", - createdAt: "2026-01-01T00:00:08Z", - entry: { - id: "work-1", - createdAt: "2026-01-01T00:00:08Z", + id: "plan-entry", + kind: "proposed-plan", + createdAt: "2026-01-01T00:00:01Z", + proposedPlan: { + id: "plan" as never, + turnId: null, + planMarkdown: "# Plan", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:01Z", + }, + }, + { + id: "assistant-entry", + kind: "message", + createdAt: "2026-01-01T00:00:02Z", + message: { + id: "assistant" as never, + role: "assistant", + text: "Planning now.", turnId: "turn-1" as never, - label: "Ran command", - tone: "tool" as const, + createdAt: "2026-01-01T00:00:02Z", + updatedAt: "2026-01-01T00:00:02Z", + streaming: true, }, }, ], @@ -798,137 +976,89 @@ describe("deriveMessagesTimelineRows", () => { revertTurnCountByUserMessageId: new Map(), }); - expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ - "assistant-thought-entry", - "work-entry-1", + "user-entry", + "plan-entry", "working-indicator-row", + "assistant-entry", ]); }); - it("does not fold the session's running turn when latestTurn regresses", () => { + it("does not treat an older turn's proposed plan as active content", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { - id: "previous-work-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "previous-work", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Read files", - tone: "tool" as const, - }, - }, - { - id: "user-followup-entry", + id: "user-entry", kind: "message", - createdAt: "2026-01-01T00:01:00Z", + createdAt: "2026-01-01T00:00:00Z", message: { - id: "user-followup" as never, + id: "user" as never, role: "user", - text: "continue", + text: "Continue", turnId: null, - createdAt: "2026-01-01T00:01:00Z", - updatedAt: "2026-01-01T00:01:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, { - id: "running-work-entry", - kind: "work", - createdAt: "2026-01-01T00:01:05Z", - entry: { - id: "running-work", - createdAt: "2026-01-01T00:01:05Z", - turnId: "turn-2" as never, - label: "Searched files", - tone: "tool" as const, + id: "older-plan-entry", + kind: "proposed-plan", + createdAt: "2026-01-01T00:00:01Z", + proposedPlan: { + id: "older-plan" as never, + turnId: "turn-old" as never, + planMarkdown: "# Old plan", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:01Z", }, }, ], latestTurn: { - turnId: "turn-1" as never, - state: "completed", + turnId: "turn-current" as never, + state: "running", startedAt: "2026-01-01T00:00:00Z", - completedAt: "2026-01-01T00:00:25Z", + completedAt: null, }, - runningTurnId: "turn-2" as never, isWorking: true, - activeTurnStartedAt: "2026-01-01T00:01:00Z", + activeTurnStartedAt: "2026-01-01T00:00:00Z", turnDiffSummaryByAssistantMessageId: new Map(), revertTurnCountByUserMessageId: new Map(), }); - expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ - "turn-1", - ]); - expect(rows.map((row) => row.id)).toContain("running-work-entry"); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); }); - it("only shows assistant metadata on the terminal assistant message", () => { + it("does not fold the active in-progress turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { id: "assistant-thought-entry", kind: "message", - createdAt: "2026-01-01T00:00:10Z", + createdAt: "2026-01-01T00:00:05Z", message: { id: "assistant-thought" as never, role: "assistant", - text: "Checking first.", - turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:10Z", - updatedAt: "2026-01-01T00:00:11Z", - streaming: false, - }, - }, - { - id: "assistant-final-entry", - kind: "message", - createdAt: "2026-01-01T00:00:20Z", - message: { - id: "assistant-final" as never, - role: "assistant", - text: "Done.", + text: "Working on it.", turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:20Z", - updatedAt: "2026-01-01T00:00:30Z", + createdAt: "2026-01-01T00:00:05Z", + updatedAt: "2026-01-01T00:00:06Z", streaming: false, }, }, - ], - expandedTurnIds: new Set(["turn-1" as never]), - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - const assistantRows = rows.filter( - (row): row is Extract<(typeof rows)[number], { kind: "message" }> => - row.kind === "message" && row.message.role === "assistant", - ); - - expect(assistantRows.map((row) => row.showAssistantMeta)).toEqual([false, true]); - }); - - it("withholds assistant metadata while the active turn is still in progress", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ { - id: "assistant-thought-entry", - kind: "message", - createdAt: "2026-01-01T00:00:10Z", - message: { - id: "assistant-thought" as never, - role: "assistant", - text: "Working on it.", + id: "work-entry-1", + kind: "work", + createdAt: "2026-01-01T00:00:08Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:08Z", turnId: "turn-1" as never, - createdAt: "2026-01-01T00:00:10Z", - updatedAt: "2026-01-01T00:00:11Z", - streaming: false, + label: "Ran command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -944,16 +1074,15 @@ describe("deriveMessagesTimelineRows", () => { revertTurnCountByUserMessageId: new Map(), }); - const assistantRow = rows.find( - (row): row is Extract<(typeof rows)[number], { kind: "message" }> => - row.kind === "message" && row.message.role === "assistant", - ); - - expect(assistantRow?.showAssistantMeta).toBe(false); - expect(assistantRow?.showAssistantCopyButton).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); + expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "assistant-thought-entry", + "work-live:work-entry-1", + ]); }); - it("models work log overflow expansion as inserted list rows", () => { + it("keeps the current tool batch expandable while live entries append", () => { const timelineEntries = [ { id: "work-entry-1", @@ -962,8 +1091,9 @@ describe("deriveMessagesTimelineRows", () => { entry: { id: "work-1", createdAt: "2026-01-01T00:00:01Z", - label: "read", - detail: "Reading package.json", + turnId: "turn-1" as never, + toolCallId: "call-1", + label: "Read file", tone: "tool" as const, }, }, @@ -974,17 +1104,774 @@ describe("deriveMessagesTimelineRows", () => { entry: { id: "work-2", createdAt: "2026-01-01T00:00:02Z", - label: "edit", - detail: "Editing MessagesTimeline.tsx", + turnId: "turn-1" as never, + toolCallId: "call-2", + label: "Run command", + command: "vp test run", tone: "tool" as const, }, }, - { - id: "work-entry-3", - kind: "work" as const, - createdAt: "2026-01-01T00:00:03Z", - entry: { - id: "work-3", + ]; + const baseInput = { + timelineEntries, + latestTurn: { + turnId: "turn-1" as never, + state: "running" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + + const collapsedRows = deriveMessagesTimelineRows(baseInput); + const expandedRows = deriveMessagesTimelineRows({ + ...baseInput, + expandedWorkGroupIds: new Set(["work-group:tool:turn-1:call-1"]), + }); + + expect(collapsedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:turn-1:call-1", + ]); + expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:turn-1:call-1", + expanded: false, + groupedEntries: [{ id: "work-1" }, { id: "work-2" }], + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:turn-1:call-1", + "work-1", + "work-2", + ]); + expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:turn-1:call-1", + expanded: true, + }); + + const appendedRows = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "work-entry-3", + kind: "work" as const, + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "work-3", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + toolCallId: "call-3", + label: "Changed file", + tone: "tool" as const, + }, + }, + ], + expandedWorkGroupIds: new Set(["work-group:tool:turn-1:call-1"]), + }); + + expect(appendedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:turn-1:call-1", + "work-1", + "work-2", + "work-3", + ]); + expect(appendedRows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "work-3" }, + groupedEntries: [{ id: "work-1" }, { id: "work-2" }, { id: "work-3" }], + }); + + const rowsAfterFirstToolSettles = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + { + ...timelineEntries[0]!, + entry: { + ...timelineEntries[0]!.entry, + toolLifecycleStatus: "stopped" as const, + }, + }, + { + ...timelineEntries[1]!, + entry: { + ...timelineEntries[1]!.entry, + toolLifecycleStatus: "inProgress" as const, + }, + }, + ], + expandedWorkGroupIds: new Set(["work-group:tool:turn-1:call-1"]), + }); + + expect(rowsAfterFirstToolSettles.find((row) => row.kind === "work-live")).toMatchObject({ + id: "work-live:tool:turn-1:call-1", + groupId: "work-group:tool:turn-1:call-1", + expanded: true, + entry: { id: "work-2" }, + }); + + const rowsWithLaterPlan = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + timelineEntries[0]!, + { + id: "commentary-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:01.500Z", + message: { + id: "commentary-message" as never, + role: "assistant" as const, + text: "I found the relevant file.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:01.500Z", + updatedAt: "2026-01-01T00:00:01.500Z", + streaming: false, + }, + }, + timelineEntries[1]!, + { + id: "plan:thread-1:turn:turn-1", + kind: "proposed-plan" as const, + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan:thread-1:turn:turn-1", + turnId: "turn-1" as never, + planMarkdown: "# Next steps", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + ], + }); + expect(rowsWithLaterPlan.map((row) => row.id)).toEqual([ + "working-indicator-row", + "commentary-entry", + "work-live:tool:turn-1:call-1", + "plan:thread-1:turn:turn-1", + ]); + expect(rowsWithLaterPlan.some((row) => row.kind === "work-toggle")).toBe(false); + }); + + it("omits superseded id-less lifecycle markers from a live batch", () => { + const input = { + timelineEntries: [ + { + id: "legacy-update-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "legacy-update", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + label: "Glob", + tone: "tool" as const, + itemType: "mcp_tool_call" as const, + sourceActivityKind: "tool.updated" as const, + }, + }, + { + id: "legacy-complete-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "legacy-complete", + createdAt: "2026-01-01T00:00:02Z", + turnId: "turn-1" as never, + label: "Glob", + tone: "tool" as const, + itemType: "mcp_tool_call" as const, + sourceActivityKind: "tool.completed" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + + const collapsedRows = deriveMessagesTimelineRows(input); + const expandedRows = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(["work-group:legacy-update-entry"]), + }); + + expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "legacy-complete" }, + groupedEntries: [{ id: "legacy-complete" }], + }); + expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "legacy-complete" }, + groupedEntries: [{ id: "legacy-complete" }], + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:legacy-update-entry", + "legacy-complete", + ]); + }); + + it("keeps parallel id-less markers when one matching live call completes", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "legacy-update-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "legacy-update", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + label: "Glob", + tone: "tool" as const, + itemType: "mcp_tool_call", + sourceActivityKind: "tool.updated" as const, + }, + }, + { + id: "parallel-running-entry", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "parallel-running", + createdAt: "2026-01-01T00:00:02Z", + turnId: "turn-1" as never, + label: "Glob", + tone: "tool" as const, + itemType: "mcp_tool_call", + sourceActivityKind: "tool.updated" as const, + }, + }, + { + id: "legacy-complete-entry", + kind: "work", + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "legacy-complete", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + label: "Glob", + tone: "tool" as const, + itemType: "mcp_tool_call", + sourceActivityKind: "tool.completed" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "legacy-complete" }, + groupedEntries: [ + { id: "legacy-update" }, + { id: "parallel-running" }, + { id: "legacy-complete" }, + ], + }); + }); + + it("advances the live label when a newer parallel call completes", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "work-running-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-running", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-running", + label: "Search files", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }, + }, + { + id: "work-completed-entry", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-completed", + createdAt: "2026-01-01T00:00:02Z", + turnId: "turn-1" as never, + toolCallId: "call-completed", + label: "Read file", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "work-completed", toolLifecycleStatus: "completed" }, + groupedEntries: [{ id: "work-running" }, { id: "work-completed" }], + }); + }); + + it("keeps a completed trailing call in the live slot while the turn continues", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "work-completed-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-completed", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-completed", + label: "Ran tests", + command: "vp test run", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "work-completed", toolLifecycleStatus: "completed" }, + }); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + expect(rows.some((row) => row.kind === "work-toggle")).toBe(false); + }); + + it("keeps one live tool line across commentary and a subagent row", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "initial-tool-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "initial-tool", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-initial", + label: "Read file", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + { + id: "commentary-entry", + kind: "message", + createdAt: "2026-01-01T00:00:02Z", + message: { + id: "commentary" as never, + role: "assistant", + text: "I found the relevant path.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:02Z", + updatedAt: "2026-01-01T00:00:02Z", + streaming: false, + }, + }, + { + id: "agent-spawn-entry", + kind: "work", + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "agent-spawn", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + label: "Kicked off 2 subagents", + tone: "thinking" as const, + agentSpawn: { workflowId: null, agentTaskIds: ["task-1", "task-2"] }, + }, + }, + ...Array.from({ length: 19 }, (_, index) => ({ + id: `later-tool-entry-${index + 1}`, + kind: "work" as const, + createdAt: `2026-01-01T00:00:${String(index + 4).padStart(2, "0")}Z`, + entry: { + id: `later-tool-${index + 1}`, + createdAt: `2026-01-01T00:00:${String(index + 4).padStart(2, "0")}Z`, + turnId: "turn-1" as never, + toolCallId: `call-${index + 1}`, + label: `Tool ${index + 1}`, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + })), + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "commentary-entry", + "agent-spawn-entry", + "work-live:tool:turn-1:call-initial", + ]); + expect(rows.filter((row) => row.kind === "work-live")).toHaveLength(1); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "later-tool-19" }, + }); + expect(rows.some((row) => row.kind === "work-toggle")).toBe(false); + }); + + it("keeps thinking visible after an informational work entry", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "context-compacted-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "context-compacted", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + label: "Context compacted", + tone: "info" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); + }); + + it("keeps task progress in the single live activity line", () => { + const input = { + timelineEntries: [ + { + id: "task-progress-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "task-progress", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + taskId: "task-1", + label: "Reviewing changes", + tone: "thinking" as const, + sourceActivityKind: "task.progress" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + + expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:task-progress-entry", + ]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "task-progress" }, + }); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + + const expandedRows = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(["work-group:task-progress-entry"]), + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:task-progress-entry", + "task-progress", + ]); + }); + + it("keeps the current tool batch live before an empty assistant placeholder", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-1", + label: "Run command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }, + }, + { + id: "assistant-placeholder-entry", + kind: "message", + createdAt: "2026-01-01T00:00:02Z", + message: { + id: "assistant-placeholder" as never, + role: "assistant", + text: " ", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:02Z", + updatedAt: "2026-01-01T00:00:02Z", + streaming: true, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toContain("work-live:tool:turn-1:call-1"); + }); + + it("does not fold the session's running turn when latestTurn regresses", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "previous-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "previous-work", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: "Read files", + tone: "tool" as const, + }, + }, + { + id: "user-followup-entry", + kind: "message", + createdAt: "2026-01-01T00:01:00Z", + message: { + id: "user-followup" as never, + role: "user", + text: "continue", + turnId: null, + createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", + streaming: false, + }, + }, + { + id: "running-work-entry", + kind: "work", + createdAt: "2026-01-01T00:01:05Z", + entry: { + id: "running-work", + createdAt: "2026-01-01T00:01:05Z", + turnId: "turn-2" as never, + label: "Searched files", + tone: "tool" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:25Z", + }, + runningTurnId: "turn-2" as never, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:01:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ + "turn-1", + ]); + expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + }); + + it("only shows assistant metadata on the terminal assistant message", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "assistant-thought-entry", + kind: "message", + createdAt: "2026-01-01T00:00:10Z", + message: { + id: "assistant-thought" as never, + role: "assistant", + text: "Checking first.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:11Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Done.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:20Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + }, + ], + expandedTurnIds: new Set(["turn-1" as never]), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const assistantRows = rows.filter( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "assistant", + ); + + expect(assistantRows.map((row) => row.showAssistantMeta)).toEqual([false, true]); + }); + + it("withholds assistant metadata while the active turn is still in progress", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "assistant-thought-entry", + kind: "message", + createdAt: "2026-01-01T00:00:10Z", + message: { + id: "assistant-thought" as never, + role: "assistant", + text: "Working on it.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:11Z", + streaming: false, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const assistantRow = rows.find( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "assistant", + ); + + expect(assistantRow?.showAssistantMeta).toBe(false); + expect(assistantRow?.showAssistantCopyButton).toBe(false); + }); + + it("models work log overflow expansion as inserted list rows", () => { + const timelineEntries = [ + { + id: "work-entry-1", + kind: "work" as const, + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + label: "read", + detail: "Reading package.json", + tone: "tool" as const, + }, + }, + { + id: "work-entry-2", + kind: "work" as const, + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + label: "edit", + detail: "Editing MessagesTimeline.tsx", + tone: "tool" as const, + }, + }, + { + id: "work-entry-3", + kind: "work" as const, + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "work-3", createdAt: "2026-01-01T00:00:03Z", label: "test", detail: "Running tests", @@ -1006,23 +1893,325 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 2, + hiddenCount: 3, expanded: false, onlyToolEntries: true, }); expect(expandedRows.map((row) => row.id)).toEqual([ + "work-toggle:work-entry-1", "work-1", "work-2", "work-3", - "work-toggle:work-entry-1", ]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, }); }); + + it("filters only matching settled lifecycle markers", () => { + const input = { + timelineEntries: [ + { + id: "unrelated-update-entry", + kind: "work", + createdAt: "2026-01-01T00:00:00Z", + entry: { + id: "unrelated-update", + createdAt: "2026-01-01T00:00:00Z", + label: "Read file", + tone: "tool", + itemType: "dynamic_tool_call", + sourceActivityKind: "tool.updated", + }, + }, + { + id: "legacy-update-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "legacy-update", + createdAt: "2026-01-01T00:00:01Z", + label: "Glob", + tone: "tool", + itemType: "mcp_tool_call", + sourceActivityKind: "tool.updated", + }, + }, + { + id: "legacy-complete-entry", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "legacy-complete", + createdAt: "2026-01-01T00:00:02Z", + label: "Glob", + tone: "tool", + itemType: "mcp_tool_call", + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "completed", + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + } satisfies Parameters[0]; + + const collapsedRows = deriveMessagesTimelineRows(input); + const expandedRows = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(["work-group:unrelated-update-entry"]), + }); + + expect(collapsedRows).toHaveLength(1); + expect(collapsedRows[0]).toMatchObject({ + kind: "work-toggle", + hiddenCount: 2, + hasFailure: false, + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "work-toggle:unrelated-update-entry", + "unrelated-update", + "legacy-complete", + ]); + }); + + it("removes superseded lifecycle markers from mixed groups", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "legacy-update-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "legacy-update", + createdAt: "2026-01-01T00:00:01Z", + label: "Glob", + tone: "tool", + itemType: "mcp_tool_call", + sourceActivityKind: "tool.updated", + }, + }, + { + id: "legacy-complete-entry", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "legacy-complete", + createdAt: "2026-01-01T00:00:02Z", + label: "Glob", + tone: "tool", + itemType: "mcp_tool_call", + sourceActivityKind: "tool.completed", + toolLifecycleStatus: "completed", + }, + }, + { + id: "spawn-entry", + kind: "work", + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "spawn", + createdAt: "2026-01-01T00:00:03Z", + label: "Kicked off an agent", + tone: "thinking", + agentSpawn: { workflowId: null, agentTaskIds: ["task-1"] }, + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + } satisfies Parameters[0]); + + expect( + rows.flatMap((row) => + row.kind === "work" ? row.groupedEntries.map((entry) => entry.id) : [], + ), + ).toEqual(["legacy-complete", "spawn"]); + }); + + it("labels mixed-group overflow from the entries actually hidden", () => { + const input = { + timelineEntries: [ + { + id: "tool-entry-1", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "tool-1", + createdAt: "2026-01-01T00:00:01Z", + label: "Read file", + tone: "tool", + }, + }, + { + id: "spawn-entry", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "spawn", + createdAt: "2026-01-01T00:00:02Z", + label: "Kicked off an agent", + tone: "thinking", + agentSpawn: { workflowId: null, agentTaskIds: ["task-1"] }, + }, + }, + { + id: "tool-entry-2", + kind: "work", + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "tool-2", + createdAt: "2026-01-01T00:00:03Z", + label: "Run tests", + tone: "tool", + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + const expandedRows = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(["work-group:tool-entry-1"]), + }); + + expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ + hiddenCount: 1, + onlyToolEntries: true, + }); + expect(expandedRows.at(-1)).toMatchObject({ + kind: "work-toggle", + expanded: true, + onlyToolEntries: true, + summary: null, + }); + }); + + it("keeps error entries visible instead of summarizing them as tools", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "runtime-error-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "runtime-error", + createdAt: "2026-01-01T00:00:01Z", + label: "Provider disconnected", + tone: "error", + sourceActivityKind: "runtime.error", + }, + }, + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: "work", id: "runtime-error-entry" }); + }); + + it("keeps an active error outside the live tool batch", () => { + const turnId = "turn-active-error" as never; + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "runtime-error-entry", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "runtime-error", + createdAt: "2026-01-01T00:00:01Z", + turnId, + toolCallId: "failed-call", + itemType: "command_execution", + label: "Provider disconnected", + tone: "error", + sourceActivityKind: "runtime.error", + toolLifecycleStatus: "failed", + }, + }, + { + id: "active-tool-entry", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "active-tool", + createdAt: "2026-01-01T00:00:02Z", + turnId, + toolCallId: "active-call", + itemType: "command_execution", + label: "Run tests", + tone: "tool", + toolLifecycleStatus: "inProgress", + }, + }, + ], + latestTurn: { + turnId, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "runtime-error-entry", + "work-live:tool:turn-active-error:active-call", + ]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + groupedEntries: [{ id: "active-tool" }], + }); + }); + + it("attributes overflow failure state only to hidden entries", () => { + const deriveRowsForTones = (tones: ReadonlyArray<"error" | "info">) => + deriveMessagesTimelineRows({ + timelineEntries: tones.map((tone, index) => ({ + id: `work-entry-${index}`, + kind: "work" as const, + createdAt: `2026-01-01T00:00:0${index + 1}Z`, + entry: { + id: `work-${index}`, + createdAt: `2026-01-01T00:00:0${index + 1}Z`, + label: tone === "error" ? "Provider disconnected" : `Log ${index}`, + tone, + ...(tone === "error" ? { sourceActivityKind: "runtime.error" as const } : {}), + }, + })), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const hiddenFailureToggle = deriveRowsForTones(["error", "info", "info", "info"]).find( + (row) => row.kind === "work-toggle", + ); + const visibleFailureToggle = deriveRowsForTones(["info", "info", "info", "error"]).find( + (row) => row.kind === "work-toggle", + ); + + expect(hiddenFailureToggle).toMatchObject({ hasFailure: true, hiddenCount: 3 }); + expect(visibleFailureToggle).toMatchObject({ hasFailure: false, hiddenCount: 3 }); + }); }); describe("computeStableMessagesTimelineRows", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c89bbd0557d9..eb49ab823b09 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,7 @@ import * as Equal from "effect/Equal"; import { formatDuration, + workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -17,6 +18,18 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; export const TIMELINE_CONTENT_MAX_WIDTH = 768; export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +export function workEntryIsVisibleInGroup( + entry: WorkLogEntry, + expandedToolGroupEntry = false, +): boolean { + return ( + (expandedToolGroupEntry && + (entry.toolLifecycleStatus === "inProgress" || + entry.sourceActivityKind === "task.progress")) || + !workEntryIndicatesToolNeutralStatus(entry) + ); +} + export interface TimelineEndState { readonly isAtEnd?: boolean; readonly contentLength?: number; @@ -170,6 +183,17 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; + isExpandedToolGroupEntry: boolean; + isLastExpandedToolGroupEntry: boolean; + } + | { + kind: "work-live"; + id: string; + createdAt: string; + entry: WorkLogEntry; + groupedEntries: WorkLogEntry[]; + groupId: string; + expanded: boolean; } | { kind: "work-toggle"; @@ -179,6 +203,9 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; + summary: string | null; + summaryKind: ToolGroupSummaryKind | null; + hasFailure: boolean; } | { kind: "turn-fold"; @@ -212,7 +239,12 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { kind: "working"; id: string; createdAt: string | null }; + | { + kind: "working"; + id: string; + createdAt: string | null; + showThinking: boolean; + }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -242,6 +274,189 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other"; +type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed"; + +export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean { + return ( + entry.itemType === "web_search" && + /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) + ); +} + +export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { + if ( + entry.requestKind === "file-read" || + entry.itemType === "image_view" || + (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") + ) { + return "read"; + } + if ( + entry.requestKind === "file-change" || + entry.itemType === "file_change" || + (entry.changedFiles?.length ?? 0) > 0 + ) { + return "edit"; + } + if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { + return "command"; + } + if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; + if (entry.itemType === "web_search") return "search"; + return "other"; +} + +function toolGroupActionCount( + action: ToolGroupAction, + entries: ReadonlyArray, +): number { + if (action !== "edit") return entries.length; + + const changedFiles = new Set(); + let editsWithoutFileDetails = 0; + for (const entry of entries) { + if (!entry.changedFiles || entry.changedFiles.length === 0) { + editsWithoutFileDetails += 1; + continue; + } + for (const file of entry.changedFiles) changedFiles.add(file); + } + return changedFiles.size + editsWithoutFileDetails; +} + +function toolGroupActionLabel(action: ToolGroupAction, count: number): string { + switch (action) { + case "read": + return `Read ${count} ${count === 1 ? "file" : "files"}`; + case "edit": + return `Changed ${count} ${count === 1 ? "file" : "files"}`; + case "command": + return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "search": + return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; + case "code-search": + return `Searched code ${count} ${count === 1 ? "time" : "times"}`; + case "other": + return `Used ${count} ${count === 1 ? "tool" : "tools"}`; + } +} + +/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ +export function summarizeToolGroup(entries: ReadonlyArray): string { + const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); + const groupedEntries = new Map(); + for (const entry of summaryEntries) { + const action = toolGroupAction(entry); + const group = groupedEntries.get(action); + if (group) group.push(entry); + else groupedEntries.set(action, [entry]); + } + const labels = [...groupedEntries].map(([action, actionEntries]) => + toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), + ); + const sentenceLabels = labels.map((label, index) => + index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), + ); + if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; + if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); + return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; +} + +function omitSupersededLifecycleMarkers( + entries: readonly T[], + workEntryFor: (entry: T) => WorkLogEntry, +): T[] { + const statuslessIdlessMarkerCounts = new Map(); + for (const entry of entries) { + const workEntry = workEntryFor(entry); + if ( + workEntry.toolCallId === undefined && + workEntry.toolLifecycleStatus === undefined && + (workEntry.sourceActivityKind === "tool.started" || + workEntry.sourceActivityKind === "tool.updated") + ) { + const identity = [ + workEntry.turnId ?? "no-turn", + workEntry.itemType ?? "", + normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label), + ].join("\u001f"); + statuslessIdlessMarkerCounts.set( + identity, + (statuslessIdlessMarkerCounts.get(identity) ?? 0) + 1, + ); + } + } + + const laterTerminalIdentities = new Set(); + const reversedEntries: T[] = []; + + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + const workEntry = workEntryFor(entry); + const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label); + const identity = [ + workEntry.turnId ?? "no-turn", + workEntry.itemType ?? "", + normalizedLabel, + ].join("\u001f"); + const isStatuslessIdlessMarker = + workEntry.toolCallId === undefined && + workEntry.toolLifecycleStatus === undefined && + (workEntry.sourceActivityKind === "tool.started" || + workEntry.sourceActivityKind === "tool.updated"); + if ( + isStatuslessIdlessMarker && + statuslessIdlessMarkerCounts.get(identity) === 1 && + laterTerminalIdentities.has(identity) + ) { + continue; + } + + reversedEntries.push(entry); + if ( + workEntry.toolCallId === undefined && + (workEntry.sourceActivityKind === "tool.completed" || + (workEntry.toolLifecycleStatus !== undefined && + workEntry.toolLifecycleStatus !== "inProgress")) + ) { + laterTerminalIdentities.add(identity); + } + } + + return reversedEntries.toReversed(); +} + +function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupSummaryKind { + const actions = new Set(entries.map(toolGroupAction)); + if (actions.size !== 1) return "mixed"; + + const action = actions.values().next().value!; + if (action !== "other") return action; + + const fallbackKinds = new Set( + entries.map((entry): ToolGroupSummaryKind => { + if (entry.itemType === "mcp_tool_call") return "other"; + if (entry.itemType === "dynamic_tool_call") return "dynamic-tool"; + if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool"; + if (entry.tone === "thinking") return "agent-tool"; + if (entry.tone === "tool") return "tone-tool"; + return "other"; + }), + ); + return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; +} + +function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { + return entry.toolCallId + ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` + : timelineEntryId; +} + +function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { + return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -314,17 +529,39 @@ function deriveUnsettledTurnId( return isSettled ? null : latestTurn.turnId; } +function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { + return timelineEntries.findLastIndex( + (entry) => entry.kind === "message" && entry.message.role === "user", + ); +} + +function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") { + return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; + } + if (entry.kind === "turn-plan") { + return entry.turnPlan.turnId; + } + if (entry.kind === "proposed-plan") { + return entry.proposedPlan.turnId; + } + return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; +} + /** * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row anchored at the turn's first foldable entry; the - * terminal assistant message stays visible below the fold. + * "Worked for ..." row. While collapsed, the row stays immediately before the + * next terminal assistant response so a steer cannot strand it above the + * visible response. While expanded, it moves before the first hidden entry so + * the revealed activity follows the disclosure in chronological order. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap { + expandedTurnIds: ReadonlySet | undefined; +}): ReadonlyMap> { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -379,7 +616,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -405,8 +642,31 @@ function deriveTurnFolds(input: { } const firstEntry = group.entries[0]; + const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id)); const lastEntry = group.entries.at(-1); - if (!firstEntry || !lastEntry) { + if (!firstEntry || !firstHiddenEntry || !lastEntry) { + continue; + } + const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => + hiddenEntryIds.has(entry.id), + ); + if (lastHiddenEntryIndex < 0) { + continue; + } + const nextTerminalAssistantEntry = input.timelineEntries + .slice(lastHiddenEntryIndex + 1) + .find( + (entry) => + entry.kind === "message" && + entry.message.role === "assistant" && + input.terminalAssistantMessageIds.has(entry.message.id), + ); + const collapsedAnchorEntry = + nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; + const anchorEntry = input.expandedTurnIds?.has(turnId) + ? firstHiddenEntry + : collapsedAnchorEntry; + if (!anchorEntry) { continue; } @@ -435,13 +695,16 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - foldsByAnchorEntryId.set(firstEntry.id, { + const fold = { turnId, - anchorEntryId: firstEntry.id, - createdAt: firstEntry.createdAt, + anchorEntryId: anchorEntry.id, + createdAt: anchorEntry.createdAt, hiddenEntryIds, label, - }); + }; + const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); + if (anchoredFolds) anchoredFolds.push(fold); + else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); } return foldsByAnchorEntryId; } @@ -471,38 +734,147 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unsettledTurnId, + expandedTurnIds: input.expandedTurnIds, }); const collapsedEntryIds = new Set(); - for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedTurnIds?.has(fold.turnId)) { - for (const entryId of fold.hiddenEntryIds) { - collapsedEntryIds.add(entryId); + for (const folds of foldsByAnchorEntryId.values()) { + for (const fold of folds) { + if (!input.expandedTurnIds?.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } } } } + let activeTurnHeaderIndex = input.timelineEntries.length; + if (input.isWorking) { + const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); + const firstOwnedAfterUser = + unsettledTurnId === null + ? -1 + : input.timelineEntries.findIndex( + (entry, index) => + index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, + ); + activeTurnHeaderIndex = + firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; + } + const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => + input.isWorking && + index >= activeTurnHeaderIndex && + (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); + const isVisibleActiveToolEntry = (entry: WorkLogEntry) => + workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true); + const activeEntries = input.isWorking + ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) + : []; + const activeTurnHasVisibleContent = activeEntries.some((entry) => { + if (entry.kind === "message") { + return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; + } + if (entry.kind === "work") { + return ( + entry.entry.agentSpawn === undefined && + workLogEntryIsToolLike(entry.entry) && + entry.entry.toolLifecycleStatus === "inProgress" + ); + } + if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true; + return false; + }); + + const activeToolEntries = activeEntries.flatMap((entry) => + entry.kind === "work" && + entry.entry.agentSpawn === undefined && + entry.entry.tone !== "error" && + workLogEntryIsToolLike(entry.entry) + ? [entry] + : [], + ); + const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id)); + const visibleActiveToolEntries = omitSupersededLifecycleMarkers( + activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)), + (entry) => entry.entry, + ); + const activeWorkAnchor = activeToolEntries[0]; + const latestActiveToolEntry = visibleActiveToolEntries.at(-1); + const activeWorkPlacementEntryId = latestActiveToolEntry?.id; + const activeWorkRow = + activeWorkAnchor && latestActiveToolEntry + ? (() => { + const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); + return { + kind: "work-live" as const, + id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, + createdAt: activeWorkAnchor.createdAt, + entry: latestActiveToolEntry.entry, + groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), + groupId, + expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + }; + })() + : null; + const appendWorkingRow = () => { + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + showThinking: activeWorkRow === null && !activeTurnHasVisibleContent, + }); + }; + const appendActiveWorkRows = () => { + if (activeWorkRow === null) return; + nextRows.push(activeWorkRow); + if (!activeWorkRow.expanded) return; + for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, + }); + } + }; + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } - const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); - if (turnFold) { - nextRows.push({ - kind: "turn-fold", - id: `turn-fold:${turnFold.turnId}`, - createdAt: turnFold.createdAt, - turnId: turnFold.turnId, - label: turnFold.label, - expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, - }); + if (input.isWorking && index === activeTurnHeaderIndex) { + appendWorkingRow(); + } + + if (timelineEntry.id === activeWorkPlacementEntryId) { + appendActiveWorkRows(); + } + + const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); + if (anchoredTurnFolds) { + for (const turnFold of anchoredTurnFolds) { + nextRows.push({ + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + }); + } } if (collapsedEntryIds.has(timelineEntry.id)) { continue; } + if (activeWorkEntryIds.has(timelineEntry.id)) { + continue; + } + if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -511,6 +883,7 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || + activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -519,19 +892,58 @@ export function deriveMessagesTimelineRows(input: { groupedEntries.push(nextEntry.entry); cursor += 1; } - const visibleGroupedEntries = groupedEntries.filter( - (entry) => !workEntryIndicatesToolNeutralStatus(entry), + const visibleGroupedEntries = omitSupersededLifecycleMarkers( + groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry)), + (entry) => entry, ); if (visibleGroupedEntries.length > 0) { - if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + const onlyToolEntries = visibleGroupedEntries.every( + (entry) => + workLogEntryIsToolLike(entry) && + entry.agentSpawn === undefined && + entry.tone !== "error", + ); + if (onlyToolEntries) { + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); + nextRows.push({ + kind: "work-toggle", + id: `work-toggle:${timelineEntry.id}`, + createdAt: timelineEntry.createdAt, + groupId, + hiddenCount: visibleGroupedEntries.length, + expanded, + onlyToolEntries: true, + summary: summarizeToolGroup(visibleGroupedEntries), + summaryKind, + hasFailure: visibleGroupedEntries.some((entry) => + workEntryDisplayIndicatesToolFailure(entry), + ), + }); + if (expanded) { + for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, + }); + } + } + } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } else { - const groupId = `work-group:${timelineEntry.id}`; + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; // Agent-spawn CTA rows are always visible: a running fleet must // never hide behind a "+N tool calls" toggle. Selection is by @@ -555,6 +967,8 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } @@ -566,8 +980,11 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries: visibleGroupedEntries.every((entry) => - workLogEntryIsToolLike(entry), + onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike), + summary: null, + summaryKind: null, + hasFailure: hiddenEntries.some((entry) => + workEntryDisplayIndicatesToolFailure(entry), ), }); } @@ -633,12 +1050,8 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking) { - nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: input.activeTurnStartedAt, - }); + if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + appendWorkingRow(); } return nextRows; @@ -670,7 +1083,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return ( + a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking + ); case "turn-fold": { const bf = b as typeof a; @@ -687,8 +1102,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - case "work": - return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); + case "work": { + const bw = b as typeof a; + return ( + a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && + a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } + + case "work-live": { + const bw = b as typeof a; + return ( + a.createdAt === bw.createdAt && + a.groupId === bw.groupId && + a.expanded === bw.expanded && + Equal.equals(a.entry, bw.entry) && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } case "work-toggle": { const bw = b as typeof a; @@ -697,7 +1129,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries + a.onlyToolEntries === bw.onlyToolEntries && + a.summary === bw.summary && + a.summaryKind === bw.summaryKind && + a.hasFailure === bw.hasFailure ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 617ee0b80d1c..30e2baf9ceaa 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,14 @@ -import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; +import { + CheckpointRef, + EnvironmentId, + MessageId, + TurnId, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { + deriveAgentPanelModel, + foldSubagentActivities, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; @@ -176,7 +186,6 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; function buildProps() { return { isWorking: false, - activeTurnInProgress: false, activeTurnStartedAt: null, listRef: createRef(), latestTurn: null, @@ -237,6 +246,110 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("keeps subagent status visible while sizing details from its container", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("@container/agent-group"); + expect(markup).toContain("@[24rem]/agent-group:inline"); + expect(markup).toContain("ml-auto flex shrink-0"); + expect(markup).toContain(">Completed"); + expect(markup).not.toContain("sr-only @[32rem]/agent-group:hidden"); + }); + + it.each([ + ["failed", "Failed"], + ["cancelled", "Cancelled"], + ["interrupted", "Interrupted"], + ] as const)("shows a %s workflow as an error", (status, label) => { + const activities = [ + { + id: "workflow-start", + tone: "info", + kind: "task.started", + summary: "Started workflow", + payload: { + taskId: "workflow-1", + taskType: "local_workflow", + agentKind: "agent", + }, + turnId: null, + createdAt: MESSAGE_CREATED_AT, + }, + { + id: "workflow-terminal", + tone: "info", + kind: "task.updated", + summary: `${label} workflow`, + payload: { taskId: "workflow-1", status }, + turnId: null, + createdAt: MESSAGE_CREATED_AT, + }, + ] as unknown as ReadonlyArray; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("bg-destructive"); + expect(markup).toContain(`>${label}`); + expect(markup).not.toContain(">Completed"); + }); + + it("keeps the compact working and thinking rows aligned", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thinking"); + expect(markup).not.toContain("Thinking ·"); + expect(markup).not.toContain('aria-hidden="true" class="size-6 shrink-0"'); + expect(markup).toContain("gap-1.5 px-0.5 py-0.5"); + expect(markup).toContain( + 'class="pb-1.5" data-timeline-row-id="working-indicator-row" data-timeline-row-kind="working"', + ); + expect(markup).toContain("border-b border-border/60 pb-2 pt-1"); + expect(markup).toContain('class="mt-1"'); + expect(markup).not.toContain('class="mt-2"'); + expect(markup).toContain("text-muted-foreground tabular-nums"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; @@ -700,7 +813,313 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("formats changed file paths from the workspace root", () => { + it("shows a disclosure chevron on expandable settled work rows", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("lucide-chevron-down"); + expect(markup).toContain("size-3 shrink-0 text-icon-muted"); + }); + + it("keeps a completed live tool row expandable with its running label", () => { + const turnId = TurnId.make("turn-live-tools"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('aria-label="Expand current tool calls"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("Running psql"); + expect(markup).not.toContain("lucide-chevron-right"); + expect(markup).not.toContain("lucide-chevron-down"); + expect(markup).not.toContain("hover:bg-accent/20"); + expect(markup).not.toContain("Tool call failed"); + }); + + it.each<[string, string]>([ + ['"C:\\Program Files\\nodejs\\node.exe" script.js', "Running node.exe"], + ["C:\\Python311\\python.exe script.py", "Running python.exe"], + ["FOO=$(printf '%s' value) npm test", "Running npm"], + ["/tmp/my\\ tool --version", "Running my tool"], + ["FOO=$(printf value npm test", "Running command"], + ["env -S 'python -O'", "Running python"], + ["env --split-string='python -O'", "Running python"], + ["env -v npm test", "Running npm"], + ["sudo FOO=bar npm test", "Running npm"], + ["sudo --user=root npm test", "Running npm"], + ["sudo --bogus=value npm test", "Running command"], + [ + Array.from({ length: 12 }).reduce( + (wrapped) => `env --split-string=${JSON.stringify(wrapped)}`, + "python -O", + ), + "Running command", + ], + ])("labels wrapped live command %s as %s", (command, expectedLabel) => { + const turnId = TurnId.make("turn-live-command-label"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(expectedLabel); + }); + + it("uses the read icon for a live dynamic Read File call", () => { + const turnId = TurnId.make("turn-live-read-file"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("lucide-eye"); + expect(markup).not.toContain("lucide-hammer"); + }); + + it("marks a failed tool that remains in the live row", () => { + const turnId = TurnId.make("turn-failed-live-tool"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Glob, tool call failed"'); + expect(markup).toContain('aria-label="Tool call failed"'); + expect(markup).toContain("lucide-x"); + expect(markup).toContain("text-destructive"); + }); + + it("keeps an earlier live-batch failure visible", () => { + const turnId = TurnId.make("turn-live-batch-failure"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Running vp, tool call failed"'); + }); + + it("does not infer failure from a running command invocation", () => { + const turnId = TurnId.make("turn-running-error-phrase"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Running rg"); + expect(markup).not.toContain("Tool call failed"); + }); + + it("summarizes completed changed-file activity", () => { const markup = renderToStaticMarkup( { createdAt: "2026-03-17T19:12:28.000Z", label: "Updated files", tone: "tool", + itemType: "command_execution", + command: "apply_patch", changedFiles: ["C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"], }, }, @@ -722,10 +1143,81 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).toContain("Changed 1 file"); + expect(markup).toContain("lucide-square-pen"); + expect(markup).not.toContain("lucide-terminal"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("keeps a dynamic tool's hammer icon when it becomes a summary", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Used 1 tool"); + expect(markup).toContain("lucide-hammer"); + expect(markup).not.toContain("lucide-wrench"); + }); + + it("keeps a tone-only tool's icon when it becomes a summary", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Used 1 tool"); + expect(markup).toContain("lucide-zap"); + expect(markup).not.toContain("lucide-wrench"); + }); + + it("aligns the iconless Thinking row with the working timer", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Working for"); + expect(markup).toContain("Thinking"); + expect(markup).toContain("gap-1.5 py-0.5 px-1"); + }); + it("renders review comment contexts as structured cards instead of raw tags", () => { const markup = renderToStaticMarkup( { ); expect(markup).toContain("lucide-x"); + expect(markup).toContain('aria-label="Used 1 tool, tool call failed"'); + expect(markup).toContain('role="img"'); expect(markup).toContain('aria-label="Tool call failed"'); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c90aa771f8d1..639f34c4e524 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -33,9 +33,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, - workEntryIndicatesToolFailure, - workEntryIndicatesToolNeutralStatus, - workEntryIndicatesToolSuccess, + workEntryDisplayIndicatesToolFailure, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -57,7 +55,7 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, - MinusIcon, + SearchIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -84,6 +82,8 @@ import { resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, shouldPreserveAssistantLineBreaks, + toolGroupAction, + workEntryIsVisibleInGroup, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -150,10 +150,7 @@ interface TimelineRowSharedState { interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; - activeTurnInProgress: boolean; latestTurnId: TurnId | null; - /** Current plan step label for the working row, when the turn has a plan. */ - workingStepLabel: string | null; } const TimelineRowCtx = createContext(null!); @@ -206,8 +203,6 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; - workingStepLabel?: string | null; - activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; @@ -250,8 +245,6 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, - workingStepLabel = null, - activeTurnInProgress, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, @@ -540,11 +533,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ isWorking, isRevertingCheckpoint, - activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, - workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], + [isRevertingCheckpoint, isWorking, latestTurn?.turnId], ); // Stable renderItem — no closure deps. Row components read shared state @@ -921,17 +912,34 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; + const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; + const isExpandedToolGroupHeader = + (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) || + (row.kind === "work-live" && row.expanded); + return (
- {row.kind === "work" ? : null} + {row.kind === "work" ? ( + + ) : null} + {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1093,7 +1107,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} @@ -1280,16 +1294,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { - const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
- - - - - - +
+
+
{row.createdAt ? ( <> Working for @@ -1297,11 +1305,13 @@ function WorkingTimelineRow({ row }: { row: Extract - {workingStepLabel ? ( - · {workingStepLabel} - ) : null} +
+ {row.showThinking ? ( +
+ +
+ ) : null}
); } @@ -1342,13 +1352,16 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, + isExpandedToolGroupEntry, }: { groupedEntries: Extract["groupedEntries"]; + isExpandedToolGroupEntry: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( - () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), - [groupedEntries], + () => + groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)), + [groupedEntries, isExpandedToolGroupEntry], ); const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry)); const groupLabel = onlyToolEntries @@ -1356,11 +1369,15 @@ const WorkGroupSection = memo(function WorkGroupSection({ ? "1 tool call" : `${nonEmptyEntries.length} tool calls` : "Work Log"; + const GroupContainer = isExpandedToolGroupEntry ? "div" : "section"; if (nonEmptyEntries.length === 0) return null; return ( -
+ {!onlyToolEntries && (

{groupLabel}

)} @@ -1370,19 +1387,170 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} + isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
- + ); }); +function LiveActivityRow({ + label, + iconName, + failed = false, +}: { + label: string; + iconName?: WorkEntryIconName; + failed?: boolean; +}) { + return ( +
+ +
+
+
+ +
+
+
+
+ ); +} + +function ThinkingActivityRow() { + return ; +} + +function LiveActivityContent({ + label, + iconName, + failed = false, + announceFailure = false, + highlighted = false, +}: { + label: string; + iconName: WorkEntryIconName | undefined; + failed?: boolean; + announceFailure?: boolean; + highlighted?: boolean; +}) { + const resolvedIconName = failed ? "x" : iconName; + + return ( +
+ {resolvedIconName ? ( + + + + ) : null} + {label} +
+ ); +} + +function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); + const failed = row.groupedEntries.some((entry) => workEntryDisplayIndicatesToolFailure(entry)); + + return ( + + ); +} + +function toolGroupSummaryIconName( + kind: Extract["summaryKind"], +): WorkEntryIconName { + switch (kind) { + case "read": + return "eye"; + case "edit": + return "square-pen"; + case "command": + return "terminal"; + case "search": + return "globe"; + case "code-search": + return "search"; + case "other": + return "wrench"; + case "dynamic-tool": + return "hammer"; + case "agent-tool": + return "bot"; + case "tone-tool": + return "zap"; + case "mixed": + case null: + return "hammer"; + } +} + function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); + if (row.onlyToolEntries && row.summary) { + return ( + + ); + } const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" @@ -1390,21 +1558,33 @@ function WorkGroupToggleTimelineRow({ : row.hiddenCount === 1 ? "log entry" : "log entries"; + const showHiddenFailure = row.hasFailure && !row.expanded; return ( +
+
+
+ + + {lead} + {workflowName ? ( + + {workflowName} + + ) : null} + + {status} + {totalTokens > 0 ? ( + + Σ {formatSubagentTokenCount(totalTokens)} + + ) : null} + +
+ + + } + > + + + {live ? "Open agents" : "View agents"} + + + {live ? "Open agents" : "View agents"} + +
+
); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } - return ; + return ( + + ); }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; - const activity = use(TimelineRowActivityCtx); + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); - const preview = - rawPreview && - normalizeCompactToolLabel(rawPreview).toLowerCase() === - normalizeCompactToolLabel(heading).toLowerCase() - ? null - : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; + const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); + const entryIconName = + showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); + const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; - const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( - "flex size-5 shrink-0 items-center justify-center", - showWarningIndicator + "flex size-6 shrink-0 items-center justify-center", + showWarningIndicator || showFailedIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2265,17 +2651,19 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground"; - const turnSettled = !activity.activeTurnInProgress; - const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); - const showSuccessIndicator = - workEntryIndicatesToolSuccess(workEntry) || - (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); + : workLogEntryIsToolLike(workEntry) + ? "text-secondary-label" + : "text-foreground/80"; + const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; + const accessibleDisplayText = showFailedIndicator + ? `${displayText}, tool call failed` + : displayText; const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, - "aria-label": displayText, + "aria-label": accessibleDisplayText, + "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -2289,85 +2677,45 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
- +
-

- {heading} - {preview && ( - {preview} - )} +

+ {displayText}

-
- - {canExpand ? ( - - ) : null} - - - {showFailedIndicator ? ( - - - } - > - - - Failed - - ) : showSuccessIndicator ? ( - - } - > - - - - - Completed - - ) : showNeutralIndicator ? ( - - } - > - - - Empty - - ) : null} - -
+ + +
{expanded && canExpand && expandedBody ? ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 07b6313a569e..71f9443f7f62 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -431,6 +431,80 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@keyframes live-activity-focus { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } +} + +@keyframes live-activity-focus-counter { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } +} + +@utility live-activity-focus { + --live-activity-focus-width: 4.5rem; + + right: auto; + left: calc(-1 * var(--live-activity-focus-width)); + width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + mask-repeat: no-repeat; + animation: live-activity-focus 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 0; + will-change: auto; + } +} + +@utility live-activity-focus-counter { + width: 100%; + animation: live-activity-focus-counter 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + will-change: auto; + } +} + +@utility live-activity-focus-aligned { + width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); + margin-left: var(--live-activity-focus-width); +} + @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 7671f7fdbb2e..b8f7c4c563b7 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -10,7 +10,6 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveActiveWorkStartedAt, - deriveActivePlanState, deriveTurnPlans, derivePendingApprovals, derivePendingUserInputs, @@ -19,6 +18,7 @@ import { findLatestProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, + workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, @@ -348,122 +348,6 @@ describe("derivePendingUserInputs", () => { }); }); -describe("deriveActivePlanState", () => { - it("returns the latest plan update for the active turn", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "plan-old", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { - explanation: "Initial plan", - plan: [{ step: "Inspect code", status: "pending" }], - }, - }), - makeActivity({ - id: "plan-latest", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { - explanation: "Refined plan", - plan: [{ step: "Implement Codex user input", status: "inProgress" }], - }, - }), - ]; - - expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toEqual({ - createdAt: "2026-02-23T00:00:02.000Z", - turnId: "turn-1", - explanation: "Refined plan", - steps: [{ step: "Implement Codex user input", status: "inProgress" }], - }); - }); - - it("falls back to the most recent plan from a previous turn", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "plan-from-turn-1", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { - plan: [{ step: "Write tests", status: "completed" }], - }, - }), - ]; - - // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan - const result = deriveActivePlanState(activities, TurnId.make("turn-2")); - expect(result).toEqual({ - createdAt: "2026-02-23T00:00:01.000Z", - turnId: "turn-1", - steps: [{ step: "Write tests", status: "completed" }], - }); - }); - - it("starts timing again after a plan is cleared and recreated", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "plan-old-start", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { plan: [{ step: "Check", status: "inProgress" }] }, - }), - makeActivity({ - id: "plan-old-complete", - createdAt: "2026-02-23T00:00:05.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { plan: [{ step: "Check", status: "completed" }] }, - }), - makeActivity({ - id: "plan-clear", - createdAt: "2026-02-23T00:00:06.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { plan: [] }, - }), - makeActivity({ - id: "plan-new-start", - createdAt: "2026-02-23T00:00:10.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { plan: [{ step: "Check", status: "inProgress" }] }, - }), - makeActivity({ - id: "plan-new-complete", - createdAt: "2026-02-23T00:00:13.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { plan: [{ step: "Check", status: "completed" }] }, - }), - ]; - - expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([ - { durationMs: 3_000, step: "Check", status: "completed" }, - ]); - }); -}); - describe("deriveTurnPlans", () => { it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { const activities: OrchestrationThreadActivity[] = [ @@ -879,27 +763,355 @@ describe("workEntryIndicatesToolFailure", () => { }), ).toBe(false); }); + + it("does not treat command text as rendered failure output", () => { + const entry = { + ...base, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + command: "rg 'command not found' src", + }; + + expect(workEntryIndicatesToolFailure(entry)).toBe(true); + expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); + expect( + workEntryDisplayIndicatesToolFailure({ + ...entry, + detail: "command not found: rg", + }), + ).toBe(true); + }); }); describe("deriveWorkLogEntries", () => { - it("omits tool started entries and keeps completed entries", () => { + it("shows a command from its start event while it is still running", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + turnId: "turn-1", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + id: "tool-start", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "inProgress", + sourceActivityKind: "tool.started", + }); + }); + + it.each(["turn-1", undefined])( + "retains interleaved start data when matching completions omit it (turn: %s)", + (turnId) => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + ...(turnId ? { turnId } : {}), + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + data: { input: { command: "vp test run" } }, + }, + }), + makeActivity({ + id: "other-tool-start", + createdAt: "2026-02-23T00:00:02.500Z", + ...(turnId ? { turnId } : {}), + summary: "Other command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "inProgress", + title: "Other command", + data: { input: { command: "vp lint" } }, + }, + }), + makeActivity({ + id: "tool-complete", + createdAt: "2026-02-23T00:00:03.000Z", + ...(turnId ? { turnId } : {}), + summary: "Command run", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "completed", + title: "Command run", + }, + }), + makeActivity({ + id: "other-tool-complete", + createdAt: "2026-02-23T00:00:04.000Z", + ...(turnId ? { turnId } : {}), + summary: "Other command", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "completed", + title: "Other command", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ id: "tool-complete", - createdAt: "2026-02-23T00:00:03.000Z", - summary: "Tool call complete", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + expect(entries[1]).toMatchObject({ + id: "other-tool-complete", + command: "vp lint", + toolCallId: "call-2", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + }, + ); + + it("does not merge reused tool ids across turns", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "turn-1-tool-start", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + summary: "Command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "reused-call", + title: "Command", + status: "inProgress", + }, + }), + makeActivity({ + id: "turn-2-tool-complete", + createdAt: "2026-02-23T00:00:02.000Z", + turnId: "turn-2", + summary: "Command completed", kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "reused-call", + title: "Command", + status: "completed", + }, }), + ]; + + expect(deriveWorkLogEntries(activities)).toHaveLength(2); + }); + + it("collapses an id-less completion into its adjacent keyed start", () => { + const activities: OrchestrationThreadActivity[] = [ makeActivity({ - id: "tool-start", + id: "keyed-start", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + summary: "Command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + title: "Command", + status: "inProgress", + data: { command: "vp test run" }, + }, + }), + makeActivity({ + id: "legacy-complete", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Tool call", + turnId: "turn-1", + summary: "Command completed", + kind: "tool.completed", + payload: { + itemType: "command_execution", + title: "Command", + status: "completed", + }, + }), + ]; + + expect(deriveWorkLogEntries(activities)).toEqual([ + expect.objectContaining({ + id: "legacy-complete", + command: "vp test run", + toolLifecycleStatus: "completed", + }), + ]); + }); + + it("orders same-timestamp lifecycle events before provider sequence", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "completed-first-by-sequence", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + sequence: 1, + summary: "Command completed", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + title: "Command", + status: "completed", + }, + }), + makeActivity({ + id: "started-second-by-sequence", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + sequence: 2, + summary: "Command started", kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + title: "Command", + status: "inProgress", + }, }), ]; - const entries = deriveWorkLogEntries(activities); - expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); + expect(deriveWorkLogEntries(activities)).toEqual([ + expect.objectContaining({ + id: "completed-first-by-sequence", + toolLifecycleStatus: "completed", + }), + ]); + }); + + it("does not merge non-adjacent tool starts without stable call ids", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "unkeyed-start-1", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + makeActivity({ + id: "keyed-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-between", + title: "Command", + status: "inProgress", + }, + }), + makeActivity({ + id: "unkeyed-start-2", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "unkeyed-start-1", + "keyed-start", + "unkeyed-start-2", + ]); + }); + + it("does not merge adjacent tool starts without stable call ids", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "unkeyed-start-1", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + makeActivity({ + id: "unkeyed-start-2", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "unkeyed-start-1", + "unkeyed-start-2", + ]); + }); + + it("does not assign an unkeyed completion to an adjacent concurrent start", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "search-start-1", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Search started", + kind: "tool.started", + payload: { + itemType: "search", + toolCallId: "search-call-1", + title: "Search", + status: "inProgress", + }, + }), + makeActivity({ + id: "search-start-2", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Search started", + kind: "tool.started", + payload: { + itemType: "search", + toolCallId: "search-call-2", + title: "Search", + status: "inProgress", + }, + }), + makeActivity({ + id: "legacy-search-complete", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "Search", + kind: "tool.completed", + payload: { + itemType: "search", + title: "Search", + status: "completed", + }, + }), + ]; + + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "search-start-1", + "search-start-2", + "legacy-search-complete", + ]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1125,6 +1337,50 @@ describe("deriveWorkLogEntries", () => { expect(entry?.toolLifecycleStatus).toBe("completed"); }); + it("does not leave id-less tool lifecycles running", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "legacy-tool-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Glob", + tone: "tool", + payload: { + itemType: "mcp_tool_call", + detail: "Searching", + }, + }), + makeActivity({ + id: "legacy-tool-update", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Glob", + tone: "tool", + payload: { + itemType: "mcp_tool_call", + detail: "Still searching", + }, + }), + makeActivity({ + id: "legacy-tool-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.completed", + summary: "Glob", + tone: "tool", + payload: { + itemType: "mcp_tool_call", + detail: "Found 3 files", + }, + }), + ]); + + expect(entries.map((entry) => entry.id)).toEqual([ + "legacy-tool-update", + "legacy-tool-complete", + ]); + expect(entries.map((entry) => entry.toolLifecycleStatus)).toEqual([undefined, "completed"]); + }); + it("preserves MCP server, tool, arguments, and results for expanded display", () => { const item = { type: "mcpToolCall", @@ -1399,6 +1655,7 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ id: "grep-complete", + toolCallId: "tool-grep-1", toolTitle: "grep", detail: "19 files", itemType: "web_search", @@ -1586,6 +1843,205 @@ describe("deriveWorkLogEntries", () => { }); }); + it("keeps id-less lifecycle entries from different turns separate", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-update-turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Tool call", + turnId: "turn-1", + payload: { + itemType: "dynamic_tool_call", + title: "Tool call", + detail: 'Read: {"file_path":"/tmp/app.ts"}', + }, + }), + makeActivity({ + id: "tool-complete-turn-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Tool call completed", + turnId: "turn-2", + payload: { + itemType: "dynamic_tool_call", + title: "Tool call", + detail: 'Read: {"file_path":"/tmp/app.ts"}', + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + + expect(entries.map((entry) => entry.id)).toEqual([ + "tool-update-turn-1", + "tool-complete-turn-2", + ]); + }); + + it("only settles an adjacent id-less completion when its target is unambiguous", () => { + const commandActivity = ( + id: string, + kind: "tool.started" | "tool.updated" | "tool.completed", + turnId: string, + sequence: number, + toolCallId?: string, + ) => + makeActivity({ + id, + kind, + turnId, + sequence, + summary: kind === "tool.completed" ? "Command completed" : "Command", + payload: { + itemType: "command_execution", + title: "Command", + status: kind === "tool.completed" ? "completed" : "inProgress", + ...(toolCallId ? { toolCallId } : {}), + }, + }); + + const concurrentEntries = deriveWorkLogEntries([ + commandActivity("call-1-start", "tool.started", "turn-1", 1, "call-1"), + commandActivity("call-2-start", "tool.started", "turn-1", 2, "call-2"), + commandActivity("call-2-update", "tool.updated", "turn-1", 3, "call-2"), + commandActivity("id-less-complete", "tool.completed", "turn-1", 4), + ]); + expect(concurrentEntries.map((entry) => entry.id)).toEqual([ + "call-1-start", + "call-2-update", + "id-less-complete", + ]); + + const nextTurnEntries = deriveWorkLogEntries([ + commandActivity("old-start", "tool.started", "turn-1", 1, "call-1"), + commandActivity("current-start", "tool.started", "turn-2", 2, "call-2"), + commandActivity("current-complete", "tool.completed", "turn-2", 3), + ]); + expect(nextTurnEntries.map((entry) => entry.id)).toEqual(["old-start", "current-complete"]); + }); + + it("folds a delayed update into an already completed tool call", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-started", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.started", + summary: "Running command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + data: { toolCallId: "tool-delayed-update" }, + }, + }), + makeActivity({ + id: "tool-completed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + data: { toolCallId: "tool-delayed-update" }, + }, + }), + makeActivity({ + id: "tool-update-delayed", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Running command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + data: { + toolCallId: "tool-delayed-update", + item: { command: ["vp", "test", "run"] }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "tool-completed", + command: "vp test run", + toolCallId: "tool-delayed-update", + toolLifecycleStatus: "completed", + }); + }); + + it("keeps a terminal tool status when stale lifecycle events arrive", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-a-failed", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Command failed", + turnId: "turn-1", + payload: { + itemType: "command_execution", + title: "Command", + status: "failed", + data: { toolCallId: "tool-a" }, + }, + }), + makeActivity({ + id: "tool-a-idless-late-update", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "tool.updated", + summary: "Running command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + title: "Command", + status: "inProgress", + }, + }), + makeActivity({ + id: "tool-b-updated", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Running another command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + status: "inProgress", + data: { toolCallId: "tool-b" }, + }, + }), + makeActivity({ + id: "tool-a-late-update", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Running command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + status: "inProgress", + data: { toolCallId: "tool-a" }, + }, + }), + makeActivity({ + id: "tool-a-late-start", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "tool.started", + summary: "Running command", + turnId: "turn-1", + payload: { + itemType: "command_execution", + data: { toolCallId: "tool-a" }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + + expect(entries.map((entry) => entry.id)).toEqual(["tool-a-failed", "tool-b-updated"]); + expect(entries[0]?.toolLifecycleStatus).toBe("failed"); + }); + it("keeps separate tool entries when an identical call starts after the prior one completed", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1850,6 +2306,20 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); + it("uses the latest user message when the running turn outruns the latest-turn pointer", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + status: "running", + activeTurnId: TurnId.make("turn-2"), + }, + null, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); + it("falls back to sendStartedAt once the latest turn is settled", () => { expect( deriveActiveWorkStartedAt( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d43b5d873e96..0c6566c770b8 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,5 +1,3 @@ -import * as Option from "effect/Option"; -import * as Arr from "effect/Array"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; import { ApprovalRequestId, @@ -65,6 +63,8 @@ export interface WorkLogEntry { id: string; createdAt: string; turnId?: TurnId | null; + /** Stable provider identity across in-progress and completed lifecycle updates. */ + toolCallId?: string; label: string; detail?: string; command?: string; @@ -224,8 +224,10 @@ function toolDetailTextLooksLikeFailure(text: string): boolean { return false; } -/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */ -export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { +function workEntryIndicatesToolFailureFromOutput( + entry: WorkLogEntry, + includeCommand: boolean, +): boolean { if (entry.tone === "error") { return true; } @@ -240,7 +242,7 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { if (entry.detail) { parts.push(entry.detail); } - if (entry.command) { + if (includeCommand && entry.command) { parts.push(entry.command); } const blob = parts.join("\n"); @@ -250,6 +252,16 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { return toolDetailTextLooksLikeFailure(blob); } +/** True when a tool failed, including providers that put error output in `command`. */ +export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { + return workEntryIndicatesToolFailureFromOutput(entry, true); +} + +/** True when the rendered result indicates failure. The command itself is user intent, not output. */ +export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boolean { + return workEntryIndicatesToolFailureFromOutput(entry, false); +} + /** Tool/command row completed without failure (blue check affordance). */ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { if (!workLogEntryIsToolLike(entry)) { @@ -338,13 +350,14 @@ export function deriveActiveWorkStartedAt( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, sendStartedAt: string | null, + latestUserMessageAt: string | null = null, ): string | null { const runningTurnId = session?.status === "running" ? session.activeTurnId : null; if (runningTurnId !== null) { if (latestTurn?.turnId === runningTurnId) { - return latestTurn.startedAt ?? sendStartedAt; + return latestTurn.startedAt ?? sendStartedAt ?? latestUserMessageAt; } - return sendStartedAt; + return sendStartedAt ?? latestUserMessageAt; } if (!isLatestTurnSettled(latestTurn, session)) { return latestTurn?.startedAt ?? sendStartedAt; @@ -638,34 +651,6 @@ function addPlanStepDurations( }; } -export function deriveActivePlanState( - activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); - // Prefer plan from the current turn; fall back to the most recent plan from any turn - // so that TodoWrite tasks persist across follow-up messages. - const latest = Option.firstSomeOf([ - ...(latestTurnId - ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) - : Option.none()), - Arr.last(allPlanActivities), - ]).pipe(Option.getOrNull); - if (!latest) { - return null; - } - const plan = planStateFromActivity(latest); - if (!plan) return null; - const matchingActivities = allPlanActivities.filter( - (activity) => activity.turnId === latest.turnId, - ); - const latestClearIndex = matchingActivities.findLastIndex( - (activity) => planStateFromActivity(activity) === null, - ); - return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1)); -} - export interface TurnPlanEntry { /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ id: string; @@ -825,7 +810,6 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They @@ -834,8 +818,14 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + // Plan updates have a dedicated task row. Keeping the raw activity here + // duplicates it as a legacy "Work Log / Plan updated" row when history + // is expanded. + if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isCodexTerminalInteractionActivity(activity)) continue; + if (isUnkeyedStatuslessToolStart(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } @@ -846,7 +836,11 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { + if ( + activity.kind !== "tool.started" && + activity.kind !== "tool.updated" && + activity.kind !== "tool.completed" + ) { return false; } @@ -857,6 +851,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } +/** + * Codex terminal interactions report bytes written to an already-running PTY. + * Some thread histories contain them as generic tool.updated rows, so filter + * their exact wire shape from the presentation model. This repairs existing + * history without deleting or rewriting persisted activities. + */ +function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.updated") { + return false; + } + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + return ( + payload?.itemType === "command_execution" && + typeof data?.itemId === "string" && + typeof data.processId === "string" && + typeof data.stdin === "string" && + typeof data.threadId === "string" && + typeof data.turnId === "string" + ); +} + function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { @@ -876,6 +892,17 @@ function extractWorkLogToolLifecycleStatus( return undefined; } +function isUnkeyedStatuslessToolStart(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.started") { + return false; + } + const payload = asRecord(activity.payload); + if (extractToolCallId(payload)) { + return false; + } + return extractWorkLogToolLifecycleStatus(payload) === undefined; +} + function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { const payload = activity.payload && typeof activity.payload === "object" @@ -955,6 +982,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && toolCallId && activity.kind === "tool.started") { + toolLifecycleStatus = "inProgress"; + } if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } @@ -1010,6 +1040,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; } +function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { + return undefined; + } + return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -1026,6 +1067,7 @@ function collapseDerivedWorkLogEntries( // own turn splintered one batch into a stream of "Kicked off N subagents" // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); + const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -1070,12 +1112,65 @@ function collapseDerivedWorkLogEntries( }); continue; } + const lifecycleKey = toolLifecycleCollapseMapKey(entry); + if (lifecycleKey !== undefined) { + const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); + if (matchingLifecycleIndex !== undefined) { + const matchingEntry = collapsed[matchingLifecycleIndex]; + if (matchingEntry && workLogEntryHasTerminalToolLifecycle(matchingEntry)) { + collapsed[matchingLifecycleIndex] = workLogEntryHasTerminalToolLifecycle(entry) + ? mergeDerivedWorkLogEntries(matchingEntry, entry) + : mergeDerivedWorkLogEntries(entry, matchingEntry); + continue; + } + if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { + const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); + collapsed[matchingLifecycleIndex] = merged; + toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); + continue; + } + toolLifecycleRowIndex.delete(lifecycleKey); + } + } const previous = collapsed.at(-1); - if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const hasCompetingIdlessCompletionTarget = + (previous?.activityKind === "tool.started" || previous?.activityKind === "tool.updated") && + entry.activityKind === "tool.completed" && + entry.toolCallId === undefined && + collapsed + .slice(0, -1) + .some( + (candidate) => + candidate.activityKind !== "tool.completed" && + candidate.turnId === previous.turnId && + candidate.itemType === previous.itemType && + normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) === + normalizeCompactToolLabel(previous.toolTitle ?? previous.label), + ); + if ( + previous && + !hasCompetingIdlessCompletionTarget && + shouldCollapseToolLifecycleEntries(previous, entry) + ) { + const previousIndex = collapsed.length - 1; + const previousKey = toolLifecycleCollapseMapKey(previous); + if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); + const merged = + workLogEntryHasTerminalToolLifecycle(previous) && + !workLogEntryHasTerminalToolLifecycle(entry) + ? mergeDerivedWorkLogEntries(entry, previous) + : mergeDerivedWorkLogEntries(previous, entry); + collapsed[previousIndex] = merged; + const mergedKey = toolLifecycleCollapseMapKey(merged); + if (mergedKey !== undefined) { + toolLifecycleRowIndex.set(mergedKey, previousIndex); + } continue; } collapsed.push(entry); + if (lifecycleKey !== undefined) { + toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); + } } return collapsed; } @@ -1084,19 +1179,34 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + if ( + previous.activityKind !== "tool.started" && + previous.activityKind !== "tool.updated" && + previous.activityKind !== "tool.completed" + ) { + return false; + } + if ( + next.activityKind !== "tool.started" && + next.activityKind !== "tool.updated" && + next.activityKind !== "tool.completed" + ) { return false; } - if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + if (previous.turnId !== next.turnId) { return false; } if (previous.activityKind === "tool.completed") { return false; } + if (next.activityKind === "tool.started") { + return false; + } if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { return true; } return ( + (previous.activityKind !== "tool.started" || next.activityKind === "tool.completed") && previous.toolCallId !== undefined && next.toolCallId === undefined && previous.itemType === next.itemType && @@ -1105,6 +1215,16 @@ function shouldCollapseToolLifecycleEntries( ); } +function workLogEntryHasTerminalToolLifecycle(entry: DerivedWorkLogEntry): boolean { + return ( + entry.activityKind === "tool.completed" || + entry.toolLifecycleStatus === "completed" || + entry.toolLifecycleStatus === "failed" || + entry.toolLifecycleStatus === "declined" || + entry.toolLifecycleStatus === "stopped" + ); +} + function mergeDerivedWorkLogEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, @@ -1157,11 +1277,15 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { return undefined; } if (entry.toolCallId) { - return `tool:${entry.toolCallId}`; + return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); const detail = entry.detail?.trim() ?? ""; @@ -1360,6 +1484,8 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + const dataInput = asRecord(data?.input); + const stateInput = asRecord(asRecord(data?.state)?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1367,6 +1493,8 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, + dataInput?.command, + stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1393,7 +1521,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(data?.toolCallId); + return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); } function normalizeInlinePreview(value: string): string {