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.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ac9009204bd2..b2b46302dd25 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] : []))), - }); - 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.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 8a82168c20dc..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; @@ -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 117e2d51c276..483859ba0300 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}