authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 19:54:23-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 19:54:23-03:00
logaf2baccd100604f9885d6af97a5fa99622dd1c4f
tree87c2083bf045fafe642560f9cf3b59a1bcf4d15a
parentc0ddfb3a81abe553fea1e43c79cb723118959449
signature Signed by PGP key B5690EEEBB952194

fix(web): keep subagent rows visible under folded turns (#11474)


3 files changed, 14 insertions(+), 50 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+5-12
......@@ -1122,7 +1122,7 @@ describe("deriveMessagesTimelineRows", () => {
11221122 ]);
11231123 });
11241124
1125 it("folds a settled subagent spawn row and keeps a live one outside the fold", () => {
1125 it("keeps subagent spawn rows outside turn folds even after they settle", () => {
11261126 const firstMessage: ChatMessage = {
11271127 id: MessageId.make("assistant-first-entry"),
11281128 role: "assistant",
......@@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => {
11611161 const derive = (
11621162 timelineEntries: typeof direct,
11631163 liveAgentTaskIds: ReadonlySet<string> | undefined,
1164 expandedSpawnEntryIds?: ReadonlySet<string>,
11651164 expandedTurnIds?: ReadonlySet<TurnId>,
11661165 ) =>
11671166 deriveMessagesTimelineRows({
......@@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => {
11711170 turnDiffSummaries: [],
11721171 supportsConversationRollback: false,
11731172 liveAgentTaskIds,
1174 expandedSpawnEntryIds,
11751173 ...(expandedTurnIds ? { expandedTurnIds } : {}),
11761174 }).map((row) => row.id);
1177 const folded = ["turn-fold:turn-1", "assistant-final-entry"];
11781175 const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"];
11791176
11801177 const activeRows = (
......@@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => {
12371234 }
12381235 }
12391236
1240 expect(derive(direct, new Set())).toEqual(folded);
1237 expect(derive(direct, new Set())).toEqual(unfolded);
12411238 expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded);
12421239 // A workflow coordinator between phases keeps its batch out of the fold.
12431240 expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded);
1244 expect(derive(workflow, new Set())).toEqual(folded);
1241 expect(derive(workflow, new Set())).toEqual(unfolded);
12451242 // No live set is known.
12461243 expect(derive(direct, undefined)).toEqual(unfolded);
1247 // The user has it open: it stays visible under the collapsed fold and
1248 // keeps its place when the fold is expanded.
1249 expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded);
1250 expect(
1251 derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])),
1252 ).toEqual([
1244 // Expanding the turn reveals the other work without duplicating the batch.
1245 expect(derive(direct, new Set(), new Set(["turn-1" as TurnId]))).toEqual([
12531246 "turn-fold:turn-1",
12541247 "assistant-first-entry",
12551248 "spawn-entry",
apps/web/src/components/chat/MessagesTimeline.logic.ts+6-30
......@@ -577,7 +577,6 @@ function deriveTurnFolds(input: {
577577 terminalAssistantMessageIds: ReadonlySet<string>;
578578 latestTurn: TimelineLatestTurn | null;
579579 unfoldedTurnIds: ReadonlySet<TurnId>;
580 liveAgentTaskIds: ReadonlySet<string> | undefined;
581580}): ReadonlyMap<string, TurnFold> {
582581 interface TurnGroup {
583582 entries: Array<TimelineEntry>;
......@@ -658,27 +657,13 @@ function deriveTurnFolds(input: {
658657 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {
659658 continue;
660659 }
661 // User input stays visible after the surrounding work settles.
662 if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
660 // User input and subagent batches stay visible after their turn settles.
661 if (
662 entry.kind === "work" &&
663 (entry.entry.questionAnswer !== undefined || entry.entry.agentSpawn !== undefined)
664 ) {
663665 continue;
664666 }
665 // Workflows outlive their launching turn (dynamic spawns, background
666 // execution), so a spawn row with a live member or coordinator stays
667 // outside the fold instead of hiding a still-running fleet. Settled
668 // spawns fold with the rest of the turn. Without a live set (no agent
669 // panel model, as in the held paint during a thread switch) every
670 // spawn row stays out.
671 if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
672 const live = input.liveAgentTaskIds;
673 const { workflowId, agentTaskIds } = entry.entry.agentSpawn;
674 if (
675 live === undefined ||
676 (workflowId !== null && live.has(workflowId)) ||
677 agentTaskIds.some((taskId) => live.has(taskId))
678 ) {
679 continue;
680 }
681 }
682667 hiddenEntryIds.add(entry.id);
683668 }
684669 if (hiddenEntryIds.size === 0) {
......@@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: {
870855 activeTurnStartedAt: string | null;
871856 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
872857 supportsConversationRollback: boolean;
873 /**
874 * Task ids of subagents still working; their spawn row stays outside turn
875 * folds. Undefined means unknown, which keeps every spawn row out.
876 */
858 /** Task ids of subagents still working, used by the active tool indicator. */
877859 liveAgentTaskIds?: ReadonlySet<string> | undefined;
878 /** Spawn rows the user opened stay visible while their turn fold is collapsed. */
879 expandedSpawnEntryIds?: ReadonlySet<string> | undefined;
880860}): MessagesTimelineRow[] {
881861 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
882862 for (const summary of input.turnDiffSummaries) {
......@@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: {
911891 terminalAssistantMessageIds,
912892 latestTurn: input.latestTurn ?? null,
913893 unfoldedTurnIds: activeVisualResponseTurnIds,
914 liveAgentTaskIds: input.liveAgentTaskIds,
915894 });
916895 const collapsedEntryIds = new Set<string>();
917896 for (const fold of foldsByAnchorEntryId.values()) {
918897 if (!input.expandedTurnIds?.has(fold.turnId)) {
919898 for (const entryId of fold.hiddenEntryIds) {
920 // An opened spawn row keeps its fold membership but is not pulled
921 // away mid-read when its last member settles.
922 if (input.expandedSpawnEntryIds?.has(entryId)) continue;
923899 collapsedEntryIds.add(entryId);
924900 }
925901 }
apps/web/src/components/chat/MessagesTimeline.tsx+3-8
......@@ -465,8 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
465465}: MessagesTimelineProps) {
466466 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
467467 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
468 // Expanded spawn rows outlive virtualization and stay visible while their
469 // turn fold is collapsed, so a settling fleet is not pulled away mid-read.
468 // Preserve member disclosure state across virtualization.
470469 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
471470 new Set(),
472471 );
......@@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
628627 workspaceRoot: string | undefined;
629628 projection: MessagesTimelineRowsProjection;
630629 } | null>(null);
631 // Subagents still working keep their spawn row outside the turn fold. Same
632 // liveness rule as the row header (deriveAgentSpawnSummary): members while
633 // active, workflow coordinators until terminal. Keyed by content so the
634 // projection input keeps its identity across unrelated panel updates.
630 // Match the row header's liveness, retaining projection input identity
631 // across unrelated panel updates.
635632 const liveAgentTaskKey = useMemo(() => {
636633 if (agentPanelModel === undefined) return undefined;
637634 const ids: string[] = [];
......@@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
667664 turnDiffSummaries,
668665 supportsConversationRollback,
669666 liveAgentTaskIds,
670 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
671667 },
672668 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
673669 ? previous.projection
......@@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
689685 turnDiffSummaries,
690686 supportsConversationRollback,
691687 liveAgentTaskIds,
692 paintedExpandedSpawnEntryIds,
693688 ]);
694689 const rows = useStableRows(rawRows, listIdentityKey);
695690 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);