From a3d37c1b51cff51f970a8f322efa924b2838b458 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 17 Sep 2026 16:39:20 +0000 Subject: [PATCH 1/4] fix(web): keep thoughts and failed tool calls in one activity row --- .../chat/MessagesTimeline.logic.test.ts | 61 +++++++++---- .../components/chat/MessagesTimeline.logic.ts | 6 +- .../src/components/chat/MessagesTimeline.tsx | 88 ++++++++++++++++--- 3 files changed, 123 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ac9009204bd2..c205a2df9c79 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2137,7 +2137,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows[1]).toMatchObject({ entries: [thought] }); }); - it("shows each tool once across expanded activity histories separated by a failed tool", () => { + it("keeps thoughts and tools in one activity row across 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"); @@ -2161,21 +2161,52 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, } satisfies Parameters[0]; const rows = deriveMessagesTimelineRows(input); - const expanded = deriveMessagesTimelineRows({ + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); + expect(rows.at(-1)).toMatchObject({ + id: "live-activity-row", + entries: [thought, ...tools], + active: true, + }); + const settled = 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" } }, - ]); + timelineEntries: [ + thought, + ...tools, + reasoningEntry("reasoning-next", "2026-01-01T00:00:05Z", "turn-1"), + { ...tools[1]!, id: "d", entry: { ...tools[1]!.entry, id: "d", toolCallId: "d" } }, + ], + isWorking: false, + activeTurnStartedAt: null, + }); + expect(settled.map((row) => row.kind)).toEqual(["activity-group"]); + }); + + it("settles the activity row while the latest tool has failed", () => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const failedTool = toolEntry("failed-tool", "2026-01-01T00:00:02Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + thought, + { + ...failedTool, + entry: { + ...failedTool.entry, + command: "echo nope", + toolCallId: "failed-tool", + toolLifecycleStatus: "failed" as const, + sourceActivityKind: "tool.completed" as const, + }, + }, + ], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]); + expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false }); + expect(rows[2]).toMatchObject({ id: "live-activity-row" }); }); it("folds mixed activity under worked-for and restores ordered details when expanded", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8a82168c20dc..ec518ce28d11 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -323,8 +323,7 @@ function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { entry.entry.agentSpawn === undefined && entry.entry.questionAnswer === undefined && entry.entry.sourceActivityKind !== "context-compaction" && - entry.entry.tone !== "error" && - !workEntryDisplayIndicatesToolFailure(entry.entry); + entry.entry.tone !== "error"; } export type MessagesTimelineRow = @@ -1158,7 +1157,8 @@ export function deriveMessagesTimelineRows(input: { const active = input.isWorking && activityTurnId === unsettledTurnId && - cursor === input.timelineEntries.length; + cursor === input.timelineEntries.length && + !latestToolFailed; const groupId = timelineEntry.kind === "work" ? workGroupId(timelineEntry.id, timelineEntry.entry) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 117e2d51c276..caa6eeaee762 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2606,6 +2606,7 @@ function ActivityGroupTimelineRow({ const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1); const thinking = row.active && liveWork === undefined; const iconWork = row.active ? liveWork : work.at(-1); + const failed = iconWork !== undefined && workEntryDisplayIndicatesToolFailure(iconWork); const label = row.active ? liveWork ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true) @@ -2639,20 +2640,11 @@ function ActivityGroupTimelineRow({ if (next.kind === "message") messages.push(next.message); } details.push( - 0} />, ); } @@ -2663,6 +2655,7 @@ function ActivityGroupTimelineRow({ {expanded ? ( -
+
{messages.map((reasoningMessage) => ( Date: Thu, 17 Sep 2026 16:49:52 +0000 Subject: [PATCH 2/4] fix(web): settle the activity row on declined tools and trim the reasoning row --- .../chat/MessagesTimeline.logic.test.ts | 55 +++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 4 +- .../src/components/chat/MessagesTimeline.tsx | 60 ++++++------------- 3 files changed, 48 insertions(+), 71 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c205a2df9c79..b2b46302dd25 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2181,33 +2181,36 @@ describe("deriveMessagesTimelineRows", () => { expect(settled.map((row) => row.kind)).toEqual(["activity-group"]); }); - it("settles the activity row while the latest tool has failed", () => { - const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); - const failedTool = toolEntry("failed-tool", "2026-01-01T00:00:02Z", "turn-1"); - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - thought, - { - ...failedTool, - entry: { - ...failedTool.entry, - command: "echo nope", - toolCallId: "failed-tool", - toolLifecycleStatus: "failed" as const, - sourceActivityKind: "tool.completed" as const, + it.each(["failed", "declined"] as const)( + "settles the activity row while the latest tool is %s", + (status) => { + const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + const tool = toolEntry("last-tool", "2026-01-01T00:00:02Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + thought, + { + ...tool, + entry: { + ...tool.entry, + command: "echo nope", + toolCallId: "last-tool", + toolLifecycleStatus: status, + sourceActivityKind: "tool.completed" as const, + }, }, - }, - ], - runningTurnId: TurnId.make("turn-1"), - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaries: [], - supportsConversationRollback: false, - }); - expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]); - expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false }); - expect(rows[2]).toMatchObject({ id: "live-activity-row" }); - }); + ], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]); + expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false }); + expect(rows[2]).toMatchObject({ id: "live-activity-row" }); + }, + ); it("folds mixed activity under worked-for and restores ordered details when expanded", () => { const entries = [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ec518ce28d11..0ef3251654d8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -390,7 +390,6 @@ export type MessagesTimelineRow = createdAt: string; message: ChatMessage; durationStart: string; - reasoningMessages?: ReadonlyArray; showAssistantMeta: boolean; showAssistantCopyButton: boolean; assistantCopyStreaming: boolean; @@ -1158,7 +1157,8 @@ export function deriveMessagesTimelineRows(input: { input.isWorking && activityTurnId === unsettledTurnId && cursor === input.timelineEntries.length && - !latestToolFailed; + !latestToolFailed && + (latestVisibleToolEntry === undefined || latestToolKeepsActivityLive); const groupId = timelineEntry.kind === "work" ? workGroupId(timelineEntry.id, timelineEntry.entry) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index caa6eeaee762..7145923acc5f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -171,7 +171,6 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis import { computeStableMessagesTimelineRows, deriveMessagesTimelineRowsWithState, - LIVE_ACTIVITY_ROW_ID, deriveUnsettledTurnId, type MessagesTimelineRowsProjection, liveWorkEntryLabel, @@ -2644,7 +2643,7 @@ function ActivityGroupTimelineRow({ key={entry.id} messages={messages} live={row.active && index === row.entries.length - 1} - showHeader={work.length > 0} + showHeader={row.entries.some((entry) => entry.kind === "work")} />, ); } @@ -2731,7 +2730,7 @@ function ReasoningTraceBlock({
) : null} -
+
{messages.map((reasoningMessage) => ( ; - 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 = - 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, disclosureAnchorKey); - }, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]); - const label = `${streaming ? "Thinking" : "Thought"}${messages.length > 1 ? ` (×${messages.length})` : ""}`; + onToggleReasoning(message.id, !expanded, row.id); + }, [expanded, message.id, row.id, onToggleReasoning]); - if ( - messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) && - !streaming - ) { + if (message.text.trim().length === 0) { return null; } @@ -2802,12 +2784,8 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ - - {label} - {streaming ? {label} : null} + + Thought {expanded ? (
- {messages.map((reasoningMessage) => ( - - ))} +
) : null}
From 51abd6f845b040fa4c9f5c048547d7c7df38a06e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 17 Sep 2026 16:56:27 +0000 Subject: [PATCH 3/4] fix(mobile): keep thoughts and failed tool calls in one activity run --- apps/mobile/src/lib/threadActivity.test.ts | 8 +++++--- apps/mobile/src/lib/threadActivity.ts | 5 +---- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index acda68455420..5d256f7c6206 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2513,7 +2513,7 @@ describe("buildThreadFeed", () => { }); it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)( - "preserves a %s boundary in expanded activity history", + "keeps thoughts in order across a %s in expanded activity history", (boundary) => { const turnId = TurnId.make("reasoning-boundary"); const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({ @@ -2576,8 +2576,10 @@ describe("buildThreadFeed", () => { (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); + // A failed call stays inside the run instead of splitting it. + expect(rows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { hasFailure: true, hiddenCount: 3 }, + ]); } expect(reasoningRows).toEqual( messages.map((message) => ({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a32843ce3f73..887be4a9272d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1901,10 +1901,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null { !isContextCompactionActivityGroup(entry) && !isUserInputActivityGroup(entry) && entry.activities.every( - (activity) => - !activity.workEntry.agentSpawn && - activity.workEntry.tone !== "error" && - !workEntryIndicatesToolFailure(activity.workEntry), + (activity) => !activity.workEntry.agentSpawn && activity.workEntry.tone !== "error", ) ) { return entry.turnId; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7145923acc5f..7b47593574a7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2643,7 +2643,7 @@ function ActivityGroupTimelineRow({ key={entry.id} messages={messages} live={row.active && index === row.entries.length - 1} - showHeader={row.entries.some((entry) => entry.kind === "work")} + showHeader={work.length > 0} />, ); } @@ -2687,7 +2687,7 @@ function ThinkingTimelineRow() { /** * Thinking inside an expanded activity group: the trace is already one click * deep, so the text renders under its "Thought" header without another toggle. - * A thought-only group already reads "Thought" on its row, so it skips the header. + * A group whose row already reads "Thought" (no visible tool) skips the header. */ function ReasoningTraceBlock({ messages, From 1989201399baf84c1488ede2b652b26c334fa8b9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 17 Sep 2026 17:04:31 +0000 Subject: [PATCH 4/4] fix(web): render thought text in the foreground color and unexport the live row id --- apps/web/src/components/chat/MessagesTimeline.logic.ts | 2 +- apps/web/src/components/chat/MessagesTimeline.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0ef3251654d8..57ed45a89d1d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -312,7 +312,7 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; -export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; type ActivityEntry = Extract; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7b47593574a7..483859ba0300 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2730,10 +2730,11 @@ function ReasoningTraceBlock({
) : null} -
+
{messages.map((reasoningMessage) => ( {expanded ? ( -
+