diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 86e7f3f7182ac..e3565821471e5 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -882,7 +882,7 @@ describe("buildThreadFeed", () => { }, ); - it("names the fold that hides a progress message, with or without a run", () => { + it("folds tool work around a mid-turn answer while keeping the answer visible", () => { const middle = { ...assistantMessage("2026-06-20T00:00:02.500Z"), id: TurnItemId.make("item-middle"), @@ -896,23 +896,27 @@ describe("buildThreadFeed", () => { projected(runless ? { ...item, runId: null } : item, position), ), ); - const ids = (expanded: ReadonlySet) => - deriveThreadFeedPresentation(feed, null, expanded).map((entry) => entry.id); - const foldRunId = messageFoldRunId( + const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); + const fold = collapsed.find((entry) => entry.type === "run-fold"); + expect(fold?.type === "run-fold" ? fold.runId : null, String(runless)).toBe( + runless ? "runless:message-user" : runId, + ); + expect(collapsed.map((entry) => entry.id)).toContain("message-middle"); + expect(collapsed.some((entry) => entry.type === "activity-group")).toBe(false); + const expanded = deriveThreadFeedPresentation( feed, - "message-middle", - deriveThreadFeedPresentation(feed, null, new Set()), + null, + new Set([fold?.type === "run-fold" ? fold.runId : runId]), ); - expect(foldRunId, String(runless)).toBe(runless ? "runless:message-user" : runId); - expect(ids(new Set())).not.toContain("message-middle"); - expect(ids(new Set([foldRunId!]))).toContain("message-middle"); + expect(expanded.some((entry) => entry.type === "activity-group")).toBe(true); + expect(expanded.map((entry) => entry.id)).toContain("message-middle"); } expect( messageFoldRunId(buildThreadFeed([projected(userMessage(), 0)]), "message-user", []), ).toBe(null); }); - it("folds progress messages while keeping the final assistant answer visible", () => { + it("keeps earlier assistant prose visible while folding tool work under Worked for", () => { const opening = { ...assistantMessage("2026-06-20T00:00:01.500Z"), id: TurnItemId.make("item-opening"), @@ -942,20 +946,22 @@ describe("buildThreadFeed", () => { const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); expect(collapsed.map((entry) => entry.id)).toEqual([ "message-user", + "message-opening", "run-fold:run-1", + "message-middle", "message-assistant", ]); - expect(collapsed[1]).toMatchObject({ + expect(collapsed[2]).toMatchObject({ type: "run-fold", - createdAt: "2026-06-20T00:00:01.500Z", + createdAt: "2026-06-20T00:00:02.000Z", label: "Worked for 2s", }); const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); expect(expanded.map((entry) => entry.type)).toEqual([ "message", - "run-fold", "message", + "run-fold", "activity-group", "message", "message", @@ -990,8 +996,8 @@ describe("buildThreadFeed", () => { ]); }); - // regression: #21393 show plans and fold settled progress notes - it("folds progress even when a response has no tool calls", () => { + // Assistant prose stays visible; with no tool work there is nothing to fold. + it("keeps every assistant message visible when a response has no tool calls", () => { const feed = buildThreadFeed([ projected(userMessage(), 0), projected( @@ -1009,7 +1015,7 @@ describe("buildThreadFeed", () => { const presented = deriveThreadFeedPresentation(feed, null, new Set()); expect(presented.map((entry) => entry.id)).toEqual([ "message-user", - "run-fold:run-1", + "message-opening", "message-assistant", ]); }); @@ -1042,7 +1048,14 @@ describe("buildThreadFeed", () => { deriveThreadFeedPresentation(feed, null, new Set()).map((entry) => entry.type === "message" ? entry.id : entry.type, ), - ).toEqual(["message-user", "run-fold", "message-steer", "run-fold", "message-assistant"]); + ).toEqual([ + "message-user", + "run-fold", + "message-steer", + "run-fold", + "message-progress", + "message-assistant", + ]); const foldRunId = messageFoldRunId( feed, "message-progress", @@ -1345,7 +1358,7 @@ describe("buildThreadFeed", () => { id: TurnItemId.make("new-prompt"), messageId: MessageId.make("new-prompt"), }), - ).toEqual(["user", "run-fold", "assistant"]); + ).toEqual(["user", "assistant", "run-fold"]); expect( presented({ ...base("wake", "2026-06-20T00:01:00.000Z", 4), @@ -1354,7 +1367,7 @@ describe("buildThreadFeed", () => { outcome: "completed", summary: "Background task finished", }), - ).toEqual(["user", "run-fold", "assistant"]); + ).toEqual(["user", "assistant", "run-fold"]); }); it("keeps a provider-native subagent's runless tool call live while it works", () => { @@ -1447,8 +1460,8 @@ describe("buildThreadFeed", () => { ); const turn = [user, step, passed, failed, final]; - expect(rows(turn, "hideSuccessful")).toEqual(["user", "fold", "step", "failed", "final"]); - expect(rows(turn, "hideAll")).toEqual(["user", "fold", "step", "final"]); + expect(rows(turn, "hideSuccessful")).toEqual(["user", "step", "fold", "failed", "final"]); + expect(rows(turn, "hideAll")).toEqual(["user", "step", "final"]); expect(rows(turn, "zen")).toEqual(["user", "final"]); const [steer, answer] = [say("steer", 2, true, true), say("answer", 3)]; expect(rows([user, steer, answer, final], "zen")).toEqual(["user", "steer", "answer", "final"]); @@ -1463,7 +1476,7 @@ describe("buildThreadFeed", () => { deriveThreadFeedPresentation(interrupted, null, new Set()).some( (row) => row.type === "message" && row.message.id === answer.messageId, ), - ).toBe(phase === "final_answer"); + ).toBe(true); expect(rows([user, partial, steer, final], "zen").includes("answer")).toBe( phase === "final_answer", ); @@ -1749,6 +1762,7 @@ describe("subagent continued by a later turn", () => { "answer-1", "prompt-2", `run-fold:${run2}`, + "answer-2", "closing-2", ]); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 7591b0060985b..c6d9e42880ccd 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1129,7 +1129,6 @@ function deriveThreadFeedRunFolds( feedFailedRunIds: ReadonlySet, ): ReadonlyMap { const terminalAssistantMessageIdByRun = new Map(); - const retainedAnswerIds = answersBeforeSteers(feed); const interruptedRunIds = new Set(); const failedRunIds = new Set(feedFailedRunIds); const promptIndexByRunId = new Map(); @@ -1228,8 +1227,8 @@ function deriveThreadFeedRunFolds( group.entries .filter( (entry) => - entry.id !== terminalAssistantId && - !(entry.type === "message" && retainedAnswerIds.has(entry.message.id)) && + // Every reply stays visible: a turn can answer, run tools, then close briefly. + entry.type !== "message" && entry.type !== "html-render" && entry.type !== "mcp-app" && entry.id !== visibleCompactionId && diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 49227eb76eadd..65deee8a0b295 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -157,6 +157,25 @@ function work(id: string, entry: Record): Entry { } as Entry; } +function fileReads(runId: RunId): Entry[] { + return Array.from({ length: 24 }, (_, index) => ({ + ...work(`work-${index}`, { + runId, + label: `Read file ${index}`, + tone: "tool", + itemType: "file_read", + }), + attempt: { + id: RunAttemptId.make(`attempt-${index}`), + runId, + attemptOrdinal: index + 1, + rootNodeId: NodeId.make(`root-${index}`), + status: "completed" as const, + completedAt: DateTime.makeUnsafe(CREATED_AT), + }, + })); +} + function v2Item(type: string, fields: Record) { const id = (fields.id as string | undefined) ?? type; return { @@ -308,7 +327,7 @@ describe("MessagesTimeline work rows", () => { ...Array.from({ length: 12 }, (_, index) => message(`Earlier context ${index}`, {}, `context-${index}`), ), - message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"), + ...fileReads(runId), message("Done", { role: "assistant", runId }, "final"), ]} />, @@ -327,9 +346,7 @@ describe("MessagesTimeline work rows", () => { expect(viewport.scrollTop).toBeGreaterThan(0); const top = heading.element().getBoundingClientRect().top; const height = viewport.scrollHeight; - using _timers = fakeTimers(); await heading.click(); - await act(() => vi.advanceTimersByTime(1000)); await vi.waitFor(() => { expect(viewport.scrollHeight).toBeGreaterThan(height + 500); expect(heading.element().getBoundingClientRect().top).toBeCloseTo(top, 0); @@ -349,7 +366,7 @@ describe("MessagesTimeline work rows", () => { ...Array.from({ length: 12 }, (_, index) => message(`Earlier context ${index}`, {}, `context-${index}`), ), - message("Progress details\n\n".repeat(50), { role: "assistant", runId }, "progress"), + ...fileReads(runId), message("Done", { role: "assistant", runId }, "final"), ]; await using screen = await render(, { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 09cbfe4e7775a..e8b676bdbc6f1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1097,7 +1097,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary); }); - it("folds the first assistant message and settled work before the terminal response", () => { + it("keeps earlier assistant prose visible while folding settled work before the terminal response", () => { const timelineEntries = [ { id: "user-entry", @@ -1173,6 +1173,7 @@ describe("deriveMessagesTimelineRows", () => { expect(collapsedRows.map((row) => row.id)).toEqual([ "user-entry", "turn-fold:turn-1", + "assistant-first-entry", "assistant-final-entry", ]); @@ -1545,11 +1546,13 @@ describe("deriveMessagesTimelineRows", () => { createdAt: time(second), entry: { id, createdAt: time(second), runId, label: "Ran command", tone: "tool" }, }); - const rows = (expandedRunIds: ReadonlySet) => + const rows = (expandedRunIds: ReadonlySet, proseBeforeSteer = false) => deriveMessagesTimelineRows({ timelineEntries: [ message("prompt", 0, "user", "turn_start"), - work("work-before-steer", 4), + proseBeforeSteer + ? message("answer-before-steer", 4, "assistant") + : work("work-before-steer", 4), message("steer", 10, "user", "steer"), work("work-after-steer", 15), message("final", 30, "assistant"), @@ -1581,6 +1584,13 @@ describe("deriveMessagesTimelineRows", () => { "work-after-steer", "final", ]); + expect(rows(new Set(), true).map((row) => row.id)).toEqual([ + "prompt", + "answer-before-steer", + "steer", + `turn-fold:${segmentKey}`, + "final", + ]); }); it("stacks back-to-back steers as one burst with a single steer label", () => { @@ -2422,25 +2432,43 @@ describe("deriveMessagesTimelineRows", () => { row.kind === "message" ? `${row.message.role}:${row.message.id}` : row.kind, ); - expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 4)).toEqual([ + expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 5)).toEqual([ "user:imported-prompt", "turn-fold", + "assistant:imported-update", "assistant:imported-answer", "user:new-prompt", ]); const withoutPrompt = rows([]); expect(withoutPrompt).toContain("turn-fold"); - expect(withoutPrompt).not.toContain("assistant:imported-update"); + expect(withoutPrompt).toContain("assistant:imported-update"); + expect(withoutPrompt).not.toContain("work"); const timelineEntries = [ message("imported-prompt", "user", 0), message("imported-update", "assistant", 4), + { + id: "imported-command", + kind: "work" as const, + createdAt: at(5), + entry: { + id: "imported-command", + createdAt: at(5), + runId: null, + label: "Ran git", + command: "git status", + requestKind: "command" as const, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, message("imported-answer", "assistant", 8), ]; const foldInput = { timelineEntries, latestRun: null, isWorking: false }; const foldRunIds = timelineTurnFoldRunIdsByEntryId(foldInput); - const foldRunId = foldRunIds.get("imported-update") ?? null; + const foldRunId = foldRunIds.get("imported-command") ?? null; expect(foldRunId).not.toBeNull(); + expect(foldRunIds.get("imported-update") ?? null).toBeNull(); const foldRow = deriveMessagesTimelineRows({ ...foldInput, turnDiffSummaries: [], @@ -2946,6 +2974,7 @@ describe("v2 run and attempt history", () => { expect(collapsedRows.map((row) => row.id)).toEqual([ "user-entry", "turn-fold:turn-1", + "assistant-thought-entry", "thread-created-entry", "assistant-final-entry", ]); @@ -3450,13 +3479,17 @@ describe("v2 run and attempt history", () => { for (const visibility of ["showAll", "zen"] as const) { const rows = rowsFor(completedSecond, runStatus, visibility, phase); expect(rows.some((row) => row.id === "old-reply-entry")).toBe(retained); + // Worked-for folds keep prose visible; only zen or a superseded attempt hides it. + const showsProse = visibility === "showAll"; if (phase !== undefined && runStatus === "completed") { const nativeRows = rowsFor(completedSecond, runStatus, visibility, phase, true); - expect(nativeRows.some((row) => row.id === "old-reply-entry")).toBe(retained); - expect(nativeRows.some((row) => row.id === "status-entry")).toBe(false); + expect(nativeRows.some((row) => row.id === "old-reply-entry")).toBe( + showsProse || retained, + ); + expect(nativeRows.some((row) => row.id === "status-entry")).toBe(showsProse); } if (runStatus === "completed") { - expect(rows.some((row) => row.id === "status-entry")).toBe(false); + expect(rows.some((row) => row.id === "status-entry")).toBe(showsProse && retained); } } } @@ -4045,6 +4078,21 @@ describe("linked timeline resources", () => { updatedAt: "2026-09-08T10:00:00Z", }, }, + { + kind: "work" as const, + id: "setup-command", + createdAt: "2026-09-08T10:00:01Z", + entry: { + id: "setup-command", + createdAt: "2026-09-08T10:00:01Z", + runId, + label: "Ran setup", + command: "pwd", + requestKind: "command" as const, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, event("created", "thread_created"), { kind: "message" as const, @@ -4064,6 +4112,7 @@ describe("linked timeline resources", () => { const collapsed = deriveMessagesTimelineRows({ ...common, timelineEntries }); expect(collapsed.map((row) => row.id)).toEqual([ "turn-fold:resource-run", + "intro", "final", "summary:created", "assistant-meta:final", @@ -4079,6 +4128,7 @@ describe("linked timeline resources", () => { expect(expanded.map((row) => row.id)).toEqual([ "turn-fold:resource-run", "intro", + "setup-command", "created", "final", "summary:created", @@ -4104,8 +4154,38 @@ describe("linked timeline resources", () => { }); const timelineEntries = [ message("intro", "assistant", 0), + { + kind: "work" as const, + id: "before-steer-work", + createdAt: "2026-09-08T10:00:00.500Z", + entry: { + id: "before-steer-work", + createdAt: "2026-09-08T10:00:00.500Z", + runId, + label: "Ran setup", + command: "pwd", + requestKind: "command" as const, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, message("steer", "user", 1), event("created", "thread_created"), + { + kind: "work" as const, + id: "after-steer-work", + createdAt: "2026-09-08T10:00:02Z", + entry: { + id: "after-steer-work", + createdAt: "2026-09-08T10:00:02Z", + runId, + label: "Ran check", + command: "ls", + requestKind: "command" as const, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + }, message("after-steer", "assistant", 3), message("final", "assistant", 4), ]; @@ -4119,9 +4199,11 @@ describe("linked timeline resources", () => { expect(rowIds([runId])).not.toContain("created"); expect(rowIds([segmentKey])).toEqual([ `turn-fold:${runId}`, + "intro", "steer", `turn-fold:${segmentKey}`, "created", + "after-steer-work", "after-steer", "final", "summary:created", @@ -4773,6 +4855,7 @@ describe("subagent continued by a later turn", () => { "answer-1", "prompt-2", `turn-fold:${run2}`, + "answer-2", "closing-2", ]); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c422656647863..ea06b1bd2e74b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1026,7 +1026,6 @@ function deriveTurnFolds(input: { unfoldedRunIds: ReadonlySet; runlessWorkActive: boolean; liveSubagentEntryIds: ReadonlySet; - retainedAssistantReplyEntryIds: ReadonlySet; }): ReadonlyMap { const interruptedRunIds = new Set(); const promptIndexByRunId = new Map(); @@ -1144,7 +1143,8 @@ function deriveTurnFolds(input: { // Folded dividers lose their place in the turn, so only the latest stays visible. const visibleCompactionId = group.entries.findLast(timelineEntryIsCompaction)?.id; for (const [index, entry] of group.entries.entries()) { - if (entry.id === group.terminalEntry?.id) { + // Every reply stays visible: a turn can answer, run tools, then close briefly. + if (entry.kind === "message") { continue; } const isCompaction = timelineEntryIsCompaction(entry); @@ -1158,8 +1158,7 @@ function deriveTurnFolds(input: { if ( entry.id === visibleCompactionId || timelineEntryIsPersistentResourceCard(entry) || - input.liveSubagentEntryIds.has(entry.id) || - input.retainedAssistantReplyEntryIds.has(entry.id) + input.liveSubagentEntryIds.has(entry.id) ) { continue; } @@ -1210,14 +1209,16 @@ function deriveTurnFolds(input: { const segmentHiddenIds = (entries: ReadonlyArray) => new Set(entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : []))); const firstSegment = group.entries.slice(0, group.steerSegments[0]?.startIndex); - foldsByAnchorEntryId.set(group.anchorEntryId, { - runId, - anchorEntryId: group.anchorEntryId, - createdAt: group.startBoundary ?? firstEntry.createdAt, - hiddenEntryIds: segmentHiddenIds(firstSegment), - entryIds: new Set(firstSegment.map((entry) => entry.id)), - label, - }); + if (hidesNonCompactionWork(firstSegment)) { + foldsByAnchorEntryId.set(group.anchorEntryId, { + runId, + anchorEntryId: group.anchorEntryId, + createdAt: group.startBoundary ?? firstEntry.createdAt, + hiddenEntryIds: segmentHiddenIds(firstSegment), + entryIds: new Set(firstSegment.map((entry) => entry.id)), + label, + }); + } for (const [index, { startIndex, steer }] of group.steerSegments.entries()) { const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex); const anchorEntry = entries[0]; @@ -1517,7 +1518,6 @@ function deriveTimelineTurnFolds( unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive: input.isWorking && input.runlessWorkActive === true, liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries), - retainedAssistantReplyEntryIds: retainedAssistantReplyEntryIds(timelineEntries), }); } @@ -1602,7 +1602,6 @@ export function deriveMessagesTimelineRows(input: { unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive, liveSubagentEntryIds, - retainedAssistantReplyEntryIds: retainedAssistantReplyIds, }); const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry])); const collapsedEntryIds = new Set();