diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4f0c140ac229..16fd407f537b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => { ]); }); + it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { + const imported = (item: T, id: string) => ({ + ...item, + id: TurnItemId.make(id), + runId: null, + }); + const presented = (start: OrchestrationV2TurnItem) => + deriveThreadFeedPresentation( + buildThreadFeed( + [ + imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"), + imported( + { + ...assistantMessage("2026-06-20T00:00:02.000Z"), + messageId: MessageId.make("update"), + }, + "imported-update", + ), + imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"), + imported( + { + ...assistantMessage("2026-06-20T00:00:08.000Z"), + messageId: MessageId.make("answer"), + }, + "imported-answer", + ), + start, + ].map((item, position) => projected(item, position)), + ), + { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null }, + new Set(), + new Set(), + "2026-06-20T00:01:00.000Z", + ) + .slice(0, 4) + .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); + + // A sent prompt and an automatic wake both start V2 work below the import. + expect( + presented({ + ...userMessage("2026-06-20T00:01:00.000Z"), + id: TurnItemId.make("new-prompt"), + messageId: MessageId.make("new-prompt"), + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + expect( + presented({ + ...base("wake", "2026-06-20T00:01:00.000Z", 4), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + }); + it("keeps a provider-native subagent's runless tool call live while it works", () => { const startedAt = "2026-06-20T00:00:01.000Z"; const { exitCode: _exitCode, ...completedCommand } = command(); @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => { expect(presented.some((entry) => entry.type === "thinking")).toBe(false); }); - it("keeps a runless tail settled while a normal thread waits for its sent run", () => { + it("keeps a runless tail folded while a normal thread waits for its sent run", () => { // Right after a send the local clock runs before the server creates the - // run, and the latest run may still be queued: neither is runless work. + // run, and the latest run may still be queued: neither is runless work, + // so the settled tail must not reopen and shift the feed. const startedAt = "2026-06-20T00:00:05.000Z"; const feed = buildThreadFeed([ projected({ ...userMessage(), runId: null }, 0), @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => { new Set(), startedAt, ); - const toggle = presented.find((entry) => entry.type === "work-toggle"); - expect(toggle).toMatchObject({ live: false, shimmer: false }); - expect(presented.at(-1)?.type).toBe("thinking"); + expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); } }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 259b76c9b173..3a77324a7ac8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -972,13 +972,14 @@ export function failedFeedRunIds( } /** - * A thread without runs (a provider-native subagent) folds each prompt's - * response like a run; `isWorking` keeps its latest response open. + * A prompt without a run (a provider-native subagent, or a turn imported from + * V1) folds its response like a run. `runlessWorkActive` keeps the latest + * runless response open; V2 work must not reopen imported turns. */ function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, - isWorking: boolean, + runlessWorkActive: boolean, ): ReadonlyMap { const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds( RunId, { entries: ThreadFeedEntry[]; startBoundary: string | null } >(); - // Fold state is keyed by run, so each prompt of a runless thread lends its - // response a stable key of its own. + // Fold state is keyed by run, so each runless prompt lends its response a + // stable key of its own. Decide per prompt, not per thread: a V1 thread's + // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; - runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null; + runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds( for (const [runId, group] of groupsByRunId) { if ( runId === activeRunId || - (isWorking && runId === runlessKey) || + (runlessWorkActive && runId === runlessKey) || interruptedRunIds.has(runId) || failedRunIds.has(runId) || group.entries.some((entry) => entry.type === "message" && entry.message.streaming) @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation( const activeTailGroup = sourceFeed.at(-1); const activeRunId = unsettledRunId(latestRun); const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; - const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking); + const foldsByAnchorId = deriveThreadFeedRunFolds( + sourceFeed, + latestRun, + isWorking && runlessWorkActive, + ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedRunIds.has(fold.runId)) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7c2fb5bd8893..f87cb75b34b8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2230,6 +2230,76 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { + const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`; + const message = ( + id: string, + role: "user" | "assistant", + second: number, + runId: string | null = null, + ) => ({ + id, + kind: "message" as const, + createdAt: at(second), + message: { + id: id as never, + role, + text: id, + runId: runId as never, + createdAt: at(second), + updatedAt: at(second), + streaming: false, + }, + }); + const rows = (tail: ReadonlyArray>) => + deriveMessagesTimelineRows({ + timelineEntries: [ + message("imported-prompt", "user", 0), + message("imported-update", "assistant", 4), + { + id: "imported-command", + kind: "work", + createdAt: at(5), + entry: { + id: "imported-command", + createdAt: at(5), + runId: null, + label: "Ran git", + command: "git status", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, + message("imported-answer", "assistant", 8), + ...tail, + ], + latestRun: { + runId: "run-1" as never, + status: "running", + startedAt: at(20), + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: at(20), + turnDiffSummaries: [], + supportsConversationRollback: false, + }).map((row) => + row.kind === "message" ? `${row.message.role}:${row.message.id}` : row.kind, + ); + + // V2 work starts from a sent prompt, or with no new prompt (a wake or a resume). + expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 4)).toEqual([ + "user:imported-prompt", + "turn-fold", + "assistant:imported-answer", + "user:new-prompt", + ]); + const withoutPrompt = rows([]); + expect(withoutPrompt).toContain("turn-fold"); + expect(withoutPrompt).not.toContain("assistant:imported-update"); + }); + it("shows a provider-native subagent's runless tools as live work while it works", () => { const entries = (commandStatus: "inProgress" | "completed") => [ { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 88da32d28fc3..09784aaf9dbd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -850,15 +850,17 @@ function failedTimelineRunIds( /** * Settled turns fold activity before their terminal assistant message behind * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures - * and work still in progress stay visible. A thread without runs (a - * provider-native subagent) folds each prompt's response the same way. + * and work still in progress stay visible. A prompt without a run (a + * provider-native subagent, or a turn imported from V1) folds its response + * the same way. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestRun: TimelineLatestRun | null; unfoldedRunIds: ReadonlySet; - isWorking: boolean; + /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ + runlessWorkActive: boolean; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -888,8 +890,9 @@ function deriveTurnFolds(input: { const groupsByRunId = new Map(); const runlessFailedKeys = new Set(); - // Fold state is keyed by run, so each prompt of a runless thread lends its - // response a stable key of its own. + // Fold state is keyed by run, so each runless prompt lends its response a + // stable key of its own. Decide per prompt, not per thread: a V1 thread's + // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null; for (const [index, entry] of input.timelineEntries.entries()) { @@ -898,7 +901,13 @@ function deriveTurnFolds(input: { pendingBoundary = nextEntry ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id } : null; - runlessKey = input.latestRun === null ? RunId.make(`runless:${entry.id}`) : null; + const boundaryRunId = + entry.kind === "message" + ? entry.message.runId + : entry.kind === "work" + ? entry.entry.runId + : null; + runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = timelineEntryFoldRunId(entry, runlessKey); @@ -940,7 +949,7 @@ function deriveTurnFolds(input: { input.unfoldedRunIds.has(runId) || interruptedRunIds.has(runId) || runlessFailedKeys.has(runId) || - (input.isWorking && runId === runlessKey) + (input.runlessWorkActive && runId === runlessKey) ) { continue; } @@ -1229,12 +1238,13 @@ export function deriveMessagesTimelineRows(input: { unsettledRunId, isWorking: input.isWorking, }); + const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; const foldsByAnchorEntryId = deriveTurnFolds({ timelineEntries: timelineEntries, terminalAssistantMessageIds, latestRun: input.latestRun ?? null, unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), - isWorking: input.isWorking, + runlessWorkActive, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1252,7 +1262,6 @@ export function deriveMessagesTimelineRows(input: { } } } - const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; const runIdIsActiveResponse = (runId: RunId | null | undefined) => runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); const workEntryIsInActiveRun = (entry: WorkLogEntry) =>