From 5122399bfc133da32024371022f1963e21e858bf Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 19 Aug 2026 20:41:09 -0400 Subject: [PATCH 1/4] feat(web): group tool activity --- apps/web/src/components/ChatView.tsx | 9 +- .../chat/MessagesTimeline.logic.test.ts | 15 +- .../components/chat/MessagesTimeline.logic.ts | 421 +++++++++++- .../components/chat/MessagesTimeline.test.tsx | 45 +- .../src/components/chat/MessagesTimeline.tsx | 608 ++++++++++++++---- apps/web/src/index.css | 74 +++ apps/web/src/session-logic.test.ts | 15 + apps/web/src/session-logic.ts | 27 +- 8 files changed, 1033 insertions(+), 181 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 336f7ed828c1..c62272f62cca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -561,8 +561,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 +614,7 @@ function useLocalDispatchState(input: { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, + latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, }; @@ -2325,6 +2328,7 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt, + latestUserMessageAt, isPreparingWorktree, isSendBusy, } = useLocalDispatchState({ @@ -2340,6 +2344,7 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn, activeThread?.session ?? null, localDispatchStartedAt, + latestUserMessageAt, ); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 70a330d46303..09a137994470 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -545,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => { "user-entry", "turn-fold:turn-1", "assistant-thought-entry", - "work-entry-1", + "work-toggle:work-entry-1", "assistant-final-entry", ]); expect( @@ -800,9 +800,9 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ - "assistant-thought-entry", - "work-entry-1", "working-indicator-row", + "assistant-thought-entry", + "work-live:work-entry-1", ]); }); @@ -864,7 +864,7 @@ describe("deriveMessagesTimelineRows", () => { 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.map((row) => row.id)).toContain("work-live:running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1006,18 +1006,19 @@ 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, + summary: "Used 3 tools", }); 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, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c89bbd0557d9..6b374f090144 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,161 @@ 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 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 && laterTerminalIdentities.has(identity)) continue; + + reversedEntries.push(entry); + if ( + 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,6 +501,25 @@ 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 @@ -481,21 +687,121 @@ export function deriveMessagesTimelineRows(input: { } } + 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) { + if (input.isWorking && index === activeTurnHeaderIndex) { + appendWorkingRow(); + } + + if (timelineEntry.id === activeWorkPlacementEntryId) { + appendActiveWorkRows(); + } + + const anchoredTurnFold = foldsByAnchorEntryId.get(timelineEntry.id); + if (anchoredTurnFold) { 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, + id: `turn-fold:${anchoredTurnFold.turnId}`, + createdAt: anchoredTurnFold.createdAt, + turnId: anchoredTurnFold.turnId, + label: anchoredTurnFold.label, + expanded: input.expandedTurnIds?.has(anchoredTurnFold.turnId) ?? false, }); } @@ -503,6 +809,10 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (activeWorkEntryIds.has(timelineEntry.id)) { + continue; + } + if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -511,6 +821,7 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || + activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -519,19 +830,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 +905,8 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } @@ -566,8 +918,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 +988,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 +1021,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 +1040,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 +1067,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..f606a94405a6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -700,7 +700,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("formats changed file paths from the workspace root", () => { + it("summarizes changed files in one line", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).toContain("Changed 1 file"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("shows the animated one-line label for a live tool group", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Working for"); + expect(markup).toContain("Running pnpm"); + expect(markup).toContain("live-activity-focus"); + }); + it("renders review comment contexts as structured cards instead of raw tags", () => { const markup = renderToStaticMarkup( ({ isWorking, isRevertingCheckpoint, - activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], + [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], ); // Stable renderItem — no closure deps. Row components read shared state @@ -921,17 +918,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} @@ -1282,14 +1302,9 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ function WorkingTimelineRow({ row }: { row: Extract }) { const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
- - - - - - +
+
+
{row.createdAt ? ( <> Working for @@ -1297,11 +1312,16 @@ function WorkingTimelineRow({ row }: { row: Extract - {workingStepLabel ? ( - · {workingStepLabel} - ) : null} + {workingStepLabel ? ( + · {workingStepLabel} + ) : null} +
+ {row.showThinking ? ( +
+ +
+ ) : null}
); } @@ -1342,13 +1362,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 +1379,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 +1397,169 @@ 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 +1567,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"} + +
+
); }); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 68b5ee652991..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", @@ -1184,7 +1440,6 @@ describe("deriveWorkLogEntries", () => { const [entry] = deriveWorkLogEntries(activities); expect(entry?.toolData).toEqual(item); - expect(entry?.toolCallId).toBe("call-1"); }); it("unwraps PowerShell command wrappers for displayed command text", () => { @@ -1400,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", @@ -1587,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({ @@ -1851,7 +2306,7 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); - it("falls back to the latest user message while a running turn is being acknowledged", () => { + it("uses the latest user message when the running turn outruns the latest-turn pointer", () => { expect( deriveActiveWorkStartedAt( latestTurn, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 30560868a3fa..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, @@ -653,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; @@ -840,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 @@ -849,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)); } @@ -861,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; } @@ -872,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 { @@ -891,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" @@ -970,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"; } @@ -1025,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[] { @@ -1041,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 && @@ -1085,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; } @@ -1099,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.updated" && next.activityKind !== "tool.completed") { + if ( + next.activityKind !== "tool.started" && + next.activityKind !== "tool.updated" && + next.activityKind !== "tool.completed" + ) { + return false; + } + 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 && @@ -1120,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, @@ -1172,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() ?? ""; @@ -1375,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[] = [ @@ -1382,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, ]; From befa5111b6eddbd887b2387f0837058293f4bae4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 19 Aug 2026 20:42:49 -0400 Subject: [PATCH 4/4] fix(web): preserve active task progress --- apps/web/src/components/ChatView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f28315303ea..6bbfd9ce75ba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4173,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);