| author | |
| committer | |
| log | af2baccd100604f9885d6af97a5fa99622dd1c4f |
| tree | 87c2083bf045fafe642560f9cf3b59a1bcf4d15a |
| parent | c0ddfb3a81abe553fea1e43c79cb723118959449 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1122 | ]); |
| 1123 | 1123 | }); |
| 1124 | 1124 | |
| 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 | 1126 | const firstMessage: ChatMessage = { |
| 1127 | 1127 | id: MessageId.make("assistant-first-entry"), |
| 1128 | 1128 | role: "assistant", |
| ... | ... | @@ -1161,7 +1161,6 @@ describe("deriveMessagesTimelineRows", () => { |
| 1161 | 1161 | const derive = ( |
| 1162 | 1162 | timelineEntries: typeof direct, |
| 1163 | 1163 | liveAgentTaskIds: ReadonlySet<string> | undefined, |
| 1164 | expandedSpawnEntryIds?: ReadonlySet<string>, | |
| 1165 | 1164 | expandedTurnIds?: ReadonlySet<TurnId>, |
| 1166 | 1165 | ) => |
| 1167 | 1166 | deriveMessagesTimelineRows({ |
| ... | ... | @@ -1171,10 +1170,8 @@ describe("deriveMessagesTimelineRows", () => { |
| 1171 | 1170 | turnDiffSummaries: [], |
| 1172 | 1171 | supportsConversationRollback: false, |
| 1173 | 1172 | liveAgentTaskIds, |
| 1174 | expandedSpawnEntryIds, | |
| 1175 | 1173 | ...(expandedTurnIds ? { expandedTurnIds } : {}), |
| 1176 | 1174 | }).map((row) => row.id); |
| 1177 | const folded = ["turn-fold:turn-1", "assistant-final-entry"]; | |
| 1178 | 1175 | const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"]; |
| 1179 | 1176 | |
| 1180 | 1177 | const activeRows = ( |
| ... | ... | @@ -1237,19 +1234,15 @@ describe("deriveMessagesTimelineRows", () => { |
| 1237 | 1234 | } |
| 1238 | 1235 | } |
| 1239 | 1236 | |
| 1240 | expect(derive(direct, new Set())).toEqual(folded); | |
| 1237 | expect(derive(direct, new Set())).toEqual(unfolded); | |
| 1241 | 1238 | expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded); |
| 1242 | 1239 | // A workflow coordinator between phases keeps its batch out of the fold. |
| 1243 | 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 | 1242 | // No live set is known. |
| 1246 | 1243 | 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([ | |
| 1253 | 1246 | "turn-fold:turn-1", |
| 1254 | 1247 | "assistant-first-entry", |
| 1255 | 1248 | "spawn-entry", |
apps/web/src/components/chat/MessagesTimeline.logic.ts+6-30| ... | ... | @@ -577,7 +577,6 @@ function deriveTurnFolds(input: { |
| 577 | 577 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 578 | 578 | latestTurn: TimelineLatestTurn | null; |
| 579 | 579 | unfoldedTurnIds: ReadonlySet<TurnId>; |
| 580 | liveAgentTaskIds: ReadonlySet<string> | undefined; | |
| 581 | 580 | }): ReadonlyMap<string, TurnFold> { |
| 582 | 581 | interface TurnGroup { |
| 583 | 582 | entries: Array<TimelineEntry>; |
| ... | ... | @@ -658,27 +657,13 @@ function deriveTurnFolds(input: { |
| 658 | 657 | if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) { |
| 659 | 658 | continue; |
| 660 | 659 | } |
| 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 | ) { | |
| 663 | 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 | 667 | hiddenEntryIds.add(entry.id); |
| 683 | 668 | } |
| 684 | 669 | if (hiddenEntryIds.size === 0) { |
| ... | ... | @@ -870,13 +855,8 @@ export function deriveMessagesTimelineRows(input: { |
| 870 | 855 | activeTurnStartedAt: string | null; |
| 871 | 856 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; |
| 872 | 857 | 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. */ | |
| 877 | 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 | 860 | }): MessagesTimelineRow[] { |
| 881 | 861 | const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>(); |
| 882 | 862 | for (const summary of input.turnDiffSummaries) { |
| ... | ... | @@ -911,15 +891,11 @@ export function deriveMessagesTimelineRows(input: { |
| 911 | 891 | terminalAssistantMessageIds, |
| 912 | 892 | latestTurn: input.latestTurn ?? null, |
| 913 | 893 | unfoldedTurnIds: activeVisualResponseTurnIds, |
| 914 | liveAgentTaskIds: input.liveAgentTaskIds, | |
| 915 | 894 | }); |
| 916 | 895 | const collapsedEntryIds = new Set<string>(); |
| 917 | 896 | for (const fold of foldsByAnchorEntryId.values()) { |
| 918 | 897 | if (!input.expandedTurnIds?.has(fold.turnId)) { |
| 919 | 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 | 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 | 465 | }: MessagesTimelineProps) { |
| 466 | 466 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| 467 | 467 | 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. | |
| 470 | 469 | const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>( |
| 471 | 470 | new Set(), |
| 472 | 471 | ); |
| ... | ... | @@ -628,10 +627,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 628 | 627 | workspaceRoot: string | undefined; |
| 629 | 628 | projection: MessagesTimelineRowsProjection; |
| 630 | 629 | } | 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. | |
| 635 | 632 | const liveAgentTaskKey = useMemo(() => { |
| 636 | 633 | if (agentPanelModel === undefined) return undefined; |
| 637 | 634 | const ids: string[] = []; |
| ... | ... | @@ -667,7 +664,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 667 | 664 | turnDiffSummaries, |
| 668 | 665 | supportsConversationRollback, |
| 669 | 666 | liveAgentTaskIds, |
| 670 | expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, | |
| 671 | 667 | }, |
| 672 | 668 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot |
| 673 | 669 | ? previous.projection |
| ... | ... | @@ -689,7 +685,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 689 | 685 | turnDiffSummaries, |
| 690 | 686 | supportsConversationRollback, |
| 691 | 687 | liveAgentTaskIds, |
| 692 | paintedExpandedSpawnEntryIds, | |
| 693 | 688 | ]); |
| 694 | 689 | const rows = useStableRows(rawRows, listIdentityKey); |
| 695 | 690 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |