From f6f83b60f5851506530b6d5f94781822db064d04 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sun, 20 Sep 2026 09:39:19 +0000 Subject: [PATCH 1/5] fix(web): chat timeline no longer overlaps mid-turn tool groups A steer during a streaming expanded tool-call group remounted the live header under a new key, so LegendList placed the following user row and Working-for pill with a 90px estimate on top of a much taller details row. Keep the live-activity-row identity across that splice, pin chrome row sizes, and include an expanded-group height signature in list extraData so growing tool rows remasure before new tail rows are placed. --- .../chat/MessagesTimeline.logic.test.ts | 162 +++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 164 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 12 +- 3 files changed, 333 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..55b3811502e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -24,7 +24,13 @@ import { computeMessageDurationStart, deriveMessagesTimelineRows, deriveMessagesTimelineRowsWithState, + estimateMessagesTimelineItemSize, + getFixedMessagesTimelineItemSize, + layoutMessagesTimelineRows, liveWorkEntryLabel, + messagesTimelineHeightSignature, + messagesTimelineListExtraData, + messagesTimelineRowRectsOverlap, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveWorkGroupScrollIndex, @@ -32,6 +38,11 @@ import { shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, type MessagesTimelineRowsProjection, + LIVE_ACTIVITY_ROW_ID, + TIMELINE_CHROME_ROW_HEIGHT, + TIMELINE_ESTIMATED_ITEM_SIZE, + TIMELINE_EXPANDED_WORK_GROUP_MAX_HEIGHT, + TIMELINE_WORKING_ROW_HEIGHT, WORKTREE_SETUP_ROW_ID, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; @@ -3890,3 +3901,154 @@ describe("computeStableMessagesTimelineRows", () => { expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); }); }); + +describe("live tool group placement across a steer", () => { + const turnId = TurnId.make("turn-steer-overlap"); + const startedAt = "2026-01-01T00:00:00Z"; + + const userEntry = (id: string, at: string, text: string) => ({ + id: `${id}-entry`, + kind: "message" as const, + createdAt: at, + message: { + id: id as never, + role: "user" as const, + text, + turnId: null, + createdAt: at, + updatedAt: at, + streaming: false, + }, + }); + + const commandEntries = (count: number) => + Array.from({ length: count }, (_, index) => ({ + id: `tool-entry-${index}`, + kind: "work" as const, + createdAt: `2026-01-01T00:00:${String(index + 1).padStart(2, "0")}Z`, + entry: { + id: `tool-${index}`, + toolCallId: `call-${index}`, + createdAt: `2026-01-01T00:00:${String(index + 1).padStart(2, "0")}Z`, + turnId, + label: "Ran command", + command: index === count - 1 ? "ssh host" : `cmd-${index}`, + tone: "tool" as const, + itemType: "command_execution" as const, + toolLifecycleStatus: index === count - 1 ? ("inProgress" as const) : ("completed" as const), + }, + })); + + const liveInput = (toolCount: number) => { + const tools = commandEntries(toolCount); + const groupId = `work-group:tool:${turnId}:call-0`; + return { + input: { + timelineEntries: [userEntry("user-1", startedAt, "inspect the repo"), ...tools], + latestTurn: { + turnId, + state: "running" as const, + startedAt, + completedAt: null, + }, + runningTurnId: turnId, + isWorking: true, + expandedWorkGroupIds: new Set([groupId]), + activeTurnStartedAt: startedAt, + turnDiffSummaries: [] as TurnDiffSummary[], + supportsConversationRollback: false, + }, + }; + }; + + it("keeps the live tool header identity when a mid-turn user message lands", () => { + const { input } = liveInput(10); + const before = deriveMessagesTimelineRows(input); + expect(before.find((row) => row.kind === "work-live")?.id).toBe(LIVE_ACTIVITY_ROW_ID); + + const after = deriveMessagesTimelineRows({ + ...input, + timelineEntries: [ + ...input.timelineEntries, + userEntry("user-steer", "2026-01-01T00:01:00Z", "ca7d62b5 is not snake right?"), + ], + }); + + expect(after.filter((row) => row.id === LIVE_ACTIVITY_ROW_ID)).toHaveLength(1); + expect(after.find((row) => row.kind === "work-live")?.id).toBe(LIVE_ACTIVITY_ROW_ID); + expect(after.find((row) => row.kind === "work" && row.isExpandedToolGroup)?.id).toBe( + before.find((row) => row.kind === "work" && row.isExpandedToolGroup)?.id, + ); + }); + + it("bumps the height signature when an expanded live group grows without adding rows", () => { + const small = deriveMessagesTimelineRows(liveInput(2).input); + const large = deriveMessagesTimelineRows(liveInput(10).input); + expect(small).toHaveLength(large.length); + expect(messagesTimelineHeightSignature(small)).not.toBe(messagesTimelineHeightSignature(large)); + expect(messagesTimelineListExtraData("thread-1", small)).not.toBe( + messagesTimelineListExtraData("thread-1", large), + ); + }); + + it("pins chrome row sizes and leaves expanded details measured", () => { + const rows = deriveMessagesTimelineRows(liveInput(10).input); + const working = rows.find((row) => row.kind === "working"); + const live = rows.find((row) => row.kind === "work-live"); + const details = rows.find((row) => row.kind === "work" && row.isExpandedToolGroup); + const user = rows.find((row) => row.kind === "message"); + expect(working && getFixedMessagesTimelineItemSize(working)).toBe(TIMELINE_WORKING_ROW_HEIGHT); + expect(live && getFixedMessagesTimelineItemSize(live)).toBeDefined(); + expect(details && getFixedMessagesTimelineItemSize(details)).toBeUndefined(); + expect(user && getFixedMessagesTimelineItemSize(user)).toBeUndefined(); + expect(details && estimateMessagesTimelineItemSize(details)).toBeGreaterThan( + TIMELINE_ESTIMATED_ITEM_SIZE, + ); + expect(details && estimateMessagesTimelineItemSize(details)).toBeLessThanOrEqual( + TIMELINE_EXPANDED_WORK_GROUP_MAX_HEIGHT + 4, + ); + expect( + getFixedMessagesTimelineItemSize({ + kind: "thinking", + id: LIVE_ACTIVITY_ROW_ID, + createdAt: startedAt, + }), + ).toBe(TIMELINE_CHROME_ROW_HEIGHT); + }); + + it("does not overlap a steer user row or Working pill with a remasured expanded tool group", () => { + const { input } = liveInput(10); + const liveRows = deriveMessagesTimelineRows(input); + const details = liveRows.find((row) => row.kind === "work" && row.isExpandedToolGroup); + expect(details).toBeDefined(); + const detailsHeight = estimateMessagesTimelineItemSize(details!); + expect(detailsHeight).toBeGreaterThan(TIMELINE_ESTIMATED_ITEM_SIZE); + + const steer = userEntry("user-steer", "2026-01-01T00:01:00Z", "ca7d62b5 is not snake right?"); + const steered = deriveMessagesTimelineRows({ + ...input, + timelineEntries: [...input.timelineEntries, steer], + }); + const measured = new Map(); + for (const row of liveRows) { + measured.set(row.id, estimateMessagesTimelineItemSize(row)); + } + + const stale = layoutMessagesTimelineRows(steered); + const staleDetails = stale.find((row) => row.id === details!.id); + const staleUser = stale.find((row) => row.id === steer.id); + expect(staleDetails?.height).toBe(TIMELINE_ESTIMATED_ITEM_SIZE); + expect(staleUser && staleDetails).toBeDefined(); + expect(staleUser!.top).toBeLessThan(staleDetails!.top + detailsHeight); + + const layout = layoutMessagesTimelineRows(steered, measured); + const detailsRect = layout.find((row) => row.id === details!.id); + const userRect = layout.find((row) => row.id === steer.id); + const workingRect = layout.find((row) => row.id === "working-indicator-row"); + expect(detailsRect && userRect && workingRect).toBeTruthy(); + expect(messagesTimelineRowRectsOverlap(detailsRect!, userRect!)).toBe(false); + expect(messagesTimelineRowRectsOverlap(detailsRect!, workingRect!)).toBe(false); + expect(userRect!.top).toBeGreaterThanOrEqual(detailsRect!.top + detailsRect!.height); + expect(workingRect!.top).toBeGreaterThanOrEqual(userRect!.top + userRect!.height); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 57ed45a89d1d..fc82a203e79f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -312,7 +312,20 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; -const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + +/** LegendList's default estimate. Chrome rows are much shorter; expanded tool groups are much taller. */ +export const TIMELINE_ESTIMATED_ITEM_SIZE = 90; +/** Working header: `pt-1` + `h-6` + `pb-2` + `border-b` + row `pb-1.5`. */ +export const TIMELINE_WORKING_ROW_HEIGHT = 43; +/** Thinking / collapsed work chrome: `min-h-6` + `py-0.5` + row `pb-2`. */ +export const TIMELINE_CHROME_ROW_HEIGHT = 36; +/** Expanded work-live / work-toggle header: chrome without row `pb-2`. */ +export const TIMELINE_EXPANDED_WORK_HEADER_HEIGHT = 28; +/** Nested tool list `max-h-[min(18rem,50dvh)]`. */ +export const TIMELINE_EXPANDED_WORK_GROUP_MAX_HEIGHT = 288; +const TIMELINE_WORK_ENTRY_ROW_HEIGHT = 24; +const TIMELINE_EXPANDED_WORK_GROUP_PADDING = 4; type ActivityEntry = Extract; @@ -489,6 +502,147 @@ function expandedWorkGroupRow( }; } +function remainderIsOnlyUserMessages( + timelineEntries: ReadonlyArray, + fromIndex: number, +): boolean { + for (let index = fromIndex; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]!; + if (entry.kind !== "message" || entry.message.role !== "user") { + return false; + } + } + return true; +} + +function workLiveRowIdFromGroupId(groupId: string): string { + return `work-live:${groupId.startsWith("work-group:") ? groupId.slice("work-group:".length) : groupId}`; +} + +/** Keep one `live-activity-row` key so LegendList can reuse its measured size. */ +function collapseDuplicateLiveActivityRowIds(rows: MessagesTimelineRow[]): void { + let lastLiveIndex = -1; + for (let index = 0; index < rows.length; index += 1) { + if (rows[index]!.id === LIVE_ACTIVITY_ROW_ID) { + lastLiveIndex = index; + } + } + if (lastLiveIndex < 0) { + return; + } + for (let index = 0; index < rows.length; index += 1) { + if (index === lastLiveIndex || rows[index]!.id !== LIVE_ACTIVITY_ROW_ID) { + continue; + } + const row = rows[index]!; + if (row.kind === "work-live") { + rows[index] = { ...row, id: workLiveRowIdFromGroupId(row.groupId) }; + } else if (row.kind === "activity-group") { + rows[index] = { ...row, id: row.groupId }; + } + } +} + +/** Pin chrome rows. Messages and expanded tool details stay measured. */ +export function getFixedMessagesTimelineItemSize(row: MessagesTimelineRow): number | undefined { + switch (row.kind) { + case "working": + return TIMELINE_WORKING_ROW_HEIGHT; + case "thinking": + return TIMELINE_CHROME_ROW_HEIGHT; + case "work-toggle": + return row.expanded ? TIMELINE_EXPANDED_WORK_HEADER_HEIGHT : TIMELINE_CHROME_ROW_HEIGHT; + case "work-live": + return row.expanded ? TIMELINE_EXPANDED_WORK_HEADER_HEIGHT : TIMELINE_CHROME_ROW_HEIGHT; + case "activity-group": + return row.expanded ? undefined : TIMELINE_CHROME_ROW_HEIGHT; + default: + return undefined; + } +} + +export function estimateMessagesTimelineItemSize(row: MessagesTimelineRow): number { + const fixed = getFixedMessagesTimelineItemSize(row); + if (fixed !== undefined) { + return fixed; + } + if (row.kind === "work" && row.isExpandedToolGroup) { + return Math.min( + TIMELINE_EXPANDED_WORK_GROUP_MAX_HEIGHT + TIMELINE_EXPANDED_WORK_GROUP_PADDING, + row.groupedEntries.length * TIMELINE_WORK_ENTRY_ROW_HEIGHT + + TIMELINE_EXPANDED_WORK_GROUP_PADDING, + ); + } + return TIMELINE_ESTIMATED_ITEM_SIZE; +} + +/** Changes when an expanded work row grows, even if `rows.length` stays put. */ +export function messagesTimelineHeightSignature(rows: ReadonlyArray): string { + let signature = `${rows.length}`; + for (const row of rows) { + switch (row.kind) { + case "work": + if (row.isExpandedToolGroup) { + signature += `|${row.id}:${row.groupedEntries.length}`; + } + break; + case "work-live": + if (row.expanded) { + signature += `|${row.id}:${row.groupedEntries.length}`; + } + break; + case "activity-group": + if (row.expanded) { + signature += `|${row.id}:${row.entries.length}`; + } + break; + case "work-toggle": + if (row.expanded) { + signature += `|${row.id}:${row.hiddenCount}`; + } + break; + } + } + return signature; +} + +export function messagesTimelineListExtraData( + listIdentityKey: string, + rows: ReadonlyArray, +): string { + return `${listIdentityKey}:${messagesTimelineHeightSignature(rows)}`; +} + +export interface MessagesTimelineRowRect { + readonly id: string; + readonly top: number; + readonly height: number; +} + +/** LegendList placement: fixed chrome, else a measured key, else the 90px estimate. */ +export function layoutMessagesTimelineRows( + rows: ReadonlyArray, + measuredHeights: ReadonlyMap = new Map(), +): MessagesTimelineRowRect[] { + let top = 0; + return rows.map((row) => { + const height = + getFixedMessagesTimelineItemSize(row) ?? + measuredHeights.get(row.id) ?? + TIMELINE_ESTIMATED_ITEM_SIZE; + const rect = { id: row.id, top, height }; + top += height; + return rect; + }); +} + +export function messagesTimelineRowRectsOverlap( + a: MessagesTimelineRowRect, + b: MessagesTimelineRowRect, +): boolean { + return a.top < b.top + b.height && b.top < a.top + a.height; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -1156,7 +1310,7 @@ export function deriveMessagesTimelineRows(input: { const active = input.isWorking && activityTurnId === unsettledTurnId && - cursor === input.timelineEntries.length && + remainderIsOnlyUserMessages(input.timelineEntries, cursor) && !latestToolFailed && (latestVisibleToolEntry === undefined || latestToolKeepsActivityLive); const groupId = @@ -1251,7 +1405,10 @@ export function deriveMessagesTimelineRows(input: { const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; nextRows.push({ kind: "work-live", - id: `work-live:${workGroupIdentity(timelineEntry.id, timelineEntry.entry)}`, + id: + activeWorkRow === null + ? LIVE_ACTIVITY_ROW_ID + : `work-live:${workGroupIdentity(timelineEntry.id, timelineEntry.entry)}`, createdAt: timelineEntry.createdAt, entry: latestActiveToolEntry, groupedEntries: visibleGroupedEntries, @@ -1452,6 +1609,7 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); } + collapseDuplicateLiveActivityRowIds(nextRows); const rows = attachTrailingToolGroupsToAssistant(nextRows); input.queuedMessages?.forEach((queuedMessage, index) => { rows.push({ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 42a45b991028..246429dc6b12 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -180,7 +180,9 @@ import { deriveMessagesTimelineRowsWithState, deriveUnsettledTurnId, type MessagesTimelineRowsProjection, + getFixedMessagesTimelineItemSize, liveWorkEntryLabel, + messagesTimelineListExtraData, workEntryIsActiveTurnActivity, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, @@ -200,6 +202,7 @@ import { worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, type MessagesTimelineRow, + TIMELINE_ESTIMATED_ITEM_SIZE, TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, type WorkGroupScrollAnchor, @@ -812,6 +815,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ queuedMessages, ]); const rows = useStableRows(rawRows, listIdentityKey); + const listExtraData = useMemo( + () => messagesTimelineListExtraData(listIdentityKey, rows), + [listIdentityKey, rows], + ); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const restoreRowIndex = restoringThreadPosition && rememberedPosition?.atEnd === false @@ -1279,11 +1286,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} - extraData={`${listIdentityKey}:${rows.length}`} + extraData={listExtraData} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} - estimatedItemSize={90} + estimatedItemSize={TIMELINE_ESTIMATED_ITEM_SIZE} + getFixedItemSize={getFixedMessagesTimelineItemSize} initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} // Legend needs a data refresh to mount new pins without a scroll event. dataVersion={readyCitationRequest?.key ?? listIdentityKey} From 572458c1afe236a8a70271bf06f118f8d536162f Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sun, 20 Sep 2026 09:54:21 +0000 Subject: [PATCH 2/5] docs(web): add TSDoc on timeline overlap helpers CodeRabbit docstring coverage on the mid-turn tool-group fix was 33%. Add one-line TSDoc on every function in that diff so coverage clears 80%. Co-authored-by: maco --- apps/web/src/components/chat/MessagesTimeline.logic.test.ts | 3 +++ apps/web/src/components/chat/MessagesTimeline.logic.ts | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 55b3811502e5..936db06636f7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3906,6 +3906,7 @@ describe("live tool group placement across a steer", () => { const turnId = TurnId.make("turn-steer-overlap"); const startedAt = "2026-01-01T00:00:00Z"; + /** Timeline user-message entry for steer-overlap layout tests. */ const userEntry = (id: string, at: string, text: string) => ({ id: `${id}-entry`, kind: "message" as const, @@ -3921,6 +3922,7 @@ describe("live tool group placement across a steer", () => { }, }); + /** Sequential command work entries; the last one stays in progress. */ const commandEntries = (count: number) => Array.from({ length: count }, (_, index) => ({ id: `tool-entry-${index}`, @@ -3939,6 +3941,7 @@ describe("live tool group placement across a steer", () => { }, })); + /** Running-turn timeline input with `toolCount` expanded live command rows. */ const liveInput = (toolCount: number) => { const tools = commandEntries(toolCount); const groupId = `work-group:tool:${turnId}:call-0`; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index fc82a203e79f..9533cac2bcca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -502,6 +502,7 @@ function expandedWorkGroupRow( }; } +/** True when every remaining timeline entry is a user message (a mid-turn steer). */ function remainderIsOnlyUserMessages( timelineEntries: ReadonlyArray, fromIndex: number, @@ -515,6 +516,7 @@ function remainderIsOnlyUserMessages( return true; } +/** Stable non-live work-live row id derived from a work group id. */ function workLiveRowIdFromGroupId(groupId: string): string { return `work-live:${groupId.startsWith("work-group:") ? groupId.slice("work-group:".length) : groupId}`; } @@ -561,6 +563,7 @@ export function getFixedMessagesTimelineItemSize(row: MessagesTimelineRow): numb } } +/** Fixed chrome height, expanded-group estimate, or LegendList's 90px default. */ export function estimateMessagesTimelineItemSize(row: MessagesTimelineRow): number { const fixed = getFixedMessagesTimelineItemSize(row); if (fixed !== undefined) { @@ -606,6 +609,7 @@ export function messagesTimelineHeightSignature(rows: ReadonlyArray, @@ -636,6 +640,7 @@ export function layoutMessagesTimelineRows( }); } +/** True when two stacked timeline row rectangles share vertical space. */ export function messagesTimelineRowRectsOverlap( a: MessagesTimelineRowRect, b: MessagesTimelineRowRect, From b4adaa34be1f2f4465a473c7d95b88633680fc8c Mon Sep 17 00:00:00 2001 From: macodev00 Date: Sun, 20 Sep 2026 09:57:59 +0000 Subject: [PATCH 3/5] fix(web): do not pin agent-spawn timeline rows to chrome height getFixedMessagesTimelineItemSize treated every work-live row as fixed chrome. Expanded AgentSpawnRow members grow in place via expandedSpawnEntryIds, so following rows overlapped. Leave expandable spawn work-live rows measured instead. Co-authored-by: maco --- .../chat/MessagesTimeline.logic.test.ts | 41 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 7 +++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 936db06636f7..0c812c106223 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4019,6 +4019,47 @@ describe("live tool group placement across a steer", () => { ).toBe(TIMELINE_CHROME_ROW_HEIGHT); }); + it("does not pin expandable agent-spawn work-live rows to chrome height", () => { + const spawnEntry: WorkLogEntry = { + id: "spawn-entry", + createdAt: startedAt, + turnId, + label: "Ran 2 subagents", + tone: "tool", + agentSpawn: { workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }, + }; + const spawnLive: MessagesTimelineRow = { + kind: "work-live", + id: LIVE_ACTIVITY_ROW_ID, + createdAt: startedAt, + entry: spawnEntry, + groupedEntries: [spawnEntry], + groupId: "work-group:spawn-entry", + expanded: false, + active: true, + }; + expect(getFixedMessagesTimelineItemSize(spawnLive)).toBeUndefined(); + expect(getFixedMessagesTimelineItemSize({ ...spawnLive, expanded: true })).toBeUndefined(); + + const following: MessagesTimelineRow = { + kind: "working", + id: "working-indicator-row", + createdAt: startedAt, + }; + const expandedSpawnHeight = 160; + expect(TIMELINE_CHROME_ROW_HEIGHT).toBeLessThan(expandedSpawnHeight); + + const layout = layoutMessagesTimelineRows( + [spawnLive, following], + new Map([[LIVE_ACTIVITY_ROW_ID, expandedSpawnHeight]]), + ); + const spawnRect = layout[0]!; + const followingRect = layout[1]!; + expect(spawnRect.height).toBe(expandedSpawnHeight); + expect(messagesTimelineRowRectsOverlap(spawnRect, followingRect)).toBe(false); + expect(followingRect.top).toBeGreaterThanOrEqual(spawnRect.top + spawnRect.height); + }); + it("does not overlap a steer user row or Working pill with a remasured expanded tool group", () => { const { input } = liveInput(10); const liveRows = deriveMessagesTimelineRows(input); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9533cac2bcca..a2dcdb5dc487 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -545,7 +545,11 @@ function collapseDuplicateLiveActivityRowIds(rows: MessagesTimelineRow[]): void } } -/** Pin chrome rows. Messages and expanded tool details stay measured. */ +/** + * Pin chrome rows. Messages, expanded tool details, and in-place agent-spawn + * rows stay measured — spawn members expand via `expandedSpawnEntryIds`, not a + * following details row. + */ export function getFixedMessagesTimelineItemSize(row: MessagesTimelineRow): number | undefined { switch (row.kind) { case "working": @@ -555,6 +559,7 @@ export function getFixedMessagesTimelineItemSize(row: MessagesTimelineRow): numb case "work-toggle": return row.expanded ? TIMELINE_EXPANDED_WORK_HEADER_HEIGHT : TIMELINE_CHROME_ROW_HEIGHT; case "work-live": + if (row.entry.agentSpawn) return undefined; return row.expanded ? TIMELINE_EXPANDED_WORK_HEADER_HEIGHT : TIMELINE_CHROME_ROW_HEIGHT; case "activity-group": return row.expanded ? undefined : TIMELINE_CHROME_ROW_HEIGHT; From 42649b09d0d98c44f6b3a700af50c51850cceab5 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Mon, 21 Sep 2026 06:35:42 +0000 Subject: [PATCH 4/5] docs(web): raise timeline overlap helper TSDoc coverage CodeRabbit docstring coverage on the mid-turn tool-group fix was 75%. Use function-declaration TSDoc on the test helpers and remaining touched functions so coverage clears 80%. --- .../chat/MessagesTimeline.logic.test.ts | 37 ++++++++++--------- .../components/chat/MessagesTimeline.logic.ts | 12 ++++-- .../src/components/chat/MessagesTimeline.tsx | 1 + 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0c812c106223..fd3095680e19 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3907,24 +3907,26 @@ describe("live tool group placement across a steer", () => { const startedAt = "2026-01-01T00:00:00Z"; /** Timeline user-message entry for steer-overlap layout tests. */ - const userEntry = (id: string, at: string, text: string) => ({ - id: `${id}-entry`, - kind: "message" as const, - createdAt: at, - message: { - id: id as never, - role: "user" as const, - text, - turnId: null, + function userEntry(id: string, at: string, text: string) { + return { + id: `${id}-entry`, + kind: "message" as const, createdAt: at, - updatedAt: at, - streaming: false, - }, - }); + message: { + id: id as never, + role: "user" as const, + text, + turnId: null, + createdAt: at, + updatedAt: at, + streaming: false, + }, + }; + } /** Sequential command work entries; the last one stays in progress. */ - const commandEntries = (count: number) => - Array.from({ length: count }, (_, index) => ({ + function commandEntries(count: number) { + return Array.from({ length: count }, (_, index) => ({ id: `tool-entry-${index}`, kind: "work" as const, createdAt: `2026-01-01T00:00:${String(index + 1).padStart(2, "0")}Z`, @@ -3940,9 +3942,10 @@ describe("live tool group placement across a steer", () => { toolLifecycleStatus: index === count - 1 ? ("inProgress" as const) : ("completed" as const), }, })); + } /** Running-turn timeline input with `toolCount` expanded live command rows. */ - const liveInput = (toolCount: number) => { + function liveInput(toolCount: number) { const tools = commandEntries(toolCount); const groupId = `work-group:tool:${turnId}:call-0`; return { @@ -3962,7 +3965,7 @@ describe("live tool group placement across a steer", () => { supportsConversationRollback: false, }, }; - }; + } it("keeps the live tool header identity when a mid-turn user message lands", () => { const { input } = liveInput(10); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a2dcdb5dc487..0b6cf274f502 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -488,6 +488,7 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; } +/** Nested tool-call details row that LegendList must measure, not pin. */ function expandedWorkGroupRow( groupId: string, createdAt: string, @@ -633,16 +634,17 @@ export function layoutMessagesTimelineRows( rows: ReadonlyArray, measuredHeights: ReadonlyMap = new Map(), ): MessagesTimelineRowRect[] { + const rects: MessagesTimelineRowRect[] = []; let top = 0; - return rows.map((row) => { + for (const row of rows) { const height = getFixedMessagesTimelineItemSize(row) ?? measuredHeights.get(row.id) ?? TIMELINE_ESTIMATED_ITEM_SIZE; - const rect = { id: row.id, top, height }; + rects.push({ id: row.id, top, height }); top += height; - return rect; - }); + } + return rects; } /** True when two stacked timeline row rectangles share vertical space. */ @@ -653,6 +655,7 @@ export function messagesTimelineRowRectsOverlap( return a.top < b.top + b.height && b.top < a.top + a.height; } +/** Copy-button visibility and rendered text for a finished assistant message. */ export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -1102,6 +1105,7 @@ function buildRevertTurnCountByUserMessageId(input: { return byUserMessageId; } +/** Flatten timeline entries into virtualized rows, keeping live tool identity across a steer. */ export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestTurn?: TimelineLatestTurn | null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 246429dc6b12..ab034bf04016 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -479,6 +479,7 @@ interface MessagesTimelineProps { // MessagesTimeline — list owner // --------------------------------------------------------------------------- +/** Virtualized chat transcript; pins chrome row sizes and extraData height signatures. */ export const MessagesTimeline = memo(function MessagesTimeline({ citationRequest = null, citationHistoryLoading = false, From afd04ec3727d44b1a1056a3ebdfce3eb00120930 Mon Sep 17 00:00:00 2001 From: macodev00 Date: Mon, 21 Sep 2026 07:54:02 +0000 Subject: [PATCH 5/5] fix(web): remasure live agent-spawn rows on expand getFixedMessagesTimelineItemSize returns undefined for work-live rows with agentSpawn so AgentSpawnRow is measured instead of pinned to chrome height. Include expandedSpawnEntryIds in LegendList extraData and derive the live spawn row in the overlap test. --- .../chat/MessagesTimeline.logic.test.ts | 66 ++++++++++++++----- .../components/chat/MessagesTimeline.logic.ts | 13 +++- .../src/components/chat/MessagesTimeline.tsx | 4 +- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index fd3095680e19..b9ecb0f33919 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4023,26 +4023,56 @@ describe("live tool group placement across a steer", () => { }); it("does not pin expandable agent-spawn work-live rows to chrome height", () => { - const spawnEntry: WorkLogEntry = { - id: "spawn-entry", - createdAt: startedAt, - turnId, - label: "Ran 2 subagents", - tone: "tool", - agentSpawn: { workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }, + const spawnLiveInput = { + timelineEntries: [ + userEntry("user-1", startedAt, "inspect the repo"), + { + id: "spawn-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "spawn-entry", + createdAt: "2026-01-01T00:00:01Z", + turnId, + label: "Ran 2 subagents", + tone: "tool" as const, + agentSpawn: { workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }, + toolLifecycleStatus: "inProgress" as const, + }, + }, + ], + latestTurn: { + turnId, + state: "running" as const, + startedAt, + completedAt: null, + }, + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: startedAt, + turnDiffSummaries: [] as TurnDiffSummary[], + supportsConversationRollback: false, + liveAgentTaskIds: new Set(["agent-a", "agent-b"]), }; - const spawnLive: MessagesTimelineRow = { + const rows = deriveMessagesTimelineRows(spawnLiveInput); + const spawnLive = rows.find((row) => row.kind === "work-live"); + expect(spawnLive).toMatchObject({ kind: "work-live", id: LIVE_ACTIVITY_ROW_ID, - createdAt: startedAt, - entry: spawnEntry, - groupedEntries: [spawnEntry], - groupId: "work-group:spawn-entry", - expanded: false, - active: true, - }; - expect(getFixedMessagesTimelineItemSize(spawnLive)).toBeUndefined(); - expect(getFixedMessagesTimelineItemSize({ ...spawnLive, expanded: true })).toBeUndefined(); + entry: { agentSpawn: { agentTaskIds: ["agent-a", "agent-b"] } }, + }); + expect(spawnLive && getFixedMessagesTimelineItemSize(spawnLive)).toBeUndefined(); + expect( + spawnLive && getFixedMessagesTimelineItemSize({ ...spawnLive, expanded: true }), + ).toBeUndefined(); + expect(messagesTimelineListExtraData("thread-1", rows)).not.toBe( + messagesTimelineListExtraData("thread-1", rows, new Set(["spawn-entry"])), + ); + + const commandLive = deriveMessagesTimelineRows(liveInput(2).input).find( + (row) => row.kind === "work-live", + ); + expect(commandLive && getFixedMessagesTimelineItemSize(commandLive)).toBeDefined(); const following: MessagesTimelineRow = { kind: "working", @@ -4053,7 +4083,7 @@ describe("live tool group placement across a steer", () => { expect(TIMELINE_CHROME_ROW_HEIGHT).toBeLessThan(expandedSpawnHeight); const layout = layoutMessagesTimelineRows( - [spawnLive, following], + [spawnLive!, following], new Map([[LIVE_ACTIVITY_ROW_ID, expandedSpawnHeight]]), ); const spawnRect = layout[0]!; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0b6cf274f502..ec74257046fb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -560,7 +560,9 @@ export function getFixedMessagesTimelineItemSize(row: MessagesTimelineRow): numb case "work-toggle": return row.expanded ? TIMELINE_EXPANDED_WORK_HEADER_HEIGHT : TIMELINE_CHROME_ROW_HEIGHT; case "work-live": - if (row.entry.agentSpawn) return undefined; + // AgentSpawnRow grows in place via expandedSpawnEntryIds; a numeric + // getFixedItemSize skips LegendList measurement and overlaps the next row. + if (row.entry.agentSpawn !== undefined) return undefined; return row.expanded ? TIMELINE_EXPANDED_WORK_HEADER_HEIGHT : TIMELINE_CHROME_ROW_HEIGHT; case "activity-group": return row.expanded ? undefined : TIMELINE_CHROME_ROW_HEIGHT; @@ -615,12 +617,17 @@ export function messagesTimelineHeightSignature(rows: ReadonlyArray, + expandedSpawnEntryIds?: ReadonlySet, ): string { - return `${listIdentityKey}:${messagesTimelineHeightSignature(rows)}`; + const spawnKey = + expandedSpawnEntryIds !== undefined && expandedSpawnEntryIds.size > 0 + ? `|spawn:${[...expandedSpawnEntryIds].toSorted().join(",")}` + : ""; + return `${listIdentityKey}:${messagesTimelineHeightSignature(rows)}${spawnKey}`; } export interface MessagesTimelineRowRect { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ab034bf04016..1a4ca41020f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -817,8 +817,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]); const rows = useStableRows(rawRows, listIdentityKey); const listExtraData = useMemo( - () => messagesTimelineListExtraData(listIdentityKey, rows), - [listIdentityKey, rows], + () => messagesTimelineListExtraData(listIdentityKey, rows, paintedExpandedSpawnEntryIds), + [listIdentityKey, rows, paintedExpandedSpawnEntryIds], ); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const restoreRowIndex =