From fbc8b960026e408c163ea55db8075bbf1a42d628 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 17 Sep 2026 14:15:48 -0300 Subject: [PATCH] fix(web): keep thoughts and failed tool calls in one activity row (#12270) --- apps/mobile/src/lib/threadActivity.test.ts | 8 +- apps/mobile/src/lib/threadActivity.ts | 5 +- .../chat/MessagesTimeline.logic.test.ts | 62 ++++++-- .../components/chat/MessagesTimeline.logic.ts | 10 +- .../src/components/chat/MessagesTimeline.tsx | 146 +++++++++++------- 5 files changed, 150 insertions(+), 81 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index acda684554209cfcb57fc018cca3fce787a278a1..5d256f7c6206b089aac3e8b9797d804ba5291fae 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 a32843ce3f739900122bb62bc00aeb878e25caa9..887be4a9272dd90d07c57fea4fb523adf4f38909 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.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ac9009204bd29278f1448692b0c2dbb742d5e070..b2b46302dd25d9793aac34247d70d2f375bf53c1 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,23 +2161,57 @@ 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] : []))), + 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, }); - 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" } }, - ]); + expect(settled.map((row) => row.kind)).toEqual(["activity-group"]); }); + 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" }); + }, + ); + 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 8a82168c20dc2cd51f300fd28c085e413d9cbe12..57ed45a89d1d361afdb6d0bf51c28f8ddaf80a63 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; @@ -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 = @@ -391,7 +390,6 @@ export type MessagesTimelineRow = createdAt: string; message: ChatMessage; durationStart: string; - reasoningMessages?: ReadonlyArray; showAssistantMeta: boolean; showAssistantCopyButton: boolean; assistantCopyStreaming: boolean; @@ -1158,7 +1156,9 @@ export function deriveMessagesTimelineRows(input: { const active = input.isWorking && activityTurnId === unsettledTurnId && - cursor === input.timelineEntries.length; + cursor === input.timelineEntries.length && + !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 117e2d51c276ffa98b49aeb23a5416ea7f06e0d7..483859ba03005fb62c090ff93b2ac307ed1fa5be 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, @@ -2606,6 +2605,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 +2639,11 @@ function ActivityGroupTimelineRow({ if (next.kind === "message") messages.push(next.message); } details.push( - 0} />, ); } @@ -2663,6 +2654,7 @@ function ActivityGroupTimelineRow({ {expanded ? ( -
- {messages.map((reasoningMessage) => ( - - ))} +
+
) : null}
-- 2.54.0