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", () => {...@@ -1122,7 +1122,7 @@ describe("deriveMessagesTimelineRows", () => {
1122 ]);1122 ]);
1123 });1123 });
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", () => {
1126 const firstMessage: ChatMessage = {1126 const firstMessage: ChatMessage = {
1127 id: MessageId.make("assistant-first-entry"),1127 id: MessageId.make("assistant-first-entry"),
1128 role: "assistant",1128 role: "assistant",
...@@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => {
1161 const derive = (1161 const derive = (
1162 timelineEntries: typeof direct,1162 timelineEntries: typeof direct,
1163 liveAgentTaskIds: ReadonlySet<string> | undefined,1163 liveAgentTaskIds: ReadonlySet<string> | undefined,
1164 expandedSpawnEntryIds?: ReadonlySet<string>,
1165 expandedTurnIds?: ReadonlySet<TurnId>,1164 expandedTurnIds?: ReadonlySet<TurnId>,
1166 ) =>1165 ) =>
1167 deriveMessagesTimelineRows({1166 deriveMessagesTimelineRows({
...@@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => {
1171 turnDiffSummaries: [],1170 turnDiffSummaries: [],
1172 supportsConversationRollback: false,1171 supportsConversationRollback: false,
1173 liveAgentTaskIds,1172 liveAgentTaskIds,
1174 expandedSpawnEntryIds,
1175 ...(expandedTurnIds ? { expandedTurnIds } : {}),1173 ...(expandedTurnIds ? { expandedTurnIds } : {}),
1176 }).map((row) => row.id);1174 }).map((row) => row.id);
1177 const folded = ["turn-fold:turn-1", "assistant-final-entry"];
1178 const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"];1175 const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"];
11791176
1180 const activeRows = (1177 const activeRows = (
...@@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => {
1237 }1234 }
1238 }1235 }
12391236
1240 expect(derive(direct, new Set())).toEqual(folded);1237 expect(derive(direct, new Set())).toEqual(unfolded);
1241 expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded);1238 expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded);
1242 // A workflow coordinator between phases keeps its batch out of the fold.1239 // A workflow coordinator between phases keeps its batch out of the fold.
1243 expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded);1240 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);
1245 // No live set is known.1242 // No live set is known.
1246 expect(derive(direct, undefined)).toEqual(unfolded);1243 expect(derive(direct, undefined)).toEqual(unfolded);
1247 // The user has it open: it stays visible under the collapsed fold and1244 // Expanding the turn reveals the other work without duplicating the batch.
1248 // keeps its place when the fold is expanded.1245 expect(derive(direct, new Set(), new Set(["turn-1" as TurnId]))).toEqual([
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([
1253 "turn-fold:turn-1",1246 "turn-fold:turn-1",
1254 "assistant-first-entry",1247 "assistant-first-entry",
1255 "spawn-entry",1248 "spawn-entry",
apps/web/src/components/chat/MessagesTimeline.logic.ts+6-30
...@@ -577,7 +577,6 @@ function deriveTurnFolds(input: {...@@ -577,7 +577,6 @@ function deriveTurnFolds(input: {
577 terminalAssistantMessageIds: ReadonlySet<string>;577 terminalAssistantMessageIds: ReadonlySet<string>;
578 latestTurn: TimelineLatestTurn | null;578 latestTurn: TimelineLatestTurn | null;
579 unfoldedTurnIds: ReadonlySet<TurnId>;579 unfoldedTurnIds: ReadonlySet<TurnId>;
580 liveAgentTaskIds: ReadonlySet<string> | undefined;
581}): ReadonlyMap<string, TurnFold> {580}): ReadonlyMap<string, TurnFold> {
582 interface TurnGroup {581 interface TurnGroup {
583 entries: Array<TimelineEntry>;582 entries: Array<TimelineEntry>;
...@@ -658,27 +657,13 @@ function deriveTurnFolds(input: {...@@ -658,27 +657,13 @@ function deriveTurnFolds(input: {
658 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {657 if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) {
659 continue;658 continue;
660 }659 }
661 // User input stays visible after the surrounding work settles.660 // User input and subagent batches stay visible after their turn settles.
662 if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {661 if (
662 entry.kind === "work" &&
663 (entry.entry.questionAnswer !== undefined || entry.entry.agentSpawn !== undefined)
664 ) {
663 continue;665 continue;
664 }666 }
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 }
682 hiddenEntryIds.add(entry.id);667 hiddenEntryIds.add(entry.id);
683 }668 }
684 if (hiddenEntryIds.size === 0) {669 if (hiddenEntryIds.size === 0) {
...@@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: {
870 activeTurnStartedAt: string | null;855 activeTurnStartedAt: string | null;
871 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;856 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
872 supportsConversationRollback: boolean;857 supportsConversationRollback: boolean;
873 /**858 /** Task ids of subagents still working, used by the active tool indicator. */
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 */
877 liveAgentTaskIds?: ReadonlySet<string> | undefined;859 liveAgentTaskIds?: ReadonlySet<string> | undefined;
878 /** Spawn rows the user opened stay visible while their turn fold is collapsed. */
879 expandedSpawnEntryIds?: ReadonlySet<string> | undefined;
880}): MessagesTimelineRow[] {860}): MessagesTimelineRow[] {
881 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();861 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
882 for (const summary of input.turnDiffSummaries) {862 for (const summary of input.turnDiffSummaries) {
...@@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: {
911 terminalAssistantMessageIds,891 terminalAssistantMessageIds,
912 latestTurn: input.latestTurn ?? null,892 latestTurn: input.latestTurn ?? null,
913 unfoldedTurnIds: activeVisualResponseTurnIds,893 unfoldedTurnIds: activeVisualResponseTurnIds,
914 liveAgentTaskIds: input.liveAgentTaskIds,
915 });894 });
916 const collapsedEntryIds = new Set<string>();895 const collapsedEntryIds = new Set<string>();
917 for (const fold of foldsByAnchorEntryId.values()) {896 for (const fold of foldsByAnchorEntryId.values()) {
918 if (!input.expandedTurnIds?.has(fold.turnId)) {897 if (!input.expandedTurnIds?.has(fold.turnId)) {
919 for (const entryId of fold.hiddenEntryIds) {898 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;
923 collapsedEntryIds.add(entryId);899 collapsedEntryIds.add(entryId);
924 }900 }
925 }901 }
apps/web/src/components/chat/MessagesTimeline.tsx+3-8
...@@ -465,8 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -465,8 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
465}: MessagesTimelineProps) {465}: MessagesTimelineProps) {
466 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());466 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
467 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());467 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
468 // Expanded spawn rows outlive virtualization and stay visible while their468 // Preserve member disclosure state across virtualization.
469 // turn fold is collapsed, so a settling fleet is not pulled away mid-read.
470 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(469 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
471 new Set(),470 new Set(),
472 );471 );
...@@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
628 workspaceRoot: string | undefined;627 workspaceRoot: string | undefined;
629 projection: MessagesTimelineRowsProjection;628 projection: MessagesTimelineRowsProjection;
630 } | null>(null);629 } | null>(null);
631 // Subagents still working keep their spawn row outside the turn fold. Same630 // Match the row header's liveness, retaining projection input identity
632 // liveness rule as the row header (deriveAgentSpawnSummary): members while631 // across unrelated panel updates.
633 // active, workflow coordinators until terminal. Keyed by content so the
634 // projection input keeps its identity across unrelated panel updates.
635 const liveAgentTaskKey = useMemo(() => {632 const liveAgentTaskKey = useMemo(() => {
636 if (agentPanelModel === undefined) return undefined;633 if (agentPanelModel === undefined) return undefined;
637 const ids: string[] = [];634 const ids: string[] = [];
...@@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
667 turnDiffSummaries,664 turnDiffSummaries,
668 supportsConversationRollback,665 supportsConversationRollback,
669 liveAgentTaskIds,666 liveAgentTaskIds,
670 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
671 },667 },
672 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot668 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
673 ? previous.projection669 ? previous.projection
...@@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
689 turnDiffSummaries,685 turnDiffSummaries,
690 supportsConversationRollback,686 supportsConversationRollback,
691 liveAgentTaskIds,687 liveAgentTaskIds,
692 paintedExpandedSpawnEntryIds,
693 ]);688 ]);
694 const rows = useStableRows(rawRows, listIdentityKey);689 const rows = useStableRows(rawRows, listIdentityKey);
695 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);690 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);