diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f5a26ad9b021..5fbc686227d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1122,7 +1122,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("folds a settled subagent spawn row and keeps a live one outside the fold", () => { + it("keeps subagent spawn rows outside turn folds even after they settle", () => { const firstMessage: ChatMessage = { id: MessageId.make("assistant-first-entry"), role: "assistant", @@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => { const derive = ( timelineEntries: typeof direct, liveAgentTaskIds: ReadonlySet | undefined, - expandedSpawnEntryIds?: ReadonlySet, expandedTurnIds?: ReadonlySet, ) => deriveMessagesTimelineRows({ @@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, liveAgentTaskIds, - expandedSpawnEntryIds, ...(expandedTurnIds ? { expandedTurnIds } : {}), }).map((row) => row.id); - const folded = ["turn-fold:turn-1", "assistant-final-entry"]; const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"]; const activeRows = ( @@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => { } } - expect(derive(direct, new Set())).toEqual(folded); + expect(derive(direct, new Set())).toEqual(unfolded); expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded); // A workflow coordinator between phases keeps its batch out of the fold. expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded); - expect(derive(workflow, new Set())).toEqual(folded); + expect(derive(workflow, new Set())).toEqual(unfolded); // No live set is known. expect(derive(direct, undefined)).toEqual(unfolded); - // The user has it open: it stays visible under the collapsed fold and - // keeps its place when the fold is expanded. - expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded); - expect( - derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])), - ).toEqual([ + // Expanding the turn reveals the other work without duplicating the batch. + expect(derive(direct, new Set(), new Set(["turn-1" as TurnId]))).toEqual([ "turn-fold:turn-1", "assistant-first-entry", "spawn-entry", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4630aedec1d4..8dcb539fb024 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -577,7 +577,6 @@ function deriveTurnFolds(input: { terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unfoldedTurnIds: ReadonlySet; - liveAgentTaskIds: ReadonlySet | undefined; }): ReadonlyMap { interface TurnGroup { entries: Array; @@ -658,27 +657,13 @@ function deriveTurnFolds(input: { if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) { continue; } - // User input stays visible after the surrounding work settles. - if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { + // User input and subagent batches stay visible after their turn settles. + if ( + entry.kind === "work" && + (entry.entry.questionAnswer !== undefined || entry.entry.agentSpawn !== undefined) + ) { continue; } - // Workflows outlive their launching turn (dynamic spawns, background - // execution), so a spawn row with a live member or coordinator stays - // outside the fold instead of hiding a still-running fleet. Settled - // spawns fold with the rest of the turn. Without a live set (no agent - // panel model, as in the held paint during a thread switch) every - // spawn row stays out. - if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { - const live = input.liveAgentTaskIds; - const { workflowId, agentTaskIds } = entry.entry.agentSpawn; - if ( - live === undefined || - (workflowId !== null && live.has(workflowId)) || - agentTaskIds.some((taskId) => live.has(taskId)) - ) { - continue; - } - } hiddenEntryIds.add(entry.id); } if (hiddenEntryIds.size === 0) { @@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; - /** - * Task ids of subagents still working; their spawn row stays outside turn - * folds. Undefined means unknown, which keeps every spawn row out. - */ + /** Task ids of subagents still working, used by the active tool indicator. */ liveAgentTaskIds?: ReadonlySet | undefined; - /** Spawn rows the user opened stay visible while their turn fold is collapsed. */ - expandedSpawnEntryIds?: ReadonlySet | undefined; }): MessagesTimelineRow[] { const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { @@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unfoldedTurnIds: activeVisualResponseTurnIds, - liveAgentTaskIds: input.liveAgentTaskIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { if (!input.expandedTurnIds?.has(fold.turnId)) { for (const entryId of fold.hiddenEntryIds) { - // An opened spawn row keeps its fold membership but is not pulled - // away mid-read when its last member settles. - if (input.expandedSpawnEntryIds?.has(entryId)) continue; collapsedEntryIds.add(entryId); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9994f032fa3a..d17c03ed3277 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -465,8 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); - // Expanded spawn rows outlive virtualization and stay visible while their - // turn fold is collapsed, so a settling fleet is not pulled away mid-read. + // Preserve member disclosure state across virtualization. const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState>( new Set(), ); @@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot: string | undefined; projection: MessagesTimelineRowsProjection; } | null>(null); - // Subagents still working keep their spawn row outside the turn fold. Same - // liveness rule as the row header (deriveAgentSpawnSummary): members while - // active, workflow coordinators until terminal. Keyed by content so the - // projection input keeps its identity across unrelated panel updates. + // Match the row header's liveness, retaining projection input identity + // across unrelated panel updates. const liveAgentTaskKey = useMemo(() => { if (agentPanelModel === undefined) return undefined; const ids: string[] = []; @@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, - expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, }, previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, supportsConversationRollback, liveAgentTaskIds, - paintedExpandedSpawnEntryIds, ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);