From 1c80f6efa5f127b575a50a21a185aa1e88310af9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 16 Sep 2026 21:44:35 +0000 Subject: [PATCH 1/2] fix(chat): group thoughts into the changing tool activity line --- .../src/features/threads/ThreadFeed.tsx | 42 ++- .../src/features/threads/thread-work-log.tsx | 2 +- apps/mobile/src/lib/threadActivity.test.ts | 245 ++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 242 +++++++++++++++-- .../chat/MessagesTimeline.logic.test.ts | 204 ++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 116 +++++++-- .../src/components/chat/MessagesTimeline.tsx | 173 +++++++++++-- 7 files changed, 913 insertions(+), 111 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1d110d6b69ce..4cafbd6d8e18 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1476,33 +1476,31 @@ function renderFeedEntry( if (entry.type === "message") { const { message } = entry; if (message.role === "reasoning") { - // Only the live turn may claim to still be thinking, and only while the - // thread is actually working: a block left open by a crashed provider - // must not shimmer on a turn that settled long ago. Same test as web. - const liveReasoning = - Boolean(message.streaming) && - props.isWorking && - message.turnId !== null && - message.turnId === props.unsettledTurnId; + const messages = entry.reasoningMessages ?? [message]; return ( props.onToggleReasoning(message.id)} + expanded={props.expandedReasoningMessageIds.has(entry.id)} + label={`Thought${messages.length > 1 ? ` (×${messages.length})` : ""}`} + streaming={false} + onToggle={() => props.onToggleReasoning(entry.id)} > - + + {messages.map((reasoningMessage) => ( + + ))} + ); @@ -2656,8 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const onToggleReasoning = useCallback( (messageId: string) => { - // The anchor must be the feed row id, which for a message row is the - // message id, or position restoration is skipped for every row. + // Reasoning details use their own row within the expanded activity history. suspendEndScrollMaintenanceForDisclosure(messageId); setInteractionState((current) => { const next = new Set(current.expandedReasoningMessageIds); @@ -2700,8 +2697,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { switch (entry.type) { case "message": // A collapsed reasoning row is the same chrome as a work toggle. - return entry.message.role === "reasoning" && - !expandedReasoningMessageIds.has(entry.message.id) + return entry.message.role === "reasoning" && !expandedReasoningMessageIds.has(entry.id) ? WORK_GROUP_TOGGLE_HEIGHT : undefined; case "turn-fold": diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index b2c31a785639..e5e7ad150703 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -920,7 +920,7 @@ export function ThreadWorkGroupToggle(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; - readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain"; readonly themeAppearance: "light" | "dark"; readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; readonly toolIcon?: ToolActivityIcon; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4e301288c94f..29fe3073da01 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2295,6 +2295,251 @@ describe("buildThreadFeed", () => { }, ); + it("groups ordered reasoning blocks, keeps the live slot, and restores the group after unfolding", () => { + const turnId = TurnId.make("reasoning-group"); + const messages: OrchestrationThread["messages"] = [1, 2, 3, 4].map((second) => ({ + id: MessageId.make(`reasoning-${second}`), + role: "reasoning", + text: `**Step ${second}**\n\nCheck ${second}.`, + turnId, + streaming: second === 4, + createdAt: `2026-04-01T00:00:0${second}.000Z`, + updatedAt: `2026-04-01T00:00:0${second}.000Z`, + })); + const thread = makeThread({ + id: ThreadId.make("reasoning-group"), + projectId: ProjectId.make("project-1"), + title: "Reasoning", + messages, + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const feed = buildThreadFeed(thread); + const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now"); + expect(rows).toMatchObject([ + { type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 }, + ]); + expect(rows).toHaveLength(1); + expect( + deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now")[0], + ).toBe(rows[0]); + + const settledTurn = { + ...thread.latestTurn!, + state: "completed" as const, + completedAt: "2026-04-01T00:00:06.000Z", + }; + // Reasoning alone stays visible, including a streaming flag left behind on settlement. + expect(deriveThreadFeedPresentation(feed, settledTurn, new Set())).toMatchObject([ + { type: "work-toggle", summary: "Thought (×4)", hiddenCount: 4 }, + ]); + const completedMessages = messages.map((message) => ({ ...message, streaming: false })); + const waiting = deriveThreadFeedPresentation( + buildThreadFeed({ ...thread, messages: completedMessages }), + thread.latestTurn, + new Set(), + new Set(), + "now", + ); + expect(waiting).toMatchObject([ + { type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 }, + ]); + const feedWithWork = buildThreadFeed({ + ...thread, + activities: [ + makeActivity({ + id: EventId.make("reasoning-tool"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { itemType: "file_read", status: "completed" }, + }), + ], + }); + const toolRunning = deriveThreadFeedPresentation( + feedWithWork, + thread.latestTurn, + new Set(), + new Set(), + "now", + ); + expect(toolRunning).toMatchObject([ + { type: "work-toggle", id: "live-activity-row", shimmer: true, hiddenCount: 5 }, + ]); + expect(toolRunning[0]).not.toMatchObject({ summary: "Thinking" }); + const nextThought = { + ...messages[3]!, + id: MessageId.make("reasoning-after-tool"), + createdAt: "2026-04-01T00:00:06.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }; + const reasoningAgainFeed: ThreadFeedEntry[] = [ + ...feedWithWork, + { + type: "message", + id: nextThought.id, + createdAt: nextThought.createdAt, + message: nextThought, + }, + ]; + const reasoningAgain = deriveThreadFeedPresentation( + reasoningAgainFeed, + thread.latestTurn, + new Set(), + new Set(), + "now", + ); + expect(reasoningAgain.filter((row) => row.id === "live-activity-row")).toMatchObject([ + { type: "work-toggle", summary: "Thinking", hiddenCount: 6 }, + ]); + expect(reasoningAgain).toHaveLength(1); + const expandedLive = deriveThreadFeedPresentation( + reasoningAgainFeed, + thread.latestTurn, + new Set(), + new Set([`activity-run:${messages[0]!.id}`]), + "now", + ); + expect(expandedLive.map((entry) => entry.type)).toEqual([ + "work-toggle", + "message", + "activity-group", + "message", + ]); + expect(expandedLive[1]).toMatchObject({ reasoningMessages: messages }); + expect(expandedLive[3]).toMatchObject({ message: nextThought }); + expect(deriveThreadFeedPresentation(feedWithWork, settledTurn, new Set())).toMatchObject([ + { type: "turn-fold", expanded: false }, + ]); + const reopened = deriveThreadFeedPresentation( + feedWithWork, + settledTurn, + new Set([turnId]), + new Set([`activity-run:${messages[0]!.id}`]), + ); + expect(reopened.map((entry) => entry.type)).toEqual([ + "turn-fold", + "work-toggle", + "message", + "activity-group", + ]); + expect(reopened[2]).toMatchObject({ id: messages[0]!.id, reasoningMessages: messages }); + const toolFirstFeed = feedWithWork.filter((entry) => entry.type === "activity-group"); + const toolFirst = deriveThreadFeedPresentation( + toolFirstFeed, + thread.latestTurn, + new Set(), + new Set(), + "now", + )[0]; + expect(toolFirst?.type).toBe("work-toggle"); + if (toolFirst?.type !== "work-toggle") return; + const preservedExpansion = deriveThreadFeedPresentation( + [...toolFirstFeed, reasoningAgainFeed.at(-1)!], + thread.latestTurn, + new Set(), + new Set([toolFirst.groupId]), + "now", + ); + expect(preservedExpansion.map((entry) => entry.type)).toEqual([ + "work-toggle", + "activity-group", + "message", + ]); + expect(preservedExpansion[0]).toMatchObject({ + id: "live-activity-row", + groupId: toolFirst.groupId, + expanded: true, + summary: "Thinking", + }); + }); + + it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)( + "preserves a %s boundary in expanded activity history", + (boundary) => { + const turnId = TurnId.make("reasoning-boundary"); + const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({ + id: MessageId.make(`reasoning-${second}`), + role: "reasoning", + text: `Step ${second}`, + turnId: + boundary === "unknown-turn" + ? null + : boundary === "turn" && second === 3 + ? TurnId.make("other-turn") + : turnId, + streaming: false, + createdAt: `2026-04-01T00:00:0${second}.000Z`, + updatedAt: `2026-04-01T00:00:0${second}.000Z`, + })); + const thread = makeThread({ + id: ThreadId.make("reasoning-boundary"), + projectId: ProjectId.make("project-1"), + title: "Reasoning", + messages: + boundary === "assistant" + ? [ + messages[0]!, + { + ...messages[0]!, + id: MessageId.make("assistant-between"), + role: "assistant", + text: "Checking the next file.", + createdAt: "2026-04-01T00:00:02.000Z", + }, + messages[1]!, + ] + : messages, + activities: + boundary === "tool" || boundary === "failed-tool" + ? [ + makeActivity({ + id: EventId.make("tool-between"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + itemType: "file_read", + status: boundary === "failed-tool" ? "failed" : "completed", + }, + }), + ] + : [], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + null, + new Set([turnId]), + new Set(messages.map((message) => `activity-run:${message.id}`)), + ); + const reasoningRows = rows.filter( + (entry) => entry.type === "message" && entry.message.role === "reasoning", + ); + if (boundary === "failed-tool") { + expect(rows.filter((entry) => entry.type === "work-toggle")).toHaveLength(3); + expect(rows.some((entry) => entry.type === "work-toggle" && entry.hasFailure)).toBe(true); + } + expect(reasoningRows).toEqual( + messages.map((message) => ({ + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + })), + ); + }, + ); + it("shows one Thinking row while a turn works without live tool activity", () => { const turnId = TurnId.make("turn-thinking"); const latestTurn = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 396addf14c2a..ca22eb9c94b1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -148,7 +148,9 @@ type RawThreadFeedEntry = }; export type ThreadFeedEntry = - | Extract + | (Extract & { + readonly reasoningMessages?: OrchestrationThread["messages"]; + }) | { readonly type: "activity-group"; readonly id: string; @@ -168,7 +170,7 @@ export type ThreadFeedEntry = readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogEntry["toolSurface"]; readonly toolIcon?: WorkLogEntry["toolIcon"]; - readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; @@ -254,6 +256,18 @@ const turnFoldRowsCache = new WeakMap< Extract >(); let cachedThinkingRow: Extract | null = null; +const reasoningGroupsCache = new WeakMap< + ThreadFeedEntry, + Extract +>(); +const activityRunsCache = new WeakMap< + ThreadFeedEntry, + { + readonly source: ReadonlyArray; + readonly state: string; + readonly rows: ThreadFeedEntry[]; + } +>(); export function isContextCompactionActivityGroup( entry: Extract, @@ -1783,7 +1797,8 @@ export function deriveThreadFeedPresentation( } const result: ThreadFeedEntry[] = []; - for (const entry of sourceFeed) { + for (let index = 0; index < sourceFeed.length; index += 1) { + const entry = sourceFeed[index]!; const isActiveTailGroup = isWorking && unsettledTurnId !== null && @@ -1815,6 +1830,31 @@ export function deriveThreadFeedPresentation( result.push(row); } if (!collapsedEntryIds.has(entry.id)) { + const runTurnId = activityRunTurnId(entry); + if (runTurnId !== null) { + let end = index + 1; + while ( + end < sourceFeed.length && + activityRunTurnId(sourceFeed[end]!) === runTurnId && + !collapsedEntryIds.has(sourceFeed[end]!.id) && + !foldsByAnchorId.has(sourceFeed[end]!.id) + ) { + end += 1; + } + const run = sourceFeed.slice(index, end); + if (run.some((row) => row.type === "message")) { + appendMixedActivityRun( + result, + run, + expandedWorkGroupIds, + unsettledTurnId, + isWorking, + run.at(-1) === activeTailGroup, + ); + index = end - 1; + continue; + } + } appendPresentedFeedEntry( result, entry, @@ -1835,15 +1875,7 @@ export function deriveThreadFeedPresentation( !result.some( (row) => (row.type === "work-toggle" && row.shimmer) || - // A live thinking block is the real version of this row, so it takes - // the slot instead of sitting under a second "Thinking". Scoped to the - // live turn: a block stranded by a killed server must not silence this - // row for every turn that follows. - (row.type === "message" && - row.message.role === "reasoning" && - row.message.streaming && - row.message.turnId !== null && - row.message.turnId === unsettledTurnId) || + row.id === LIVE_ACTIVITY_ROW_ID || // A working spawn card is the live activity: its status line shows // what the agents are doing, so a Thinking row under it would lie. (row.type === "agent-spawn" && @@ -1856,6 +1888,153 @@ export function deriveThreadFeedPresentation( return result; } +function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null { + if (entry.type === "message" && entry.message.role === "reasoning") { + return entry.message.turnId; + } + if ( + entry.type === "activity-group" && + !isContextCompactionActivityGroup(entry) && + !isUserInputActivityGroup(entry) && + entry.activities.every( + (activity) => + !activity.workEntry.agentSpawn && + activity.workEntry.tone !== "error" && + !workEntryIndicatesToolFailure(activity.workEntry), + ) + ) { + return entry.turnId; + } + return null; +} + +function appendMixedActivityRun( + result: ThreadFeedEntry[], + run: ReadonlyArray, + expandedWorkGroupIds: ReadonlySet, + unsettledTurnId: TurnId | null, + isWorking: boolean, + activeTail: boolean, +) { + const first = run[0]!; + const last = run.at(-1)!; + const turnId = activityRunTurnId(first); + const live = isWorking && activeTail && turnId === unsettledTurnId; + const firstTool = + first.type === "activity-group" + ? visibleActivityGroupEntries(first, unsettledTurnId, isWorking)[0] + : undefined; + const groupId = firstTool ? toolActivityGroupId(firstTool) : `activity-run:${first.id}`; + const expanded = expandedWorkGroupIds.has(groupId); + const state = `${isWorking}:${unsettledTurnId}:${activeTail}:${expanded}`; + const cached = activityRunsCache.get(first); + if ( + cached?.state === state && + cached.source.length === run.length && + run.every((entry, index) => entry === cached.source[index]) + ) { + result.push(...cached.rows); + return; + } + const outputStart = result.length; + const history = groupConsecutiveReasoningMessages(run).map((entry) => + entry.type === "activity-group" + ? { + ...entry, + activities: visibleActivityGroupEntries(entry, unsettledTurnId, isWorking).map( + (activity) => ({ ...activity, groupedToolDetail: true }), + ), + } + : entry, + ); + const activities = history.flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + const toolRows: ThreadFeedEntry[] = []; + if (activities.length > 0) { + appendToolGroupRows( + toolRows, + { type: "activity-group", id: first.id, createdAt: first.createdAt, turnId, activities }, + activities, + new Set(), + unsettledTurnId, + isWorking, + live && last.type === "activity-group", + ); + } + const toolSummary = toolRows.find((entry) => entry.type === "work-toggle"); + const thinking = live && (last.type === "message" || !toolSummary?.shimmer); + const thoughtCount = run.filter((entry) => entry.type === "message").length; + result.push({ + type: "work-toggle", + id: live ? LIVE_ACTIVITY_ROW_ID : `work-toggle:${groupId}`, + createdAt: first.createdAt, + turnId, + groupId, + hiddenCount: activities.length + thoughtCount, + expanded, + summary: thinking + ? "Thinking" + : (toolSummary?.summary ?? `Thought${thoughtCount > 1 ? ` (×${thoughtCount})` : ""}`), + summaryKind: toolSummary?.summaryKind ?? "other", + ...(thinking || !toolSummary + ? { summaryToolIcon: "brain" as const } + : { + ...(toolSummary.toolSurface ? { toolSurface: toolSummary.toolSurface } : {}), + ...(toolSummary.toolIcon ? { toolIcon: toolSummary.toolIcon } : {}), + ...(toolSummary.summaryToolIcon ? { summaryToolIcon: toolSummary.summaryToolIcon } : {}), + }), + hasFailure: toolSummary?.hasFailure ?? false, + live, + shimmer: live, + }); + if (expanded) result.push(...history); + activityRunsCache.set(first, { source: run, state, rows: result.slice(outputStart) }); +} + +function groupConsecutiveReasoningMessages( + feed: ReadonlyArray, +): ThreadFeedEntry[] { + const result: ThreadFeedEntry[] = []; + for (let index = 0; index < feed.length; index += 1) { + const entry = feed[index]!; + if (entry.type !== "message" || entry.message.role !== "reasoning" || !entry.message.turnId) { + result.push(entry); + continue; + } + const messages = [entry.message]; + while (index + 1 < feed.length) { + const next = feed[index + 1]!; + if ( + next.type !== "message" || + next.message.role !== "reasoning" || + next.message.turnId !== entry.message.turnId + ) { + break; + } + messages.push(next.message); + index += 1; + } + if (messages.length === 1) { + result.push(entry); + continue; + } + let group = reasoningGroupsCache.get(entry); + if ( + !group || + group.reasoningMessages?.length !== messages.length || + !messages.every( + (message, messageIndex) => group?.reasoningMessages?.[messageIndex] === message, + ) + ) { + group = { ...entry, reasoningMessages: messages }; + reasoningGroupsCache.set(entry, group); + } + result.push(group); + } + return result; +} + /** * Shared by the trailing tool group's live row and the "Thinking" row so the * list keeps one mounted row for the turn's live slot (mirrors web's @@ -1924,16 +2103,7 @@ function appendActivityGroupRows( isWorking: boolean, activeTail: boolean, ): void { - const activities = omitSupersededLifecycleMarkers( - entry.activities.filter( - (activity) => - !(activity.toolLike && activity.status === "neutral") || - (isWorking && - activity.lifecycleStatus === "inProgress" && - activity.turnId === unsettledTurnId), - ), - (activity) => activity.workEntry, - ); + const activities = visibleActivityGroupEntries(entry, unsettledTurnId, isWorking); if (activities.length === 0) { return; } @@ -1984,6 +2154,28 @@ function appendActivityGroupRows( flushGroupableRun(true); } +function visibleActivityGroupEntries( + entry: ThreadFeedActivityGroup, + unsettledTurnId: TurnId | null, + isWorking: boolean, +) { + return omitSupersededLifecycleMarkers( + entry.activities.filter( + (activity) => + !(activity.toolLike && activity.status === "neutral") || + (isWorking && + activity.lifecycleStatus === "inProgress" && + activity.turnId === unsettledTurnId), + ), + (activity) => activity.workEntry, + ); +} + +function toolActivityGroupId(activity: ThreadFeedActivity): string { + const entry = activity.workEntry; + return `work-group:${entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : activity.id}`; +} + function appendToolGroupRows( result: ThreadFeedEntry[], sourceGroup: Extract, @@ -1993,11 +2185,7 @@ function appendToolGroupRows( isWorking: boolean, activeTail: boolean, ): void { - const firstEntry = activities[0]!.workEntry; - const identity = firstEntry.toolCallId - ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}` - : activities[0]!.id; - const groupId = `work-group:${identity}`; + const groupId = toolActivityGroupId(activities[0]!); const expanded = expandedWorkGroupIds.has(groupId); const latestActiveActivity = activities.findLast( (activity) => diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 1455298e54d6..5d3ebe8a3b19 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1950,37 +1950,213 @@ describe("deriveMessagesTimelineRows", () => { }, }); - it("keeps a thought-only turn out of the work fold", () => { + it("keeps all thoughts in one activity row as current and earlier traces stream", () => { + const entries = [1, 2, 3, 4].map((second) => { + const entry = reasoningEntry(`reasoning-${second}`, `2026-01-01T00:00:0${second}Z`, "turn-1"); + return { + ...entry, + message: { + ...entry.message, + text: `Step ${second}`, + streaming: second === 2 || second === 4, + }, + }; + }); + const input = { + timelineEntries: entries, + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const initial = deriveMessagesTimelineRowsWithState(input); + expect(initial.rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); + expect(initial.rows.at(-1)).toMatchObject({ + id: "live-activity-row", + entries, + expanded: false, + active: true, + }); + expect(deriveMessagesTimelineRowsWithState(input, initial).rows).toBe(initial.rows); + const stable = computeStableMessagesTimelineRows(initial.rows, { byId: new Map(), result: [] }); + expect(computeStableMessagesTimelineRows(deriveMessagesTimelineRows(input), stable)).toBe( + stable, + ); + + for (const index of [3, 1]) { + const entry = entries[index]!; + const updatedEntry = { ...entry, message: { ...entry.message, text: "Updated trace" } }; + const updatedEntries = entries.map((entry, position) => + position === index ? updatedEntry : entry, + ); + const updatedInput = { ...input, timelineEntries: updatedEntries }; + const updated = deriveMessagesTimelineRowsWithState(updatedInput, initial); + expect(updated.rows).toEqual(deriveMessagesTimelineRows(updatedInput)); + const updatedStable = computeStableMessagesTimelineRows(updated.rows, stable); + expect(updatedStable.byId.get("live-activity-row")).not.toBe( + stable.byId.get("live-activity-row"), + ); + expect(updatedStable.byId.get("live-activity-row")).toMatchObject({ + entries: updatedEntries, + }); + expect(updatedStable.byId.get("working-indicator-row")).toBe( + stable.byId.get("working-indicator-row"), + ); + expect(initial.rows.at(-1)).toMatchObject({ entries }); + } + }); + + it("updates the same collapsed row as tools and thoughts alternate", () => { + const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); + const current = reasoningEntry("thought-current", "2026-01-01T00:00:02Z", "turn-1"); + current.message.streaming = true; + const tool = toolEntry("tool-current", "2026-01-01T00:00:03Z", "turn-1"); + const runningTool = { + ...tool, + entry: { ...tool.entry, command: "pwd", toolLifecycleStatus: "inProgress" as const }, + }; + const next = reasoningEntry("thought-next", "2026-01-01T00:00:04Z", "turn-1"); + next.message.streaming = true; + const completed = { ...current, message: { ...current.message, streaming: false } }; + for (const entries of [ + [first, current], + [first, current, runningTool], + [first, current, runningTool, next], + [first, completed], + ]) { + const input = { + timelineEntries: entries, + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); + expect(rows.at(-1)).toMatchObject({ + id: "live-activity-row", + entries, + active: true, + expanded: false, + }); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(["activity-group:thought-first"]), + }); + expect(expanded.at(-1)).toMatchObject({ id: "live-activity-row", entries, expanded: true }); + } + }); + + it.each(["assistant", "user", "error", "turn"] as const)( + "keeps activity separate across a %s boundary", + (boundary) => { + const first = reasoningEntry("reasoning-first", "2026-01-01T00:00:01Z", "turn-1"); + const last = reasoningEntry( + "reasoning-last", + "2026-01-01T00:00:03Z", + boundary === "turn" ? "turn-2" : "turn-1", + ); + const answer = answerEntry("answer-between", "2026-01-01T00:00:02Z", "turn-1"); + const error = toolEntry("error-between", "2026-01-01T00:00:02Z", "turn-1"); + const middle = + boundary === "turn" + ? [] + : boundary === "error" + ? [{ ...error, entry: { ...error.entry, tone: "error" as const } }] + : [ + { + ...answer, + message: { + ...answer.message, + role: boundary === "user" ? ("user" as const) : ("assistant" as const), + }, + }, + ]; + const rows = deriveMessagesTimelineRows({ + timelineEntries: [first, ...middle, last], + expandedTurnIds: new Set([TurnId.make("turn-1"), TurnId.make("turn-2")]), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.filter((row) => row.kind === "activity-group")).toMatchObject([ + { entries: [first], active: false }, + { entries: [last], active: false }, + ]); + if (boundary === "error") { + expect(rows).toContainEqual(expect.objectContaining({ kind: "work", id: error.id })); + } + }, + ); + + it("does not combine thoughts without a known turn", () => { + const thoughts = [1, 2].map((second) => + reasoningEntry(`unknown-${second}`, `2026-01-01T00:00:0${second}Z`, null), + ); const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), - answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1"), - ], + timelineEntries: thoughts, isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, }); + expect(rows.map((row) => row.id)).toEqual(thoughts.map((entry) => entry.id)); + }); + it("keeps a thought-only turn out of the work fold", () => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [thought, answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1")], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); - expect(rows.map((row) => row.id)).toContain("reasoning-entry"); + expect(rows.find((row) => row.kind === "activity-group")).toMatchObject({ + entries: [thought], + expanded: false, + }); }); - it("folds a thinking block away with the tool work beside it", () => { - const rows = deriveMessagesTimelineRows({ + it("folds mixed activity under worked-for and restores ordered details when expanded", () => { + const entries = [ + reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), + toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), + reasoningEntry("reasoning-next", "2026-01-01T00:00:03Z", "turn-1"), + ]; + const input = { timelineEntries: [ - reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), - toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), - answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"), + ...entries, + answerEntry("assistant-entry", "2026-01-01T00:00:04Z", "turn-1"), ], isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + expect(rows.map((row) => row.kind)).toEqual(["turn-fold", "message"]); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set([TurnId.make("turn-1")]), }); - - expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); - expect(rows.map((row) => row.id)).not.toContain("reasoning-entry"); + expect(expanded.filter((row) => row.kind === "activity-group")).toMatchObject([ + { entries, expanded: false, active: false }, + ]); + const details = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set([TurnId.make("turn-1")]), + expandedWorkGroupIds: new Set(["activity-group:reasoning-entry"]), + }); + expect(details.find((row) => row.kind === "activity-group")).toMatchObject({ + entries, + expanded: true, + }); + expect(deriveMessagesTimelineRows(input)).toEqual(rows); }); it("still folds a lone trailing tool call when a thought follows the answer", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 244185fa705c..95201169665f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -310,9 +310,32 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; -const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + +type ActivityEntry = Extract; + +function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { + return entry.kind === "message" + ? entry.message.role === "reasoning" + : entry.kind === "work" && + entry.entry.agentSpawn === undefined && + entry.entry.questionAnswer === undefined && + entry.entry.sourceActivityKind !== "context-compaction" && + entry.entry.tone !== "error" && + !workEntryDisplayIndicatesToolFailure(entry.entry); +} export type MessagesTimelineRow = + | { + kind: "activity-group"; + id: string; + createdAt: string; + turnId: TurnId; + groupId: string; + entries: ActivityEntry[]; + expanded: boolean; + active: boolean; + } | { kind: "work"; id: string; @@ -366,6 +389,7 @@ export type MessagesTimelineRow = createdAt: string; message: ChatMessage; durationStart: string; + reasoningMessages?: ReadonlyArray; showAssistantMeta: boolean; showAssistantCopyButton: boolean; assistantCopyStreaming: boolean; @@ -587,7 +611,7 @@ function deriveActiveVisualResponseTurnIds(input: { return turnIds; } -function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { +export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { return ( entry.toolLifecycleStatus === "inProgress" || (entry.toolLifecycleStatus === undefined && @@ -819,7 +843,10 @@ function attachTrailingToolGroupsToAssistant( if (candidate.kind === "message") { break; } - if (candidate.kind === "work-toggle" && candidate.turnId === turnId) { + if ( + (candidate.kind === "work-toggle" || candidate.kind === "activity-group") && + candidate.turnId === turnId + ) { hasTrailingToolGroup = true; lastTrailingWorkIndex = index; continue; @@ -1074,6 +1101,7 @@ export function deriveMessagesTimelineRows(input: { ); }; + let scannedActivityThrough = -1; for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { @@ -1104,6 +1132,48 @@ export function deriveMessagesTimelineRows(input: { continue; } + const activityTurnId = timelineEntryTurnId(timelineEntry); + if (index > scannedActivityThrough && activityTurnId && isActivityEntry(timelineEntry)) { + const entries = [timelineEntry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const next = input.timelineEntries[cursor]!; + if ( + !isActivityEntry(next) || + timelineEntryTurnId(next) !== activityTurnId || + collapsedEntryIds.has(next.id) || + foldsByAnchorEntryId.has(next.id) + ) + break; + entries.push(next); + cursor += 1; + } + scannedActivityThrough = cursor - 1; + if (entries.some((entry) => entry.kind === "message")) { + const active = + input.isWorking && + activityTurnId === unsettledTurnId && + cursor === input.timelineEntries.length; + const groupId = + timelineEntry.kind === "work" + ? workGroupId(timelineEntry.id, timelineEntry.entry) + : `activity-group:${timelineEntry.id}`; + nextRows.push({ + kind: "activity-group", + id: active ? LIVE_ACTIVITY_ROW_ID : groupId, + createdAt: timelineEntry.createdAt, + turnId: activityTurnId, + groupId, + entries, + expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + active, + }); + hasActivityRow ||= active; + index = cursor - 1; + continue; + } + } + if (activeWorkEntryIds.has(timelineEntry.id)) { continue; } @@ -1364,29 +1434,13 @@ export function deriveMessagesTimelineRows(input: { ); } } - // A live thinking block is the real version of the placeholder below, so it - // suppresses it rather than sitting under a second "Thinking" row. - const hasStreamingReasoningRow = nextRows.some( - (row) => - row.kind === "message" && - row.message.role === "reasoning" && - row.message.streaming && - row.message.turnId !== null && - row.message.turnId === unsettledTurnId, - ); - // A running setup owns the working slot above its card and shows no // activity row of its own; every other state gets the usual tail. const hasWorkingRow = nextRows.some((row) => row.kind === "working"); if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if ( - input.isWorking && - !setupRunning && - !hasStreamingReasoningRow && - (!hasActivityRow || latestToolFailed) - ) { + if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, @@ -1468,6 +1522,18 @@ function replaceStreamingMessageRows( } if (replacements.size === 0) return previous.rows; return previous.rows.map((row) => { + if (row.kind === "activity-group") { + if (!row.entries.some((entry) => entry.kind === "message" && replacements.has(entry.message))) + return row; + return { + ...row, + entries: row.entries.map((entry) => { + if (entry.kind !== "message") return entry; + const message = replacements.get(entry.message); + return message ? { ...entry, message } : entry; + }), + }; + } if (row.kind !== "message" && row.kind !== "assistant-meta") return row; const message = replacements.get(row.message); return message ? { ...row, message } : row; @@ -1512,6 +1578,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean if (a.kind !== b.kind || a.id !== b.id) return false; switch (a.kind) { + case "activity-group": { + const group = b as typeof a; + return ( + a.active === group.active && + a.expanded === group.expanded && + a.groupId === group.groupId && + a.entries.length === group.entries.length && + a.entries.every((entry, index) => entry === group.entries[index]) + ); + } case "working": case "thinking": return a.createdAt === (b as typeof a).createdAt; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5807541c4cd2..27443ee2ed75 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,6 +29,8 @@ import { resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, + summarizeToolGroup, + omitSupersededLifecycleMarkers, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { @@ -167,9 +169,11 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis import { computeStableMessagesTimelineRows, deriveMessagesTimelineRowsWithState, + LIVE_ACTIVITY_ROW_ID, deriveUnsettledTurnId, type MessagesTimelineRowsProjection, liveWorkEntryLabel, + workEntryIsActiveTurnActivity, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -281,7 +285,7 @@ interface TimelineRowSharedState { onToggleWorkGroup: (groupId: string, anchorKey: string) => void; onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; onToggleSpawnRow: (entryId: string, expanded: boolean) => void; - onToggleReasoning: (messageId: string, expanded: boolean) => void; + onToggleReasoning: (messageId: string, expanded: boolean, anchorKey: string) => void; expandedReasoningMessageIds: ReadonlySet; workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; @@ -671,10 +675,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], ); const onToggleReasoning = useCallback( - (messageId: string, expanded: boolean) => { - // The anchor must be the timeline row id, which for a message row is the - // message id, or position restoration is skipped for every row. - suspendEndScrollMaintenanceForDisclosure(messageId, !expanded); + (messageId: string, expanded: boolean, anchorKey: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey, !expanded); setExpandedReasoningMessageIds((current) => { if (current.has(messageId) === expanded) return current; const next = new Set(current); @@ -1494,6 +1496,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work" || row.kind === "work-live" || row.kind === "work-toggle" || + row.kind === "activity-group" || row.kind === "thinking" || row.kind === "worktree-setup" ? "pb-2" @@ -1519,6 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time /> ) : null} {row.kind === "work-live" ? : null} + {row.kind === "activity-group" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "context-compaction" ? : null} @@ -2392,6 +2396,108 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap ); } +function ActivityGroupTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const work = omitSupersededLifecycleMarkers( + row.entries.flatMap((entry) => + entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) + ? [entry.entry] + : [], + ), + (entry) => entry, + ); + const thoughtCount = row.entries.filter((entry) => entry.kind === "message").length; + const lastThoughtIndex = row.entries.findLastIndex((entry) => entry.kind === "message"); + const trailingWork = omitSupersededLifecycleMarkers( + row.entries + .slice(lastThoughtIndex + 1) + .flatMap((entry) => + entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) + ? [entry.entry] + : [], + ), + (entry) => entry, + ); + const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1); + const thinking = row.active && liveWork === undefined; + const iconWork = row.active ? liveWork : work.at(-1); + const label = row.active + ? liveWork + ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true) + : "Thinking" + : work.length > 0 + ? summarizeToolGroup(work) + : `Thought${thoughtCount > 1 ? ` (×${thoughtCount})` : ""}`; + const details: ReactNode[] = []; + if (row.expanded) { + for (let index = 0; index < row.entries.length; index += 1) { + const entry = row.entries[index]!; + if (entry.kind === "work") { + const entries = [entry.entry]; + while (row.entries[index + 1]?.kind === "work") { + const next = row.entries[++index]!; + if (next.kind === "work") entries.push(next.entry); + } + details.push( + entry)} + isExpandedToolGroup + />, + ); + } else { + const messages = [entry.message]; + while (row.entries[index + 1]?.kind === "message") { + const next = row.entries[++index]!; + if (next.kind === "message") messages.push(next.message); + } + details.push( + , + ); + } + } + } + return ( +
+ + {row.expanded ?
{details}
: null} +
+ ); +} + function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. @@ -2411,28 +2517,35 @@ function ThinkingTimelineRow() { */ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ row, + disclosureAnchorKey = row.id, }: { row: Extract; + disclosureAnchorKey?: string; }) { const ctx = use(TimelineRowCtx); const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); const { message } = row; + const messages = row.reasoningMessages ?? [message]; // A block left open by a crashed provider or a restarted server never gets // its completion. Only the live turn may claim to still be thinking, so a // settled turn cannot shimmer "Thinking" at the user forever. const streaming = - Boolean(message.streaming) && + row.id === LIVE_ACTIVITY_ROW_ID && + messages.some((reasoningMessage) => reasoningMessage.streaming) && isWorking && message.turnId !== null && message.turnId === unsettledTurnId; const expanded = ctx.expandedReasoningMessageIds.has(message.id); const { onToggleReasoning } = ctx; const toggle = useCallback(() => { - onToggleReasoning(message.id, !expanded); - }, [expanded, message.id, onToggleReasoning]); - const label = streaming ? "Thinking" : "Thought"; + onToggleReasoning(message.id, !expanded, disclosureAnchorKey); + }, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]); + const label = `${streaming ? "Thinking" : "Thought"}${messages.length > 1 ? ` (×${messages.length})` : ""}`; - if (message.text.trim().length === 0 && !streaming) { + if ( + messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) && + !streaming + ) { return null; } @@ -2466,18 +2579,21 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ {expanded ? ( -
- +
+ {messages.map((reasoningMessage) => ( + + ))}
) : null}
@@ -2529,19 +2645,21 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders standalone activity or one bounded, virtualized expanded tool group. */ const WorkGroupSection = memo(function WorkGroupSection({ anchorKey, + disclosureAnchorKey = anchorKey, groupedEntries, isExpandedToolGroup, displayLabel, }: { anchorKey: string; + disclosureAnchorKey?: string; groupedEntries: Extract["groupedEntries"]; isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); const onToggleStandaloneEntry = useCallback( - (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), - [anchorKey, onToggleWorkEntry], + (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), + [disclosureAnchorKey, onToggleWorkEntry], ); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), @@ -2554,6 +2672,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ @@ -2580,10 +2699,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ function ExpandedWorkGroupEntries({ anchorKey, + disclosureAnchorKey, entries, workspaceRoot, }: { anchorKey: string; + disclosureAnchorKey: string; entries: TimelineWorkEntry[]; workspaceRoot: string | undefined; }) { @@ -2609,9 +2730,9 @@ function ExpandedWorkGroupEntries({ const groupView = useMemo( () => ({ state: viewState, - onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), + onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), }), - [anchorKey, onToggleWorkEntry, viewState], + [disclosureAnchorKey, onToggleWorkEntry, viewState], ); const updateScrollFades = useCallback(() => { const element = listRef.current?.getScrollableNode(); From fa47d53e7004ef0c656f4fca4758d1b5632545fa Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 16 Sep 2026 22:03:51 +0000 Subject: [PATCH 2/2] fix(chat): preserve activity boundaries and current status --- apps/mobile/src/lib/threadActivity.test.ts | 30 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 8 ++- .../chat/MessagesTimeline.logic.test.ts | 56 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 5 +- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 29fe3073da01..6533458da512 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2460,6 +2460,36 @@ describe("buildThreadFeed", () => { expanded: true, summary: "Thinking", }); + const strandedToolFeed = buildThreadFeed({ + ...thread, + messages: [], + activities: [ + makeActivity({ + id: EventId.make("stranded-tool"), + kind: "tool.updated", + tone: "tool", + summary: "Running command", + createdAt: "2026-04-01T00:00:00.000Z", + turnId, + payload: { + toolCallId: "stranded-tool", + itemType: "command_execution", + command: "sleep 60", + status: "inProgress", + }, + }), + ], + }); + const afterStrandedTool = deriveThreadFeedPresentation( + [...strandedToolFeed, ...feedWithWork], + thread.latestTurn, + new Set(), + new Set(), + "now", + ); + expect(afterStrandedTool).toMatchObject([ + { type: "work-toggle", id: "live-activity-row", summary: toolFirst.summary, hiddenCount: 6 }, + ]); }); it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ca22eb9c94b1..68ef02f48571 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1950,12 +1950,16 @@ function appendMixedActivityRun( const activities = history.flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + const trailingGroup = history.at(-1); + // A missing completion before the latest thought must not reclaim the live line. + const summaryActivities = + live && trailingGroup?.type === "activity-group" ? trailingGroup.activities : activities; const toolRows: ThreadFeedEntry[] = []; - if (activities.length > 0) { + if (summaryActivities.length > 0) { appendToolGroupRows( toolRows, { type: "activity-group", id: first.id, createdAt: first.createdAt, turnId, activities }, - activities, + summaryActivities, new Set(), unsettledTurnId, isWorking, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5d3ebe8a3b19..ac9009204bd2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2122,6 +2122,62 @@ describe("deriveMessagesTimelineRows", () => { }); }); + it("keeps the assistant footer before a trailing thought-only group", () => { + const answer = answerEntry("assistant-entry", "2026-01-01T00:00:01Z", "turn-1"); + const thought = reasoningEntry("reasoning-after", "2026-01-01T00:00:02Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [answer, thought], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["message", "activity-group"]); + expect(rows[0]).toMatchObject({ message: answer.message, showAssistantMeta: true }); + expect(rows[1]).toMatchObject({ entries: [thought] }); + }); + + it("shows each tool once across expanded activity histories separated by a failed tool", () => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const tools = ["a", "b", "c"].map((id, index) => { + const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1"); + return { + ...entry, + entry: { + ...entry.entry, + command: `echo ${id}`, + toolCallId: id, + toolLifecycleStatus: id === "b" ? ("failed" as const) : ("completed" as const), + sourceActivityKind: "tool.completed" as const, + }, + }; + }); + const input = { + timelineEntries: [thought, ...tools], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const rows = deriveMessagesTimelineRows(input); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedWorkGroupIds: new Set(rows.flatMap((row) => ("groupId" in row ? [row.groupId] : []))), + }); + const visibleTools = expanded.flatMap((row) => + row.kind === "activity-group" && row.expanded + ? row.entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry.id] : [])) + : row.kind === "work" + ? row.groupedEntries.map((entry) => entry.id) + : [], + ); + expect(visibleTools).toEqual(["a", "b", "c"]); + expect(expanded.filter((row) => row.id === "live-activity-row")).toMatchObject([ + { kind: "work-live", entry: { id: "c" } }, + ]); + }); + it("folds mixed activity under worked-for and restores ordered details when expanded", () => { const entries = [ reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 95201169665f..699e50fb1d8c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -844,7 +844,9 @@ function attachTrailingToolGroupsToAssistant( break; } if ( - (candidate.kind === "work-toggle" || candidate.kind === "activity-group") && + (candidate.kind === "work-toggle" || + (candidate.kind === "activity-group" && + candidate.entries.some((entry) => entry.kind === "work"))) && candidate.turnId === turnId ) { hasTrailingToolGroup = true; @@ -1023,6 +1025,7 @@ export function deriveMessagesTimelineRows(input: { break; } activeToolEntries.unshift(entry); + if (workEntryDisplayIndicatesToolFailure(entry.entry)) break; } const visibleActiveToolEntries = omitSupersededLifecycleMarkers( activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),