From 9f8b53a64bc1f899f6a8e879bc2722e7e931eacd Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 14 Aug 2026 00:49:32 +0800 Subject: [PATCH] fix(flowchat): stabilize virtual item estimates Use data-driven height estimators for unmounted FlowChat items so TanStack Virtual reserves space according to message content, tool state, reading width, and Explore expansion state. Preserve measured DOM sizes while invalidating derived positions when estimate inputs change. Also stop the viewport anchor RAF loop when the anchor remains in-place, and extend the gated viewport diagnostics with bounded estimate breakdowns for resize investigations. --- .../modern/FLOWCHAT_HISTORY_PAGING.md | 10 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 10 +- .../modern/VirtualMessageList.layout.test.ts | 77 +++++ .../components/modern/VirtualMessageList.tsx | 22 +- .../modern/useFlowChatViewportAnchor.test.tsx | 19 +- .../modern/useFlowChatViewportAnchor.ts | 10 +- .../modern/useFlowChatVirtualizer.ts | 25 +- .../modern/virtualItemHeightEstimators.ts | 287 ++++++++++++++++++ .../modern/virtualMessageListLayout.ts | 163 +++++----- 9 files changed, 522 insertions(+), 101 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md index 4a18afba1..49cd8315a 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md @@ -429,11 +429,11 @@ to nothing else. That is what let the virtualizer underneath it be replaced without the keeper changing at all. One consequence of the refresh rule is worth stating plainly: a frame that finds -the anchor already in place still counts as answered, so an open transcript that -no other writer owns holds one animation frame in flight indefinitely. The cost -is a `querySelectorAll` and two rect reads per frame. That is accepted; the -window winds down when there is no anchor to keep, and when another owner holds -the viewport. +the anchor already in place consumes the remaining settle budget rather than +refreshing it. The cost is a `querySelectorAll` and two rect reads per frame for +the bounded settle window. A correction or a Turn still waiting to render +refreshes the window, while a stable anchor winds it down and another owner +holding the viewport still stands it down. The anchor is skipped entirely while follow-output owns the viewport. Restoring a pre-prepend position is only meaningful when the user owns it — and a frame diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 55e46a901..d53e741e2 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -21,7 +21,15 @@ pass: `size = measured ?? estimateSize(i)`. A per-item estimate for everything unmeasured. react-virtuoso reserves a single scalar (`lastSize`) for all of them, and this transcript alternates 38px user messages with model rounds up to 5012px, so the scroll range was wrong by an order of magnitude until an item was -actually measured. `estimateVirtualMessageItemHeight` now feeds it directly. +actually measured. `estimateVirtualMessageItemHeightWithContext` now feeds it +directly. The estimate is owned by the data shape in +`virtualItemHeightEstimators.ts`: text, thinking, user messages, model rounds, +Explore groups, and tool families each derive a bounded height from their +content, status, width, and expansion state. This code is pure and runs before +a row has a DOM node. Once mounted, DOM measurement remains authoritative and +replaces the estimate. Width and volatile Explore expansion changes invalidate +only the derived position pass; TanStack's key-based measured-size cache is +retained. **Items stay in normal flow inside a padded window**, not absolutely positioned. Everything outside the window stands in as `padding-top` and `padding-bottom` diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts index 774d97deb..e2a0fc2f6 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; import { estimateTextHeightFromLength, + estimateVirtualMessageItemHeightWithContext, estimateVirtualMessageItemHeight, getVirtualMessageDefaultItemHeight, selectInitialHistoryRenderWindow, } from './virtualMessageListLayout'; +import { estimateToolHeight } from './virtualItemHeightEstimators'; import type { VirtualItem } from '../../store/modernFlowChatStore'; +import type { FlowToolItem } from '../../types/flow-chat'; describe('getVirtualMessageDefaultItemHeight', () => { it('keeps compact historical projections on the small row estimate', () => { @@ -113,6 +116,80 @@ describe('estimateVirtualMessageItemHeight', () => { expect(estimateVirtualMessageItemHeight(item)).toBeLessThanOrEqual(160); }); + + it('uses tool-owned data to distinguish compact and expanded Write estimates', () => { + const tool = { + id: 'write-1', + type: 'tool', + toolName: 'Write', + status: 'completed', + timestamp: 1, + toolCall: { + id: 'call-1', + input: { content: 'x'.repeat(600) }, + }, + } as FlowToolItem; + + const completed = estimateToolHeight(tool); + const running = estimateToolHeight({ + ...tool, + status: 'running', + } as FlowToolItem); + + expect(completed.heightPx).toBeLessThan(running.heightPx); + expect(running.kind).toBe('tool-write'); + }); + + it('uses layout width and explicit Explore state for unmeasured rows', () => { + const textItem = { + type: 'model-round', + turnId: 'turn-1', + isLastRound: true, + isTurnComplete: true, + data: { + id: 'round-width', + status: 'completed', + isStreaming: false, + items: [{ + id: 'text-width', + type: 'text', + content: 'x'.repeat(600), + status: 'completed', + timestamp: 1, + }], + }, + } as VirtualItem; + expect(estimateVirtualMessageItemHeightWithContext(textItem, { availableWidthPx: 360 })) + .toBeGreaterThan(estimateVirtualMessageItemHeightWithContext(textItem, { availableWidthPx: 1200 })); + + const exploreItem = { + type: 'explore-group', + turnId: 'turn-1', + data: { + groupId: 'group-1', + allItems: [{ + id: 'tool-1', + type: 'tool', + toolName: 'Write', + status: 'running', + timestamp: 1, + toolCall: { id: 'call-1', input: { content: 'x'.repeat(300) } }, + }], + stats: { readCount: 0, searchCount: 0, commandCount: 0 }, + rounds: [], + isGroupStreaming: false, + isLastGroupInTurn: true, + wasCutByCritical: true, + }, + } as VirtualItem; + const collapsed = estimateVirtualMessageItemHeightWithContext(exploreItem, { + exploreGroupStates: new Map([['group-1', false]]), + }); + const expanded = estimateVirtualMessageItemHeightWithContext(exploreItem, { + exploreGroupStates: new Map([['group-1', true]]), + }); + expect(expanded).toBeGreaterThan(collapsed); + }); }); describe('selectInitialHistoryRenderWindow', () => { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 1745be04a..93d956345 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -69,8 +69,10 @@ import { type HistoryBoundaryProximity, } from './flowChatHistoryBoundary'; import { VirtualItemRenderer } from './VirtualItemRenderer'; +import { useFlowChatVolatileContext } from './FlowChatContext'; import { - estimateVirtualMessageItemHeight, + estimateVirtualMessageItemHeightWithContext, + type VirtualItemHeightEstimateContext, } from './virtualMessageListLayout'; import { resolveVisibleFlowChatTurnIds } from './flowChatVisibleTurns'; import { warnHistoryPagingRefusedWithPendingTurns } from '../../services/historySessionDiagnostics'; @@ -371,6 +373,7 @@ const VirtualMessageListSession = forwardRef(null); const [scrollerElement, setScrollerElement] = useState(null); const [viewportHeightPx, setViewportHeightPx] = useState(0); + const [viewportWidthPx, setViewportWidthPx] = useState(0); /** Last scroller box the resize observer saw, to tell it apart from a content change. */ const observedViewportBoxRef = useRef({ width: 0, height: 0 }); /** Remaining resize callbacks over which to keep a resting viewport at the end. */ @@ -485,7 +489,20 @@ const VirtualMessageListSession = forwardRef 0 ? viewportWidthPx : scrollerElement?.clientWidth, + isHistorical: activeSession?.isHistorical === true, + exploreGroupStates, + } satisfies VirtualItemHeightEstimateContext, + estimateContextRevision: [ + viewportWidthPx, + activeSession?.isHistorical === true ? 'historical' : 'live', + [...(exploreGroupStates?.entries() ?? [])] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([groupId, expanded]) => `${groupId}:${expanded ? 1 : 0}`) + .join(','), + ].join('|'), scrollPaddingStartPx: FLOWCHAT_TURN_TOP_GAP_PX, writeViewport: viewportOwner.write, shiftViewport: viewportOwner.shift, @@ -1298,6 +1315,7 @@ const VirtualMessageListSession = forwardRef { expect(frames).toHaveLength(0); }); - it('keeps a frame in flight for as long as an anchor stands', () => { + it('winds the settle window down when the anchor stays in place', () => { /* - * A frame that answered refreshes the window, and a frame that found the - * anchor already in place counts as answered — so an open transcript that - * nobody else is writing to holds the loop open indefinitely. That is the - * shipped behaviour, and its cost is one `querySelectorAll` and two rect - * reads per frame for as long as a session is open. + * An in-place frame means the relationship is already stable. It spends + * the remaining settle budget instead of refreshing it, so an open + * transcript that nobody else is writing to does not keep a RAF loop alive. */ layoutTurns({ 'turn-3': 100 }); api.captureAnchor(); api.openSettleWindow(); - for (let index = 0; index < 60; index += 1) runFrame(); + let ranFrames = 0; + while (frames.length > 0 && ranFrames < 60) { + runFrame(); + ranFrames += 1; + } - expect(frames).toHaveLength(1); + expect(ranFrames).toBe(ANCHOR_SETTLE_FRAMES); + expect(frames).toHaveLength(0); expect(scroller.scrollTop).toBe(0); }); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts index d246fe58c..14aa50f13 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts @@ -670,13 +670,15 @@ export function useFlowChatViewportAnchor({ */ correctionFrameStartMsRef.current = frameStartMs; /* - * A frame that answered refreshes the window — and so does one still - * waiting for the anchored Turn to be rendered. "Not there yet" is + * A frame that made a correction refreshes the window, and so does one + * still waiting for the anchored Turn to be rendered. "Not there yet" is * neither a repair nor a failure, and spending a frame on it makes the * settle outlast the wait only for as long as `ANCHOR_SETTLE_FRAMES` and * `ANCHOR_MISSING_TURN_ATTEMPTS` happen to be the same number. They are * independent constants describing different things; this says what was - * meant instead of relying on them coinciding. + * meant instead of relying on them coinciding. An in-place frame is + * already settled and must consume the remaining budget, otherwise an + * anchor that never moves keeps this RAF loop alive forever. */ const outcome = attemptRestoreRef.current(); // Counted here rather than beside `attempts`, because it is frames the @@ -695,7 +697,7 @@ export function useFlowChatViewportAnchor({ * and that count can only advance on a frame that does not stand down: * the one condition jammed the loop and made its only exit unreachable. */ - if (outcome === 'corrected' || outcome === 'in-place' || outcome === 'awaiting-turn') { + if (outcome === 'corrected' || outcome === 'awaiting-turn') { settleFramesRef.current = ANCHOR_SETTLE_FRAMES; } correctionFrameStartMsRef.current = null; diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts index cd74131d7..2d7f8cf97 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -34,6 +34,10 @@ import { isViewportDiagnosticsEnabled, } from '@/infrastructure/diagnostics/flowChatViewportDiagnostics'; import type { FlowChatViewportOwner } from './flowChatViewportOwnership'; +import { + describeVirtualItemEstimate, + type VirtualItemHeightEstimateContext, +} from './virtualMessageListLayout'; /** Item-count overscan. Roughly two Turns either side of the viewport. */ const FLOW_CHAT_OVERSCAN_ITEMS = 6; @@ -117,7 +121,11 @@ export interface UseFlowChatVirtualizerOptions { */ headerRef: RefObject; getItemKey: (item: T) => string; - estimateItemHeightPx: (item: T) => number; + estimateItemHeightPx: (item: T, context?: VirtualItemHeightEstimateContext) => number; + /** Data-driven inputs used by estimates before an item is mounted. */ + estimateContext?: VirtualItemHeightEstimateContext; + /** Stable identity for data that changes an unmeasured row's estimate. */ + estimateContextRevision?: string | number; /** * Gap kept above a Turn that has been scrolled to the top of the viewport. * Applied by the virtualizer itself so that its re-aim, which runs while @@ -278,6 +286,8 @@ export function useFlowChatVirtualizer({ headerRef, getItemKey, estimateItemHeightPx, + estimateContext, + estimateContextRevision, scrollPaddingStartPx, writeViewport, shiftViewport = () => false, @@ -318,6 +328,8 @@ export function useFlowChatVirtualizer({ getItemKeyRef.current = getItemKey; const estimateItemHeightRef = useRef(estimateItemHeightPx); estimateItemHeightRef.current = estimateItemHeightPx; + const estimateContextRef = useRef(estimateContext); + estimateContextRef.current = estimateContext; const [contentStartPx, setContentStartPx] = useState(0); useEffect(() => { @@ -340,13 +352,17 @@ export function useFlowChatVirtualizer({ */ const estimateSize = useCallback((index: number) => { const item = itemsRef.current[index]; - return item === undefined ? 0 : estimateItemHeightRef.current(item); + return item === undefined ? 0 : estimateItemHeightRef.current(item, estimateContextRef.current); }, []); const resolveItemKey = useCallback((index: number) => { + // Recreate the callback when estimate inputs change. TanStack uses the + // callback identity to invalidate its derived measurement positions while + // retaining DOM-measured sizes in its key cache. + void estimateContextRevision; const item = itemsRef.current[index]; return item === undefined ? index : getItemKeyRef.current(item); - }, []); + }, [estimateContextRevision]); const virtualizer = useVirtualizer({ count: items.length, @@ -420,6 +436,9 @@ export function useFlowChatVirtualizer({ applied, afterScrollTopPx: roundViewportPx(scroller.scrollTop), afterScrollHeightPx: roundViewportPx(scroller.scrollHeight), + estimateBreakdown: virtualItem && typeof virtualItem === 'object' && 'type' in virtualItem + ? describeVirtualItemEstimate(virtualItem as unknown as Parameters[0]) + : null, }), }, ); diff --git a/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts b/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts new file mode 100644 index 000000000..171c5088f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/virtualItemHeightEstimators.ts @@ -0,0 +1,287 @@ +/** + * Pure, data-driven height estimates used before a virtual row mounts. + * + * These are deliberately separate from React renderers: TanStack must reserve + * space for an item before its DOM exists. Once mounted, ResizeObserver/DOM + * measurement remains authoritative and replaces this estimate. + */ +import type { + AnyFlowItem, + FlowToolItem, +} from '../../types/flow-chat'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { getEffectiveToolName } from '../../utils/toolInvocationIdentity'; + +export interface VirtualItemHeightEstimateContext { + /** Width available to the reading column, excluding the scrollbar. */ + availableWidthPx?: number; + isHistorical?: boolean; + expandedThinkingItemIds?: readonly string[]; + exploreGroupStates?: ReadonlyMap; +} + +export interface VirtualItemHeightEstimate { + heightPx: number; + confidence: 'high' | 'medium' | 'low'; + kind: string; +} + +const DEFAULT_WIDTH_PX = 900; +const MIN_TEXT_LINE_WIDTH_PX = 24; +const USER_MESSAGE_BASE_HEIGHT_PX = 96; +const USER_MESSAGE_LINE_HEIGHT_PX = 22; +const MODEL_ROUND_BASE_HEIGHT_PX = 80; +const MODEL_ROUND_TEXT_BASE_HEIGHT_PX = 72; +const MODEL_ROUND_TEXT_LINE_HEIGHT_PX = 30; +const TOOL_HEADER_HEIGHT_PX = 38; +const TOOL_COMPACT_HEIGHT_PX = 53; +const TODO_COMPACT_HEIGHT_PX = 24; +const TOOL_EXPANDED_BASE_HEIGHT_PX = 96; +const EXPLORE_GROUP_HEADER_HEIGHT_PX = 20; +const EXPLORE_GROUP_MAX_CONTENT_HEIGHT_PX = 400; +const ESTIMATED_TEXT_CHARS_PER_LINE = 60; +const TERMINAL_TOOL_NAMES = new Set(['Bash', 'ExecCommand', 'WriteStdin', 'ExecControl', 'TerminalControl']); +const COLLAPSED_TOOL_STATUSES = new Set(['completed', 'cancelled', 'error', 'rejected']); + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function widthScale(widthPx: number | undefined): number { + const width = Math.min( + DEFAULT_WIDTH_PX, + Math.max(MIN_TEXT_LINE_WIDTH_PX, widthPx ?? DEFAULT_WIDTH_PX), + ); + return DEFAULT_WIDTH_PX / width; +} + +function estimateTextLines(text: string, widthPx: number | undefined): number { + return estimateTextLineCount(text.length, widthPx); +} + +function estimateTextLineCount(textLength: number, widthPx: number | undefined): number { + const scale = widthScale(widthPx); + return Math.max(1, Math.ceil(textLength * scale / ESTIMATED_TEXT_CHARS_PER_LINE)); +} + +function estimateTextHeight( + text: string, + basePx: number, + lineHeightPx: number, + widthPx: number | undefined, +): number { + return basePx + estimateTextLines(text, widthPx) * lineHeightPx; +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function objectValue(value: unknown): Record { + return value !== null && typeof value === 'object' ? value as Record : {}; +} + +function toolInput(tool: FlowToolItem): Record { + return objectValue(tool.partialParams ?? tool.toolCall?.input); +} + +function toolResultText(tool: FlowToolItem): string { + const result = objectValue(tool.toolResult?.result); + return stringValue( + result.output + ?? result.content + ?? result.result + ?? tool.toolResult?.resultForAssistant + ?? tool.toolResult?.error, + ); +} + +function toolContentLength(tool: FlowToolItem): number { + const input = toolInput(tool); + const candidates = [ + input.content, + input.contents, + input.new_string, + input.old_string, + input.command, + input.cmd, + input.description, + toolResultText(tool), + ]; + return candidates.reduce((total, value) => total + stringValue(value).length, 0); +} + +function todoCount(tool: FlowToolItem): number { + const input = toolInput(tool); + const result = objectValue(tool.toolResult?.result); + const todos = input.todos ?? result.todos; + return Array.isArray(todos) ? todos.length : 0; +} + +function isTerminalTool(toolName: string): boolean { + return TERMINAL_TOOL_NAMES.has(toolName); +} + +function isCollapsedByStatus(tool: FlowToolItem): boolean { + return COLLAPSED_TOOL_STATUSES.has(tool.status); +} + +export function estimateFlowItemHeight( + item: AnyFlowItem, + context: VirtualItemHeightEstimateContext = {}, +): VirtualItemHeightEstimate { + if (item.type === 'thinking' && !(context.expandedThinkingItemIds ?? []).includes(item.id)) { + return { heightPx: 40, confidence: 'high', kind: 'thinking-collapsed' }; + } + + if (item.type === 'text' || item.type === 'thinking' || item.type === 'user-steering') { + return { + heightPx: clamp( + estimateTextHeight(item.content, MODEL_ROUND_TEXT_BASE_HEIGHT_PX, MODEL_ROUND_TEXT_LINE_HEIGHT_PX, context.availableWidthPx), + 40, + 3200, + ), + confidence: 'medium', + kind: item.type, + }; + } + + if (item.type === 'image-analysis') { + return { heightPx: 320, confidence: 'low', kind: 'image-analysis' }; + } + + if (item.type === 'tool') { + return estimateToolHeight(item, context); + } + + return { heightPx: 72, confidence: 'low', kind: 'flow-item' }; +} + +export function estimateToolHeight( + tool: FlowToolItem, + context: VirtualItemHeightEstimateContext = {}, +): VirtualItemHeightEstimate { + const toolName = getEffectiveToolName(tool); + const contentLength = toolContentLength(tool); + const collapsed = isCollapsedByStatus(tool); + const contentLines = estimateTextLineCount(contentLength, context.availableWidthPx); + + if (toolName === 'TodoWrite') { + const count = todoCount(tool); + return { + heightPx: collapsed ? TODO_COMPACT_HEIGHT_PX : clamp(TOOL_EXPANDED_BASE_HEIGHT_PX + count * 30, 96, 360), + confidence: count > 0 ? 'high' : 'medium', + kind: 'tool-todo-write', + }; + } + + if (toolName === 'Write' || toolName === 'Edit' || toolName === 'CreateCanvas' || toolName === 'UpdateCanvas') { + return { + heightPx: collapsed + ? TOOL_COMPACT_HEIGHT_PX + : clamp(TOOL_EXPANDED_BASE_HEIGHT_PX + contentLines * 22, TOOL_EXPANDED_BASE_HEIGHT_PX, 480), + confidence: contentLength > 0 ? 'high' : 'medium', + kind: `tool-${toolName.toLowerCase()}`, + }; + } + + if (isTerminalTool(toolName)) { + const outputLines = Math.max(1, Math.min(15, contentLines)); + return { + heightPx: collapsed + ? TOOL_COMPACT_HEIGHT_PX + : TOOL_HEADER_HEIGHT_PX + 22 * outputLines + 40, + confidence: contentLength > 0 ? 'medium' : 'low', + kind: 'tool-terminal', + }; + } + + if (collapsed) { + return { heightPx: TOOL_COMPACT_HEIGHT_PX, confidence: 'medium', kind: 'tool-collapsed' }; + } + + return { + heightPx: clamp(TOOL_HEADER_HEIGHT_PX + contentLines * 22, 72, 420), + confidence: 'low', + kind: 'tool-generic', + }; +} + +export function estimateModelRoundHeight( + item: Extract, + context: VirtualItemHeightEstimateContext = {}, +): VirtualItemHeightEstimate { + const flowItems = item.data.items ?? []; + if (flowItems.length === 0) { + return { heightPx: 200, confidence: 'low', kind: 'model-round-empty' }; + } + const contentHeight = flowItems.reduce( + (total, flowItem) => total + estimateFlowItemHeight(flowItem, context).heightPx, + 0, + ); + return { + heightPx: clamp(MODEL_ROUND_BASE_HEIGHT_PX + contentHeight, 200, 3600), + confidence: flowItems.some(flowItem => flowItem.type === 'tool') ? 'medium' : 'high', + kind: 'model-round', + }; +} + +export function estimateExploreGroupHeight( + item: Extract, + context: VirtualItemHeightEstimateContext = {}, +): VirtualItemHeightEstimate { + const isExpanded = context.exploreGroupStates?.has(item.data.groupId) + ? context.exploreGroupStates.get(item.data.groupId) + : !item.data.wasCutByCritical; + const contentHeight = item.data.allItems.reduce( + (total, flowItem) => total + estimateFlowItemHeight(flowItem as AnyFlowItem, context).heightPx, + 0, + ); + return { + heightPx: isExpanded + ? EXPLORE_GROUP_HEADER_HEIGHT_PX + Math.min(EXPLORE_GROUP_MAX_CONTENT_HEIGHT_PX, contentHeight) + : EXPLORE_GROUP_HEADER_HEIGHT_PX, + confidence: 'medium', + kind: isExpanded ? 'explore-group-expanded' : 'explore-group-collapsed', + }; +} + +export function estimateVirtualItemHeight( + item: VirtualItem, + context: VirtualItemHeightEstimateContext = {}, +): VirtualItemHeightEstimate { + switch (item.type) { + case 'user-message': + case 'user-steering-message': + return { + heightPx: clamp( + estimateTextHeight(item.data.content, USER_MESSAGE_BASE_HEIGHT_PX, USER_MESSAGE_LINE_HEIGHT_PX, context.availableWidthPx), + 96, + 320, + ), + confidence: 'high', + kind: 'user-message', + }; + case 'model-round': + return estimateModelRoundHeight(item, { + ...context, + expandedThinkingItemIds: item.layoutHints?.expandedThinkingItemIds + ?? context.expandedThinkingItemIds, + }); + case 'explore-group': + return estimateExploreGroupHeight(item, context); + case 'turn-completion-notice': + return { heightPx: 120, confidence: 'high', kind: 'turn-completion-notice' }; + case 'turn-failure-notice': + return { heightPx: 160, confidence: 'medium', kind: 'turn-failure-notice' }; + case 'image-analyzing': + return { heightPx: 200, confidence: 'low', kind: 'image-analyzing' }; + } +} + +export function estimateVirtualItemHeightPx( + item: VirtualItem, + context: VirtualItemHeightEstimateContext = {}, +): number { + return estimateVirtualItemHeight(item, context).heightPx; +} diff --git a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts index f43f3cec0..38d23a73f 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts @@ -1,5 +1,13 @@ -import type { AnyFlowItem } from '../../types/flow-chat'; +import type { AnyFlowItem, FlowItem, FlowToolItem } from '../../types/flow-chat'; import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { getEffectiveToolName } from '../../utils/toolInvocationIdentity'; +import { + estimateFlowItemHeight as estimateFlowItemHeightByOwner, + estimateVirtualItemHeight as estimateVirtualItemHeightByOwner, + type VirtualItemHeightEstimateContext, +} from './virtualItemHeightEstimators'; + +export type { VirtualItemHeightEstimateContext, VirtualItemHeightEstimate } from './virtualItemHeightEstimators'; export const LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX = 200; export const HISTORICAL_SESSION_DEFAULT_ITEM_HEIGHT_PX = 72; @@ -7,14 +15,6 @@ export const HISTORICAL_SESSION_MODEL_ROUND_DEFAULT_ITEM_HEIGHT_PX = 960; export const INITIAL_HISTORY_RENDER_MIN_TURN_COUNT = 2; const INITIAL_HISTORY_RENDER_USER_ONLY_LATEST_MIN_TURN_COUNT = 3; export const INITIAL_HISTORY_RENDER_MIN_ESTIMATED_HEIGHT_PX = 1400; -const USER_MESSAGE_BASE_HEIGHT_PX = 96; -const USER_MESSAGE_LINE_HEIGHT_PX = 22; -const MODEL_ROUND_BASE_HEIGHT_PX = 80; -const MODEL_ROUND_TEXT_BASE_HEIGHT_PX = 72; -const MODEL_ROUND_TEXT_LINE_HEIGHT_PX = 30; -const TOOL_CARD_ESTIMATE_HEIGHT_PX = 88; -const EXPLORE_GROUP_BASE_HEIGHT_PX = 96; -const ESTIMATED_TEXT_CHARS_PER_LINE = 60; export function getLeadingVirtualItemIndexDelta( previousItems: readonly T[], @@ -57,90 +57,97 @@ export function getVirtualMessageDefaultItemHeight(params: { } export function estimateTextHeightFromLength(textLength: number, basePx: number, lineHeightPx: number): number { + const ESTIMATED_TEXT_CHARS_PER_LINE = 60; const lineCount = Math.max(1, Math.ceil(textLength / ESTIMATED_TEXT_CHARS_PER_LINE)); return basePx + lineCount * lineHeightPx; } -function estimateTextHeight(content: string, basePx: number, lineHeightPx: number): number { - return estimateTextHeightFromLength(content.length, basePx, lineHeightPx); -} - -function getFlowItemTextLength(item: AnyFlowItem): number { - if (item.type === 'text' || item.type === 'thinking' || item.type === 'user-steering') { - return item.content.length; - } - return 0; +function describeFlowItemEstimate( + item: FlowItem, + index: number, + totalCount: number, + expandedThinkingItemIds: readonly string[], +): Record { + const content = item.type === 'text' || item.type === 'thinking' || item.type === 'user-steering' + ? (item as AnyFlowItem & { content: string }).content + : null; + return { + index, + type: item.type, + ...(item.type === 'tool' + ? { + toolName: getEffectiveToolName(item as FlowToolItem), + status: item.status, + isStreaming: (item as FlowToolItem).isParamsStreaming ?? false, + } + : {}), + ...(content === null + ? {} + : { + contentLength: content.length, + lineCount: content.length === 0 ? 0 : content.split('\n').length, + }), + isLastItem: index === totalCount - 1, + estimatedHeightPx: estimateFlowItemHeightByOwner(item as AnyFlowItem, { + expandedThinkingItemIds, + }).heightPx, + }; } -function estimateFlowItemHeight(item: AnyFlowItem, expandedThinkingItemIds: readonly string[]): number { - if (item.type === 'thinking' && !expandedThinkingItemIds.includes(item.id)) return 40; - const textLength = getFlowItemTextLength(item); - if (textLength > 0) { - return Math.min( - 3200, - estimateTextHeightFromLength( - textLength, - MODEL_ROUND_TEXT_BASE_HEIGHT_PX, - MODEL_ROUND_TEXT_LINE_HEIGHT_PX, +/** Bounded, content-free estimate details for FlowChat viewport diagnostics. */ +export function describeVirtualItemEstimate(item: VirtualItem): Record { + if (item.type === 'model-round') { + const flowItems = item.data.items ?? []; + const expandedThinkingItemIds = item.layoutHints?.expandedThinkingItemIds ?? []; + const itemEstimates = flowItems.map((flowItem, index) => describeFlowItemEstimate( + flowItem, + index, + flowItems.length, + expandedThinkingItemIds, + )); + return { + estimateKind: 'model-round', + isLastRound: item.isLastRound, + isTurnComplete: item.isTurnComplete, + isStreaming: item.data.isStreaming, + expandedThinkingItemIds, + baseHeightPx: 80, + contentEstimatePx: itemEstimates.reduce( + (total, entry) => total + Number(entry.estimatedHeightPx ?? 0), + 0, ), - ); - } - - if (item.type === 'tool') { - return TOOL_CARD_ESTIMATE_HEIGHT_PX; - } - - if (item.type === 'image-analysis') { - return 320; + itemEstimates, + }; } - return HISTORICAL_SESSION_DEFAULT_ITEM_HEIGHT_PX; -} - -function estimateModelRoundHeight(item: Extract): number { - const flowItems = item.data.items ?? []; - if (flowItems.length === 0) { - return LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX; + if (item.type === 'explore-group') { + const itemEstimates = item.data.allItems.map((flowItem, index) => describeFlowItemEstimate( + flowItem as AnyFlowItem, + index, + item.data.allItems.length, + [], + )); + return { + estimateKind: 'explore-group', + itemCount: item.data.allItems.length, + defaultExpanded: !item.data.wasCutByCritical, + estimatedHeightPx: estimateVirtualItemHeightByOwner(item).heightPx, + itemEstimates, + }; } - const contentHeight = flowItems.reduce( - (total, flowItem) => total + estimateFlowItemHeight( - flowItem, - item.layoutHints?.expandedThinkingItemIds ?? [], - ), - 0, - ); - return Math.min(3600, Math.max(LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX, MODEL_ROUND_BASE_HEIGHT_PX + contentHeight)); + return { estimateKind: item.type }; } -function estimateUserMessageHeight(content: string | undefined): number { - return Math.min( - 320, - estimateTextHeight(content ?? '', USER_MESSAGE_BASE_HEIGHT_PX, USER_MESSAGE_LINE_HEIGHT_PX), - ); -} - -function estimateExploreGroupHeight(item: Extract): number { - const visibleRowCount = Math.min(10, item.data.allItems.length); - return Math.min(420, EXPLORE_GROUP_BASE_HEIGHT_PX + visibleRowCount * 24); +export function estimateVirtualMessageItemHeight(item: VirtualItem): number { + return estimateVirtualItemHeightByOwner(item).heightPx; } -export function estimateVirtualMessageItemHeight(item: VirtualItem): number { - switch (item.type) { - case 'user-message': - case 'user-steering-message': - return estimateUserMessageHeight(item.data.content); - case 'model-round': - return estimateModelRoundHeight(item); - case 'explore-group': - return estimateExploreGroupHeight(item); - case 'turn-completion-notice': - return 120; - case 'turn-failure-notice': - return 160; - case 'image-analyzing': - return LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX; - } +export function estimateVirtualMessageItemHeightWithContext( + item: VirtualItem, + context: VirtualItemHeightEstimateContext = {}, +): number { + return estimateVirtualItemHeightByOwner(item, context).heightPx; } export interface InitialHistoryRenderWindow {