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 1db6f52f79..24abc3d591 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -1303,14 +1303,15 @@ const VirtualMessageListSession = forwardRef { + if (!onViewportSnapshotRef.current) return; const snapshot = captureViewportSnapshot(sample); - if (snapshot) onViewportSnapshotRef.current?.(snapshot); + if (snapshot) onViewportSnapshotRef.current(snapshot); }, [captureViewportSnapshot]); const publishViewportSnapshotRef = useRef(publishViewportSnapshot); publishViewportSnapshotRef.current = publishViewportSnapshot; const scheduleViewportSnapshot = useCallback(() => { - if (viewportSnapshotFrameRef.current !== null) return; + if (!onViewportSnapshotRef.current || viewportSnapshotFrameRef.current !== null) return; viewportSnapshotFrameRef.current = requestAnimationFrame(() => { viewportSnapshotFrameRef.current = null; publishViewportSnapshotRef.current(); @@ -1605,20 +1606,21 @@ const VirtualMessageListSession = forwardRef 0.5 ? 'after' : undefined; + let geometrySample: FlowChatViewportGeometry | undefined; + const getGeometry = () => (geometrySample ??= readFlowChatViewportGeometry(scrollerElement)); // Synchronous corrections are removed by the register. Smooth owned // navigation/follow scrolls are excluded here; unowned momentum counts. if (direction && (owner === null || owner === 'user-gesture')) { - captureLeadingExtent(readFlowChatScrollTop(scrollerElement), true, geometry); + captureLeadingExtent(readFlowChatScrollTop(scrollerElement), true, getGeometry()); historyPager.readerIntent(direction); } - if (owner === 'one-shot-navigation') captureLeadingExtent(readFlowChatScrollTop(scrollerElement), true, geometry); + if (owner === 'one-shot-navigation') captureLeadingExtent(readFlowChatScrollTop(scrollerElement), true, getGeometry()); /* * A scroll under a scrollbar press is the one case where a plain scroll * event does carry intent — the press is what qualifies it. Left @@ -1638,9 +1640,14 @@ const VirtualMessageListSession = forwardRef 0 ? expandableIds : undefined, }]; }); } +const virtualItemSearchSourcesCache = new WeakMap(); + function getVirtualItemSearchSources(item: VirtualItem): SearchableSource[] { + const cached = virtualItemSearchSourcesCache.get(item); + if (cached) return cached; + let sources: SearchableSource[]; if (item.type === 'background-run') { - return [{ content: item.turn.userMessage.content }, ...flowItemSearchSources(item.turn.modelRounds.flatMap(round => round.items))]; - } - if (item.type === 'user-message' || item.type === 'user-steering-message') { - return [{ content: item.data?.content ?? '' }]; - } - if (item.type === 'model-round') { - return getProjectedModelRoundGroups(item).flatMap(group => group.type !== 'critical' - ? flowItemSearchSources(group.items, buildInlineFlowGroupData(item.data.id, group).groupId) + const userContent = item.turn.userMessage.content; + sources = [ + { content: userContent, foldedContent: userContent.toLowerCase() }, + ...flowItemSearchSources(item.turn.modelRounds.flatMap(round => round.items)), + ]; + } else if (item.type === 'user-message' || item.type === 'user-steering-message') { + const content = item.data?.content ?? ''; + sources = [{ content, foldedContent: content.toLowerCase() }]; + } else if (item.type === 'model-round') { + sources = getProjectedModelRoundGroups(item).flatMap((group, index) => group.type !== 'critical' + ? flowItemSearchSources(group.items, getCachedInlineFlowGroupData(item.data, index, group).groupId) : flowItemSearchSources([group.item])); + } else if (item.type === 'explore-group') { + sources = flowItemSearchSources(item.data.allItems, item.data.groupId); + } else if (item.type === 'turn-completion-notice') { + const content = item.data.reasonCode; + sources = [{ content, foldedContent: content.toLowerCase() }]; + } else if (item.type === 'turn-failure-notice') { + const content = [ + item.data.error, + item.data.errorDetail?.provider, + item.data.errorDetail?.providerCode, + item.data.errorDetail?.providerMessage, + item.data.errorDetail?.requestId, + ].filter(Boolean).join(' '); + sources = [{ content, foldedContent: content.toLowerCase() }]; + } else { + sources = []; } - if (item.type === 'explore-group') { - return flowItemSearchSources(item.data.allItems, item.data.groupId); - } - if (item.type === 'turn-completion-notice') { - return [{ content: item.data.reasonCode }]; - } - if (item.type === 'turn-failure-notice') { - return [{ - content: [ - item.data.error, - item.data.errorDetail?.provider, - item.data.errorDetail?.providerCode, - item.data.errorDetail?.providerMessage, - item.data.errorDetail?.requestId, - ].filter(Boolean).join(' '), - }]; - } - return []; + virtualItemSearchSourcesCache.set(item, sources); + return sources; } -function countQueryOccurrences(content: string, foldedQuery: string): number { - const haystack = content.toLowerCase(); +function countFoldedQueryOccurrences(foldedContent: string, foldedQuery: string): number { let count = 0; let from = 0; for (;;) { - const index = haystack.indexOf(foldedQuery, from); + const index = foldedContent.indexOf(foldedQuery, from); if (index < 0) { return count; } @@ -118,20 +128,36 @@ function countQueryOccurrences(content: string, foldedQuery: string): number { } } +const emptySearchMatches: SearchMatch[] = []; +const perItemSearchMatchCache = new WeakMap(); + export function buildFlowChatSearchMatches( virtualItems: readonly VirtualItem[], searchQuery: string, ): SearchMatch[] { const trimmed = searchQuery.trim(); - if (!trimmed) return []; + if (!trimmed) return emptySearchMatches; const query = trimmed.toLowerCase(); const matches: SearchMatch[] = []; - virtualItems.forEach((item, virtualItemIndex) => { + for (let virtualItemIndex = 0; virtualItemIndex < virtualItems.length; virtualItemIndex += 1) { + const item = virtualItems[virtualItemIndex]; + const cached = perItemSearchMatchCache.get(item); + if (cached && cached.query === query && cached.virtualItemIndex === virtualItemIndex) { + for (let i = 0; i < cached.matches.length; i += 1) { + matches.push(cached.matches[i]); + } + continue; + } + const itemMatches: SearchMatch[] = []; for (const source of getVirtualItemSearchSources(item)) { - const occurrenceCount = countQueryOccurrences(source.content, query); + const occurrenceCount = countFoldedQueryOccurrences(source.foldedContent, query); for (let occurrenceIndex = 0; occurrenceIndex < occurrenceCount; occurrenceIndex += 1) { - matches.push({ + itemMatches.push({ virtualItemIndex, turnId: item.turnId, type: item.type, @@ -141,7 +167,11 @@ export function buildFlowChatSearchMatches( }); } } - }); + perItemSearchMatchCache.set(item, { query, virtualItemIndex, matches: itemMatches }); + for (let i = 0; i < itemMatches.length; i += 1) { + matches.push(itemMatches[i]); + } + } return matches; } 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 3c352c7922..9413cee6a1 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -42,6 +42,25 @@ function useMeasuredVirtualizer(options: VirtualizerOptions { if (!mounted.current || (!sync && requested.current)) return; + if (commits.current < MAX_SYNCHRONOUS_VIRTUALIZER_COMMITS) { + pending.current = false; + requested.current = true; + commits.current += 1; + if (frame.current === null) { + frame.current = requestAnimationFrame(() => { + frame.current = null; + commits.current = 0; + if (!mounted.current || !pending.current) return; + pending.current = false; + requested.current = true; + commits.current = 1; + rerender(); + }); + } + if (sync) flushSync(rerender); + else rerender(); + return; + } pending.current = true; if (frame.current === null) { frame.current = requestAnimationFrame(() => { @@ -56,12 +75,6 @@ function useMeasuredVirtualizer(options: VirtualizerOptions= MAX_SYNCHRONOUS_VIRTUALIZER_COMMITS) return; - pending.current = false; - requested.current = true; - commits.current += 1; - if (sync) flushSync(rerender); - else rerender(); }, []); instance.setOptions({ @@ -756,7 +769,8 @@ export function useFlowChatVirtualizer({ const key = keys[index] ?? getItemKeyRef.current(item); const knownHeight = getKnownItemHeightPx(item); if (knownHeight !== undefined) next.set(key, knownHeight); - if (knownHeight === undefined && !previous.has(key)) continue; + const prevHeight = previous.get(key); + if (knownHeight === prevHeight) continue; // A mounted row has real geometry. An unmounted row must have its old // key-based measurement replaced now; otherwise virtual padding keeps @@ -814,15 +828,18 @@ export function useFlowChatVirtualizer({ measuredOriginRef.current = endOrigin; // Stable DOM rows keep their chronological index while an origin handoff // changes the library index. ResizeObserver must see the new mapping too. - const mountedRows = [...(scroller?.querySelectorAll('[data-virtual-index]') ?? [])] - .filter(element => element.closest('[data-flowchat-virtual-viewport]') === scroller); - for (const element of mountedRows) { - element.setAttribute('data-flowchat-measure-index', String(toVirtualIndex(Number(element.getAttribute('data-virtual-index'))))); + const attached = originChanged + ? [...(scroller?.querySelectorAll('[data-virtual-index]') ?? [])] + .filter(element => element.closest('[data-flowchat-virtual-viewport]') === scroller) + : [...attachingRowsRef.current].filter(element => element.isConnected); + if (originChanged) { + for (const element of attached) { + element.setAttribute('data-flowchat-measure-index', String(toVirtualIndex(Number(element.getAttribute('data-virtual-index'))))); + } } - const attached = originChanged ? mountedRows : [...attachingRowsRef.current].filter(element => element.isConnected); attachingRowsRef.current.clear(); - virtualizer.measureElement(null); if (!attached.length || isViewportSuspendedRef.current()) return; + virtualizer.measureElement(null); // Read before any viewport shift or size-cache notification can write. measurementBatchRef.current = new Map(attached.map(element => [element, measureRowHeight(element)])); try { diff --git a/src/web-ui/src/flow_chat/grouping/groupProjection.ts b/src/web-ui/src/flow_chat/grouping/groupProjection.ts index 30396a2942..e8c0d123a9 100644 --- a/src/web-ui/src/flow_chat/grouping/groupProjection.ts +++ b/src/web-ui/src/flow_chat/grouping/groupProjection.ts @@ -4,9 +4,59 @@ import type { FlowGroupData, ModelRoundItemGroup } from './types'; import { getFlowGroupCategory, getFlowGroupStateIds } from './types'; import { canContinueFlowGroup, flowGroupPolicies, hasPendingFlowGroupClassification, joinedFlowGroupCategory } from './policies'; import { flowGroupLifecycle } from './lifecycle'; -import { buildInlineFlowGroupData, getModelRoundFlowGroups, getProjectedModelRoundGroups, +import { getCachedInlineFlowGroupData, getModelRoundFlowGroups, getProjectedModelRoundGroups, flowGroupCompanionStart, hasModelRoundLeadingControls, isFlowGroupCompanion, isModelRoundGroupingDisabled, projectedFlowGroup } from './roundGroups'; +export function canStartFreshFlowGroupRunAt( + prevItem: VirtualItem | undefined, + currItem: VirtualItem, +): boolean { + if (currItem.type !== 'model-round') { + return currItem.type !== 'explore-group'; + } + const disabled = isModelRoundGroupingDisabled(currItem.data); + if (disabled || hasModelRoundLeadingControls(currItem.data)) return true; + const currGroups = getProjectedModelRoundGroups(currItem, true); + let firstCurrIndex = 0; + while (firstCurrIndex < currGroups.length) { + const g = currGroups[firstCurrIndex]; + if (g.type === 'critical' && isFlowGroupCompanion(g.item)) { + firstCurrIndex += 1; + continue; + } + break; + } + if (firstCurrIndex >= currGroups.length) return false; + const firstCurr = currGroups[firstCurrIndex]; + if (firstCurr.type === 'critical') { + return !hasPendingFlowGroupClassification(firstCurr.item); + } + + // Current round starts with a collectible group (`firstCurr`). + // Check if `prevItem` ended with an open group of a DIFFERENT category or a hard boundary. + if (!prevItem || prevItem.type !== 'model-round') return false; + if (isModelRoundGroupingDisabled(prevItem.data)) return true; + const prevGroups = getProjectedModelRoundGroups(prevItem, true); + // If prevGroups has any trailing companion items after its last non-companion, + // those companions could be absorbed into `currItem` if we don't include `prevItem`. + const lastPrev = prevGroups[prevGroups.length - 1]; + if (!lastPrev) return false; + if (lastPrev.type === 'critical') { + if (isFlowGroupCompanion(lastPrev.item) || hasPendingFlowGroupClassification(lastPrev.item)) { + return false; + } + return true; + } + // `lastPrev` is a collectible group at the very end of `prevItem` (no trailing prose/thinking). + // If `firstCurr` has any leading companions, `collect` would behave differently if `continuing` were true vs false, + // so only cut when `firstCurrIndex === 0` AND the two group categories cannot continue together. + if (firstCurrIndex > 0) return false; + return ( + joinedFlowGroupCategory(lastPrev.type, firstCurr.type) === undefined || + !canContinueFlowGroup(firstCurr.type, lastPrev.items, firstCurr.items) + ); +} + /** Count only until the policy minimum; the shared threshold helper copies the item list. */ function reachesFlowGroupThreshold(category: ReturnType, items: readonly FlowItem[]): boolean { const minimum = flowGroupPolicies[category].minimumItems; @@ -34,9 +84,22 @@ function sourceGroupIds(group: FlowGroupData): readonly string[] { return [...ids]; } +const mergedFlowGroupRunCache = new WeakMap(); + /** Finalize once per run instead of repeatedly copying and recounting its growing prefix. */ function mergeFlowGroupRun(parts: readonly FlowGroupData[]): FlowGroupData { const first = parts[0]; + const cached = mergedFlowGroupRunCache.get(first); + if ( + cached && + cached.parts.length === parts.length && + cached.parts.every((part, idx) => part === parts[idx]) + ) { + return cached.merged; + } const last = parts[parts.length - 1]; const mergedIds = new Set(); const rounds: ModelRound[] = []; @@ -57,7 +120,7 @@ function mergeFlowGroupRun(parts: readonly FlowGroupData[]): FlowGroupData { for (const item of part.allItems) allItems.push(item); if (part.isGroupStreaming) isGroupStreaming = true; } - return flowGroupPolicies[category ?? getFlowGroupCategory(first)].create({ + const merged = flowGroupPolicies[category ?? getFlowGroupCategory(first)].create({ ...first, sourceGroupIds: [...mergedIds], rounds, @@ -66,6 +129,8 @@ function mergeFlowGroupRun(parts: readonly FlowGroupData[]): FlowGroupData { isLastGroupInTurn: last.isLastGroupInTurn, wasCutByCritical: last.wasCutByCritical, }); + mergedFlowGroupRunCache.set(first, { parts: [...parts], merged }); + return merged; } /** @@ -180,11 +245,15 @@ export function projectAdjacentFlowGroups(items: readonly VirtualItem[], options else boundary(); return; } - let data = buildInlineFlowGroupData(item.data.id, group, item.isLastRound && groupIndex === groups.length - 1); // Read saved disclosure choices from the previous whole-round projection. - if (group.type === 'explore' && firstExploreIndex === groupIndex) { - data = { ...data, sourceGroupIds: [...new Set([...getFlowGroupStateIds(data), item.data.id])] }; - } + const includeRoundIdAlias = group.type === 'explore' && firstExploreIndex === groupIndex; + const data = getCachedInlineFlowGroupData( + item.data, + groupIndex, + group, + item.isLastRound && groupIndex === groups.length - 1, + includeRoundIdAlias, + ); collect(data, next => { renderedGroups[groupIndex] = projectedFlowGroup(getFlowGroupCategory(next), next.allItems, group.isLast, next); changed = true; diff --git a/src/web-ui/src/flow_chat/grouping/lifecycle.ts b/src/web-ui/src/flow_chat/grouping/lifecycle.ts index 34b53e883c..970c1a68f4 100644 --- a/src/web-ui/src/flow_chat/grouping/lifecycle.ts +++ b/src/web-ui/src/flow_chat/grouping/lifecycle.ts @@ -10,14 +10,30 @@ export function isFlowGroupMemberActive(item: FlowItem): boolean { || ((item.type === 'thinking' || item.type === 'text') && (item as FlowTextItem).isStreaming); } +const lifecycleCache = new WeakMap; + sealedFalse?: Pick; +}>(); + /** Boundary and execution are independent: a sealed run can still be draining. */ export function flowGroupLifecycle(items: readonly FlowItem[], sealed: boolean): Pick { + let entry = lifecycleCache.get(items); + if (entry) { + const cached = sealed ? entry.sealedTrue : entry.sealedFalse; + if (cached) return cached; + } else { + entry = {}; + lifecycleCache.set(items, entry); + } const active = items.some(isFlowGroupMemberActive); const shell = shellActivityStats(items.filter((item): item is FlowToolItem => item.type === 'tool' && isShellActivity(item as FlowToolItem))); - return { + const result: Pick = { phase: !sealed ? 'collecting' : active ? 'settling' : 'settled', isGroupStreaming: active, needsAttention: shell.failed > 0 || shell.stopped > 0 || items.some(item => ['error', 'pending_confirmation', 'cancelled', 'rejected'].includes(item.status) || (item.type === 'tool' && (item as FlowToolItem).toolResult?.success === false)), }; + if (sealed) entry.sealedTrue = result; + else entry.sealedFalse = result; + return result; } diff --git a/src/web-ui/src/flow_chat/grouping/receiveFeedback.ts b/src/web-ui/src/flow_chat/grouping/receiveFeedback.ts index 204d8662c2..f5def4373c 100644 --- a/src/web-ui/src/flow_chat/grouping/receiveFeedback.ts +++ b/src/web-ui/src/flow_chat/grouping/receiveFeedback.ts @@ -10,21 +10,48 @@ interface FeedbackSnapshot { groupedMembers: ReadonlySet; } -const memberKey = (turnId: string, itemId: string) => JSON.stringify([turnId, itemId]); +const memberKey = (turnId: string, itemId: string) => `${turnId}\u0000${itemId}`; + +const itemFeedbackKeysCache = new WeakMap(); + +function getItemFeedbackKeys(item: VirtualItem) { + let cached = itemFeedbackKeysCache.get(item); + if (cached) return cached; + const groupedKeys: string[] = []; + for (const group of getVirtualItemFlowGroups(item)) { + for (const member of group.allItems) { + groupedKeys.push(memberKey(item.turnId, member.id)); + } + } + const activeKeys: string[] = []; + if (item.type === 'model-round' && !item.isTurnComplete) { + for (const member of getModelRoundActiveItems(item.data)) { + if ( + member.type === 'tool' && + ((member as FlowToolItem).isParamsStreaming || + ['pending', 'queued', 'preparing', 'running', 'streaming', 'pending_confirmation'].includes(member.status)) + ) { + activeKeys.push(memberKey(item.turnId, member.id)); + } + } + } + cached = { activeKeys, groupedKeys }; + itemFeedbackKeysCache.set(item, cached); + return cached; +} export function captureFlowGroupFeedbackSnapshot(items: readonly VirtualItem[], scope: string | undefined): FeedbackSnapshot { const activeMembers = new Set(); const groupedMembers = new Set(); for (const item of items) { - for (const group of getVirtualItemFlowGroups(item)) { - group.allItems.forEach(member => groupedMembers.add(memberKey(item.turnId, member.id))); - } - if (item.type !== 'model-round' || item.isTurnComplete) continue; - for (const member of getModelRoundActiveItems(item.data)) { - if (member.type === 'tool' && ((member as FlowToolItem).isParamsStreaming - || ['pending', 'queued', 'preparing', 'running', 'streaming', 'pending_confirmation'].includes(member.status))) { - activeMembers.add(memberKey(item.turnId, member.id)); - } + if (item.type === 'model-round' && item.isTurnComplete) continue; + const { activeKeys, groupedKeys } = getItemFeedbackKeys(item); + for (let i = 0; i < activeKeys.length; i += 1) activeMembers.add(activeKeys[i]); + if (activeKeys.length > 0 || groupedKeys.length > 0) { + for (let i = 0; i < groupedKeys.length; i += 1) groupedMembers.add(groupedKeys[i]); } } return { scope, activeMembers, groupedMembers }; @@ -33,7 +60,7 @@ export function captureFlowGroupFeedbackSnapshot(items: readonly VirtualItem[], /** Legacy collection feedback; live windows already show arrivals without a pulse. */ export function collectFlowGroupReceiveFeedback(previous: FeedbackSnapshot | undefined, items: readonly VirtualItem[], scope: string | undefined): ReadonlyMap { const receipts = new Map(); - if (!previous || previous.scope !== scope) return receipts; + if (!previous || previous.scope !== scope || previous.activeMembers.size === 0) return receipts; for (const item of items) { for (const group of getVirtualItemFlowGroups(item)) { if (group.phase !== undefined) continue; diff --git a/src/web-ui/src/flow_chat/grouping/roundGroups.ts b/src/web-ui/src/flow_chat/grouping/roundGroups.ts index 5ef9b07d5d..2bf2fa0afc 100644 --- a/src/web-ui/src/flow_chat/grouping/roundGroups.ts +++ b/src/web-ui/src/flow_chat/grouping/roundGroups.ts @@ -5,11 +5,11 @@ import { canContinueFlowGroup, classifyFlowGroupItem, flowGroupPolicies, joinedF import { isFlowGroupMemberActive } from './lifecycle'; import { isShellActivity } from './activityClassification'; import { parseDeepResearchContent } from '../deep-research/deepResearchProtocol'; -import type { ExploreGroupData, FlowGroupCategory, FlowGroupData, GroupedFlowItems, ModelRoundItemGroup } from './types'; +import { getFlowGroupStateIds, isFlowGroupExpanded, type ExploreGroupData, type FlowGroupCategory, type FlowGroupData, type GroupedFlowItems, type ModelRoundItemGroup } from './types'; export type { ModelRoundItemGroup } from './types'; -export { getFlowGroupStateIds, isFlowGroupExpanded } from './types'; -export { getFlowGroupStateIds as getExploreGroupStateIds, isFlowGroupExpanded as isExploreGroupExpanded } from './types'; +export { getFlowGroupStateIds, isFlowGroupExpanded }; +export { getFlowGroupStateIds as getExploreGroupStateIds, isFlowGroupExpanded as isExploreGroupExpanded }; /** Match the visible attempt rather than collecting failed retry history. */ export function getModelRoundActiveItems(round: Pick): FlowItem[] { @@ -92,22 +92,105 @@ export function buildInlineFlowGroupData(roundId: string, group: GroupedFlowItem }); } +const roundGroupsCache = new WeakMap; +}>(); + +function getOrCreateRoundGroupsCacheEntry(round: ModelRound, disabled: boolean) { + let entry = roundGroupsCache.get(round); + if (!entry || entry.itemsRef !== round.items || entry.attemptsRef !== round.attempts || entry.disabled !== disabled) { + entry = { + itemsRef: round.items, + attemptsRef: round.attempts, + disabled, + }; + roundGroupsCache.set(round, entry); + } + return entry; +} + +export function getCachedInlineFlowGroupData( + round: ModelRound, + groupIndex: number, + group: GroupedFlowItems, + isLastGroupInTurn = group.isLast, + includeRoundIdAlias = false, +): FlowGroupData { + if (group.projection) return group.projection; + const disabled = isModelRoundGroupingDisabled(round); + const entry = getOrCreateRoundGroupsCacheEntry(round, disabled); + const key = groupIndex * 4 + (isLastGroupInTurn ? 2 : 0) + (includeRoundIdAlias ? 1 : 0); + let byKey = entry.inlineGroupsByKey; + if (!byKey) { + byKey = new Map(); + entry.inlineGroupsByKey = byKey; + } + let cached = byKey.get(key); + if (!cached) { + cached = buildInlineFlowGroupData(round.id, group, isLastGroupInTurn); + if (includeRoundIdAlias) { + cached = { + ...cached, + sourceGroupIds: [...new Set([...getFlowGroupStateIds(cached), round.id])], + }; + } + byKey.set(key, cached); + } + return cached; +} + /** The category and projection are constructed together by the selected policy. */ export function projectedFlowGroup(category: FlowGroupCategory, items: FlowItem[], isLast: boolean, projection?: FlowGroupData): GroupedFlowItems { return { type: category, items, isLast, ...(projection ? { projection } : {}) } as GroupedFlowItems; } export function getModelRoundFlowGroups(round: ModelRound, projectedGroups?: ModelRoundItemGroup[]): FlowGroupData[] { - return (projectedGroups ?? buildFlowItemGroups({ - items: getModelRoundActiveItems(round), disabled: isModelRoundGroupingDisabled(round), - })).flatMap(group => group.type === 'critical' ? [] : [buildInlineFlowGroupData(round.id, group)]); + if (projectedGroups) { + return projectedGroups.flatMap((group, index) => group.type === 'critical' + ? [] + : [getCachedInlineFlowGroupData(round, index, group)]); + } + const disabled = isModelRoundGroupingDisabled(round); + const entry = getOrCreateRoundGroupsCacheEntry(round, disabled); + if (!entry.groups) { + entry.groups = buildFlowItemGroups({ + items: getModelRoundActiveItems(round), + disabled, + }); + } + return entry.groups.flatMap((group, index) => group.type === 'critical' + ? [] + : [getCachedInlineFlowGroupData(round, index, group)]); } export function getProjectedModelRoundGroups(item: Extract, retainCandidates = false): ModelRoundItemGroup[] { - return item.projectedGroups ?? buildFlowItemGroups({ - items: getModelRoundActiveItems(item.data), - disabled: isModelRoundGroupingDisabled(item.data), retainCandidates, - }); + if (item.projectedGroups) return item.projectedGroups; + const round = item.data; + const disabled = isModelRoundGroupingDisabled(round); + const entry = getOrCreateRoundGroupsCacheEntry(round, disabled); + if (retainCandidates) { + if (!entry.retainedCandidateGroups) { + entry.retainedCandidateGroups = buildFlowItemGroups({ + items: getModelRoundActiveItems(round), + disabled, + retainCandidates: true, + }); + } + return entry.retainedCandidateGroups; + } + if (!entry.groups) { + entry.groups = buildFlowItemGroups({ + items: getModelRoundActiveItems(round), + disabled, + retainCandidates: false, + }); + } + return entry.groups; } interface BuildFlowItemGroupsInput extends FlowGroupPolicyOptions { diff --git a/src/web-ui/src/flow_chat/grouping/selectors.ts b/src/web-ui/src/flow_chat/grouping/selectors.ts index bd0876e644..84b7bd3208 100644 --- a/src/web-ui/src/flow_chat/grouping/selectors.ts +++ b/src/web-ui/src/flow_chat/grouping/selectors.ts @@ -2,10 +2,22 @@ import type { VirtualItem } from '../types/flow-chat-projection'; import { getModelRoundFlowGroups } from './roundGroups'; import { getFlowGroupStateIds, type FlowGroupBase, type FlowGroupData } from './types'; +const virtualItemFlowGroupsCache = new WeakMap(); +const emptyFlowGroups: FlowGroupData[] = []; + export function getVirtualItemFlowGroups(item: VirtualItem): FlowGroupData[] { - if (item.type === 'explore-group') return [item.data]; - if (item.type === 'model-round') return getModelRoundFlowGroups(item.data, item.projectedGroups); - return []; + const cached = virtualItemFlowGroupsCache.get(item); + if (cached) return cached; + let result: FlowGroupData[]; + if (item.type === 'explore-group') { + result = [item.data]; + } else if (item.type === 'model-round') { + result = getModelRoundFlowGroups(item.data, item.projectedGroups); + } else { + return emptyFlowGroups; + } + virtualItemFlowGroupsCache.set(item, result); + return result; } export interface FlowGroupLocation { @@ -14,12 +26,61 @@ export interface FlowGroupLocation { virtualIndex: number; } +let lastIndexedItems: readonly VirtualItem[] = []; +let lastIndexedById = new Map(); +let lastIndexedByMemberId = new Map(); +let lastIndexedByTurnId = new Map(); + /** One ownership index for disclosure and navigation, including merged legacy aliases. */ export function indexFlowGroups(items: readonly VirtualItem[]) { - const byId = new Map(); - const byMemberId = new Map(); + if (items === lastIndexedItems) { + return { byId: lastIndexedById, byMemberId: lastIndexedByMemberId, byTurnId: lastIndexedByTurnId }; + } + + let prefixLen = 0; + const maxPrefix = Math.min(lastIndexedItems.length, items.length); + while (prefixLen < maxPrefix && lastIndexedItems[prefixLen] === items[prefixLen]) { + prefixLen += 1; + } + + // Only reuse prefix maps when strictly appending or mutating the tail (no removed items from the replaced suffix) + const canReusePrefix = prefixLen > 0 && prefixLen >= lastIndexedItems.length - 2; + const byId = canReusePrefix ? new Map(lastIndexedById) : new Map(); + const byMemberId = canReusePrefix ? new Map(lastIndexedByMemberId) : new Map(); const byTurnId = new Map(); - items.forEach((item, virtualIndex) => { + + if (canReusePrefix) { + // Remove entries from the replaced suffix of lastIndexedItems before indexing the new suffix + const affectedTurnIds = new Set(); + for (let i = prefixLen; i < lastIndexedItems.length; i += 1) { + const oldItem = lastIndexedItems[i]; + affectedTurnIds.add(oldItem.turnId); + for (const group of getVirtualItemFlowGroups(oldItem)) { + for (const id of getFlowGroupStateIds(group)) byId.delete(id); + byId.delete(group.groupId); + for (const member of group.allItems) { + if (byMemberId.get(member.id)?.virtualIndex === i) { + byMemberId.delete(member.id); + } + } + } + } + for (let i = prefixLen; i < items.length; i += 1) { + affectedTurnIds.add(items[i].turnId); + } + for (const [turnId, locations] of lastIndexedByTurnId) { + if (!affectedTurnIds.has(turnId)) { + byTurnId.set(turnId, locations); + } else { + const kept = locations.filter(loc => loc.virtualIndex < prefixLen); + if (kept.length > 0) byTurnId.set(turnId, kept); + } + } + } + + const startIndex = canReusePrefix ? prefixLen : 0; + for (let virtualIndex = startIndex; virtualIndex < items.length; virtualIndex += 1) { + const item = items[virtualIndex]; for (const group of getVirtualItemFlowGroups(item)) { const location = { group, turnId: item.turnId, virtualIndex }; for (const id of getFlowGroupStateIds(group)) byId.set(id, location); @@ -29,6 +90,11 @@ export function indexFlowGroups(items: readonly VirtualItem[]) { turnGroups.push(location); byTurnId.set(item.turnId, turnGroups); } - }); + } + + lastIndexedItems = items; + lastIndexedById = byId; + lastIndexedByMemberId = byMemberId; + lastIndexedByTurnId = byTurnId; return { byId, byMemberId, byTurnId }; } diff --git a/src/web-ui/src/flow_chat/perf/longRoundPerfHarness.test.tsx b/src/web-ui/src/flow_chat/perf/longRoundPerfHarness.test.tsx new file mode 100644 index 0000000000..03fc4fc3c7 --- /dev/null +++ b/src/web-ui/src/flow_chat/perf/longRoundPerfHarness.test.tsx @@ -0,0 +1,646 @@ +// @vitest-environment jsdom +import React, { Profiler, act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { VirtualMessageList } from '../components/modern/VirtualMessageList'; +import { getVirtualItemStableKey } from '../components/modern/virtualItemIdentity'; +import { sessionToVirtualItems, type VirtualItem } from '../store/modernFlowChatStore'; +import { useFlowGroupState } from '../components/modern/useFlowGroupState'; +import { FlowChatContext, FlowChatVolatileContext } from '../components/modern/FlowChatContext'; +import { + benchBackgroundBudgetAndPersistence, + benchGroupStateAndSearch, + benchStoreProjection, + benchTimelineDocument, + buildLongRoundSessionFixture, + formatBenchmarkReport, + runLongRoundPipelineBenchmark, +} from './longRoundPerfHarness'; +import type { DialogTurn, FlowTextItem, ModelRound, Session } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const harnessStoreState = vi.hoisted(() => ({ + items: [] as VirtualItem[], + session: null as Session | null, + isProcessing: true, +})); + +const renderTelemetry = vi.hoisted(() => ({ + virtualListCommits: 0, + virtualListActualDurationMs: 0, + rowRendersByKey: new Map(), + reset() { + this.virtualListCommits = 0; + this.virtualListActualDurationMs = 0; + this.rowRendersByKey.clear(); + }, +})); + +vi.mock('../store/modernFlowChatStore', async (importOriginal) => { + const actual = await importOriginal(); + const store = Object.assign( + (select: (state: unknown) => unknown) => + select({ + visibleTurnInfo: null, + activeSession: harnessStoreState.session, + virtualItems: harnessStoreState.items, + }), + { + getState: () => ({ + setVisibleTurnInfo() {}, + activeSession: harnessStoreState.session, + virtualItems: harnessStoreState.items, + }), + } + ); + return { + ...actual, + useVirtualItems: () => harnessStoreState.items, + useActiveSession: () => harnessStoreState.session, + useModernFlowChatStore: store, + useModernFlowChatStoreApi: () => store, + }; +}); + +vi.mock('../hooks/useActiveSessionState', () => ({ + useActiveSessionState: () => ({ + sessionId: harnessStoreState.session?.sessionId ?? null, + isProcessing: harnessStoreState.isProcessing, + processingPhase: null, + error: null, + status: harnessStoreState.isProcessing ? 'active' : 'idle', + }), +})); + +vi.mock('../store/chatInputStateStore', () => ({ + useChatInputState: (select: (state: unknown) => unknown) => select({ inputHeight: 100 }), +})); + +vi.mock('../hooks/useScrollToTurnHeader', () => ({ + useScrollToTurnHeader: () => ({ shouldShowButton: false, handleClick() {} }), +})); + +vi.mock('../components/modern/RuntimeStatusSlot', () => ({ + RuntimeStatusSlot: () => null, +})); + +vi.mock('../components/ScrollToLatestBar', () => ({ + ScrollToLatestBar: () => null, +})); + +vi.mock('../components/ScrollToTurnHeaderButton', () => ({ + ScrollToTurnHeaderButton: () => null, +})); + +// Keep React.memo semantics identical to production VirtualItemRenderer while +// recording per-row render counts and subscribing to FlowChatVolatileContext. +vi.mock('../components/modern/VirtualItemRenderer', async () => { + const ReactModule = await import('react'); + const { useFlowChatVolatileContext } = await import('../components/modern/FlowChatContext'); + const { getVirtualItemStableKey: stableKey } = await import('../components/modern/virtualItemIdentity'); + + const InstrumentedRow = ReactModule.memo( + ({ + item, + index, + measureRef, + }: { + item: VirtualItem; + index: number; + isLatestTurn?: boolean; + endsBeforeUserTurn?: boolean; + continuesAmbientToolRunAfter?: boolean; + measureRef?: (element: HTMLElement | null) => void; + }) => { + useFlowChatVolatileContext(); + const key = stableKey(item); + renderTelemetry.rowRendersByKey.set(key, (renderTelemetry.rowRendersByKey.get(key) ?? 0) + 1); + return ( +
+ ); + }, + (prev, next) => + prev.item === next.item && + prev.index === next.index && + prev.isLatestTurn === next.isLatestTurn && + prev.endsBeforeUserTurn === next.endsBeforeUserTurn && + prev.continuesAmbientToolRunAfter === next.continuesAmbientToolRunAfter && + prev.measureRef === next.measureRef + ); + + return { VirtualItemRenderer: InstrumentedRow }; +}); + +function LiveContainerHarness({ latestTurnId }: { latestTurnId: string }) { + const session = harnessStoreState.session; + const virtualItems = harnessStoreState.items; + const { + groupStates, + groupReceiveFeedback, + expandedToolCapsules, + onToolCapsuleExpandedChange, + onGroupToggle, + onExpandGroup, + onExpandAllInTurn, + onCollapseGroup, + } = useFlowGroupState( + virtualItems, + undefined, + session?.sessionId, + undefined, + `perf:${session?.sessionId ?? ''}` + ); + + const contextValue = React.useMemo( + () => ({ + sessionId: session?.sessionId, + workspaceId: session?.config?.workspaceId, + workspacePath: session?.config?.workspacePath, + onGroupToggle, + onExploreGroupToggle: onGroupToggle, + onExpandGroup, + onExpandAllInTurn, + onCollapseGroup, + onToolCapsuleExpandedChange, + }), + [ + session?.sessionId, + session?.config?.workspaceId, + session?.config?.workspacePath, + onGroupToggle, + onExpandGroup, + onExpandAllInTurn, + onCollapseGroup, + onToolCapsuleExpandedChange, + ] + ); + + const volatileValue = React.useMemo( + () => ({ + pendingPermissionToolCallIds: new Set(), + groupStates, + groupReceiveFeedback, + exploreGroupStates: groupStates, + expandedToolCapsules, + searchQuery: '', + searchMatchesByVirtualIndex: new Map(), + searchCurrentMatch: undefined, + }), + [groupStates, groupReceiveFeedback, expandedToolCapsules] + ); + + return ( + + + { + renderTelemetry.virtualListCommits += 1; + renderTelemetry.virtualListActualDurationMs += actualDuration; + }} + > + + + + + ); +} + +describe('FlowChat Long-Round Categorized Benchmark Suites', () => { + describe('Category 1: Store & Adjacent Group Projection [bench:store-projection]', () => { + it('benchmarks sessionToVirtualItems & projectAdjacentFlowGroups scaling and reference reuse', () => { + const result = benchStoreProjection({ + checkpoints: [10, 50, 100, 200, 300], + chunksPerCheckpoint: 5, + }); + + console.info( + [ + `=== [Category 1: Store Projection] ===`, + `Total Time (20 flushes) : ${result.totalMs.toFixed(2)} ms (avg ${result.avgFlushMs.toFixed(2)} ms/flush)`, + `Scaling (10 -> 300 rounds) : ${result.earlyFlushAvgMs.toFixed(2)} ms -> ${result.lateFlushAvgMs.toFixed(2)} ms (${result.scalingRatio.toFixed(2)}x)`, + `Settled VirtualItem Ref Reuse : ${(result.settledVirtualItemReuseRatio * 100).toFixed(1)}%`, + ].join('\n') + ); + + expect(result.totalFlushes).toBe(20); + }); + }); + + describe('Category 2: Timeline Document & Rhythm Pass [bench:timeline-document]', () => { + it('benchmarks ConversationDocumentProjection.project & getNextVisibleVirtualItemIndexes', () => { + const result = benchTimelineDocument({ + checkpoints: [10, 50, 100, 200, 300], + chunksPerCheckpoint: 5, + }); + + console.info( + [ + `=== [Category 2: Timeline Document & Rhythm] ===`, + `Document Projection Total : ${result.documentProjectionTotalMs.toFixed(2)} ms`, + `Rhythm Visible-Index Total : ${result.rhythmPassTotalMs.toFixed(2)} ms`, + `Scaling (10 -> 300 rounds) : ${result.earlyFlushAvgMs.toFixed(2)} ms -> ${result.lateFlushAvgMs.toFixed(2)} ms (${result.scalingRatio.toFixed(2)}x)`, + `Current TimelineBlock Ref Reuse : ${(result.settledTimelineBlockReuseRatio * 100).toFixed(1)}%`, + `Isolated Reuse (if Store stable) : ${(result.isolatedSettledTimelineBlockReuseRatio * 100).toFixed(1)}% (${result.isolatedAvgFlushMs.toFixed(2)} ms/flush at 300 rounds)`, + ].join('\n') + ); + + expect(result.totalFlushes).toBe(20); + }); + }); + + describe('Category 3: Container Group State, Feedback & Search [bench:group-state-search]', () => { + it('benchmarks indexFlowGroups, receiveFeedback, and buildFlowChatSearchMatches', () => { + const result = benchGroupStateAndSearch({ + checkpoints: [10, 50, 100, 200, 300], + chunksPerCheckpoint: 5, + }); + + console.info( + [ + `=== [Category 3: Container Group Index, Feedback & Search] ===`, + `indexFlowGroups Total : ${result.indexFlowGroupsTotalMs.toFixed(2)} ms`, + `receiveFeedback Total : ${result.receiveFeedbackTotalMs.toFixed(2)} ms`, + `buildFlowChatSearchMatches Total : ${result.searchPassTotalMs.toFixed(2)} ms`, + `Scaling (10 -> 300 rounds) : ${result.earlyCombinedAvgMs.toFixed(2)} ms -> ${result.lateCombinedAvgMs.toFixed(2)} ms (${result.scalingRatio.toFixed(2)}x)`, + ].join('\n') + ); + + expect(result.totalFlushes).toBe(20); + }); + }); + + describe('Category 4: Background Memory Budget & Persistence Hash [bench:budget-persistence]', () => { + it('benchmarks estimateRetainedBytes and calculateTurnHash across growing turns', () => { + const result = benchBackgroundBudgetAndPersistence({ + checkpoints: [10, 50, 100, 200, 300], + iterationsPerCheckpoint: 5, + }); + + console.info( + [ + `=== [Category 4: Background Budget & Persistence] ===`, + `estimateRetainedBytes Total : ${result.retainedBytesTotalMs.toFixed(2)} ms (${result.retainedBytesEarlyAvgMs.toFixed(2)} ms -> ${result.retainedBytesLateAvgMs.toFixed(2)} ms, ${result.retainedBytesScalingRatio.toFixed(2)}x)`, + `calculateTurnHash Total : ${result.turnHashTotalMs.toFixed(2)} ms (${result.turnHashEarlyAvgMs.toFixed(2)} ms -> ${result.turnHashLateAvgMs.toFixed(2)} ms)`, + `Estimated Turn Size @ 300 rounds : ${(result.estimatedActiveTurnBytesAtMaxRound / 1024).toFixed(1)} KB`, + ].join('\n') + ); + + expect(result.estimatedActiveTurnBytesAtMaxRound).toBeGreaterThan(0); + }); + }); + + describe('Category 5: React Framework & Virtualizer DOM Interaction [bench:react-virtualizer]', () => { + let root: Root; + let host: HTMLDivElement; + let frames: Map; + let frameId: number; + let now: number; + let observers: Array<{ + callback: ResizeObserverCallback; + targets: Set; + sizes: WeakMap; + }>; + const heights = new Map(); + const offsets = new WeakMap(); + const px = (value: string) => Number.parseFloat(value || '0') || 0; + const realHrTimeMs = () => Number(process.hrtime.bigint()) / 1_000_000; + + function measureElementHeight(element: HTMLElement, measured = new WeakMap()): number { + const cached = measured.get(element); + if (cached !== undefined) return cached; + let result = 80; + if (element.hasAttribute('data-flowchat-scroller')) { + result = 720; + } else if (element.dataset.virtualItemKey) { + result = heights.get(element.dataset.virtualItemKey) ?? 84; + } else if (element.style.height) { + result = px(element.style.height); + } else { + result = Math.max( + px(element.style.minHeight), + px(element.style.paddingTop) + + px(element.style.paddingBottom) + + [...element.children].reduce( + (sum, child) => sum + measureElementHeight(child as HTMLElement, measured), + 0 + ) + ); + } + measured.set(element, result); + return result; + } + + function contentTop(element: HTMLElement, measured: WeakMap): number { + let top = 0; + let current = element; + while (current.parentElement && !current.hasAttribute('data-flowchat-scroller')) { + let sibling = current.previousElementSibling; + while (sibling) { + top += measureElementHeight(sibling as HTMLElement, measured); + sibling = sibling.previousElementSibling; + } + current = current.parentElement; + top += px(current.style.paddingTop); + } + return top; + } + + const flushAnimationFrames = () => { + now += 16; + const pending = [...frames.values()]; + frames.clear(); + act(() => pending.forEach(cb => cb(now))); + }; + + const flushResizeObservers = () => + act(() => { + for (const observer of observers) { + const measured = new WeakMap(); + const entries = [...observer.targets] + .filter(target => target.isConnected) + .flatMap(target => { + const blockSize = measureElementHeight(target as HTMLElement, measured); + const inlineSize = 900; + const size = `${inlineSize}:${blockSize}`; + if (observer.sizes.get(target) === size) return []; + observer.sizes.set(target, size); + return [ + { + target, + borderBoxSize: [{ inlineSize, blockSize }], + contentRect: target.getBoundingClientRect(), + } as ResizeObserverEntry, + ]; + }); + if (entries.length) observer.callback(entries, {} as ResizeObserver); + } + }); + + beforeEach(() => { + activateSurface('local'); + renderTelemetry.reset(); + frames = new Map(); + frameId = 0; + now = 1_000; + observers = []; + heights.clear(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.spyOn(performance, 'now').mockImplementation(() => now + (realHrTimeMs() % 1)); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + frames.set(++frameId, cb); + return frameId; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) + ); + vi.stubGlobal( + 'ResizeObserver', + class { + targets = new Set(); + sizes = new WeakMap(); + constructor(public callback: ResizeObserverCallback) { + observers.push(this); + } + observe(target: Element) { + this.targets.add(target); + } + unobserve(target: Element) { + this.targets.delete(target); + this.sizes.delete(target); + } + disconnect() { + this.targets.clear(); + this.sizes = new WeakMap(); + } + } + ); + + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { + return Math.round(measureElementHeight(this)); + }); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function () { + return Math.round(measureElementHeight(this)); + }); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(900); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900); + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function () { + return Math.round( + this.hasAttribute('data-flowchat-scroller') + ? measureElementHeight(this.firstElementChild as HTMLElement) + : measureElementHeight(this) + ); + }); + vi.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation(function () { + const range = Math.max(0, this.scrollHeight - this.clientHeight); + return Math.min(offsets.get(this) ?? 0, range); + }); + vi.spyOn(HTMLElement.prototype, 'scrollTop', 'set').mockImplementation(function (value: number) { + const range = Math.max(0, this.scrollHeight - this.clientHeight); + offsets.set(this, Math.max(0, Math.min(value, range))); + }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + const measured = new WeakMap(); + const scroller = this.closest('[data-flowchat-scroller]'); + const top = + !scroller || this === scroller + ? 0 + : contentTop(this, measured) - (offsets.get(scroller) ?? 0); + const h = measureElementHeight(this, measured); + const w = 900; + return { + x: 0, + y: top, + top, + left: 0, + right: w, + bottom: top + h, + width: w, + height: h, + toJSON: () => ({}), + } as DOMRect; + }); + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('[bench:react-streaming] measures React commits & wasted settled-row renders during 10 tail stream chunks (120 rounds)', () => { + const roundCount = 120; + const baseSession = buildLongRoundSessionFixture({ roundCount }); + const activeTurnIndex = baseSession.dialogTurns.length - 1; + const baseTurn = baseSession.dialogTurns[activeTurnIndex]; + const settledRounds = baseTurn.modelRounds.slice(0, -1); + const tailRoundIndex = roundCount - 1; + + harnessStoreState.session = baseSession; + harnessStoreState.items = sessionToVirtualItems(baseSession); + harnessStoreState.isProcessing = true; + + act(() => { + root.render(); + }); + flushResizeObservers(); + flushAnimationFrames(); + + const scroller = host.querySelector('[data-flowchat-scroller]')!; + act(() => { + scroller.scrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + scroller.dispatchEvent(new Event('scroll')); + }); + flushResizeObservers(); + flushAnimationFrames(); + + renderTelemetry.reset(); + + const streamChunks = 10; + const tStreamStart = realHrTimeMs(); + for (let chunk = 1; chunk <= streamChunks; chunk += 1) { + now += 32; + const streamingText: FlowTextItem = { + id: `stream-tail-text-${tailRoundIndex}`, + type: 'text', + content: `Live streaming output chunk ${chunk}: ` + 'token '.repeat(chunk * 3), + isStreaming: chunk < streamChunks, + isMarkdown: true, + timestamp: now, + status: chunk < streamChunks ? 'streaming' : 'completed', + }; + const tailRound: ModelRound = { + ...baseTurn.modelRounds[tailRoundIndex], + items: [...baseTurn.modelRounds[tailRoundIndex].items, streamingText], + isStreaming: chunk < streamChunks, + isComplete: chunk === streamChunks, + status: chunk < streamChunks ? 'streaming' : 'completed', + }; + const nextTurn: DialogTurn = { + ...baseTurn, + modelRounds: [...settledRounds, tailRound], + }; + const nextSession: Session = { + ...baseSession, + dialogTurns: [...baseSession.dialogTurns.slice(0, activeTurnIndex), nextTurn], + lastActiveAt: now, + }; + + harnessStoreState.session = nextSession; + harnessStoreState.items = sessionToVirtualItems(nextSession); + + act(() => { + root.render(); + }); + flushResizeObservers(); + flushAnimationFrames(); + } + const streamWallMs = realHrTimeMs() - tStreamStart; + + let settledRowRenderSum = 0; + let settledRowKeysCount = 0; + let activeRowRenderSum = 0; + for (const [key, count] of renderTelemetry.rowRendersByKey.entries()) { + if ( + key.includes(`round-${tailRoundIndex}`) || + key.includes(`stream-tail-text-${tailRoundIndex}`) || + key.includes(`tool-${tailRoundIndex}-`) || + key.includes(`thinking-${tailRoundIndex}`) || + key.includes(`text-${tailRoundIndex}`) + ) { + activeRowRenderSum += count; + } else { + settledRowKeysCount += 1; + settledRowRenderSum += count; + } + } + + console.info( + [ + `=== [Category 5A: React Streaming Tail Interaction (120 rounds, 10 chunks)] ===`, + `Wall time : ${streamWallMs.toFixed(2)} ms (${(streamWallMs / streamChunks).toFixed(2)} ms/chunk)`, + `VirtualMessageList commits : ${renderTelemetry.virtualListCommits}`, + `Active tail row renders : ${activeRowRenderSum}`, + `Settled row keys re-rendered : ${settledRowKeysCount} keys (${settledRowRenderSum} total wasted renders)`, + ].join('\n') + ); + + expect(renderTelemetry.virtualListCommits).toBeGreaterThan(0); + }); + + it('[bench:react-scroll] measures Virtualizer windowing commits & latency across 9 viewport scroll steps (120 rounds)', () => { + const roundCount = 120; + const baseSession = buildLongRoundSessionFixture({ roundCount }); + const activeTurnIndex = baseSession.dialogTurns.length - 1; + const baseTurn = baseSession.dialogTurns[activeTurnIndex]; + + harnessStoreState.session = baseSession; + harnessStoreState.items = sessionToVirtualItems(baseSession); + harnessStoreState.isProcessing = true; + + act(() => { + root.render(); + }); + flushResizeObservers(); + flushAnimationFrames(); + + const scroller = host.querySelector('[data-flowchat-scroller]')!; + const maxScroll = Math.max(1, scroller.scrollHeight - scroller.clientHeight); + renderTelemetry.reset(); + + const scrollSteps = 8; + const tScrollStart = realHrTimeMs(); + for (let step = 0; step <= scrollSteps; step += 1) { + now += 16; + const targetTop = Math.round((maxScroll * (scrollSteps - step)) / scrollSteps); + act(() => { + scroller.scrollTop = targetTop; + scroller.dispatchEvent(new Event('scroll')); + }); + flushResizeObservers(); + flushAnimationFrames(); + } + const scrollWallMs = realHrTimeMs() - tScrollStart; + + console.info( + [ + `=== [Category 5B: Virtualizer Scroll Windowing (120 rounds, ${scrollSteps + 1} steps)] ===`, + `Scroll distance : ${maxScroll} px`, + `Wall time : ${scrollWallMs.toFixed(2)} ms (${(scrollWallMs / (scrollSteps + 1)).toFixed(2)} ms/step)`, + `VirtualMessageList commits : ${renderTelemetry.virtualListCommits} (${(renderTelemetry.virtualListCommits / (scrollSteps + 1)).toFixed(1)} commits/step)`, + `Total row mounts/renders : ${[...renderTelemetry.rowRendersByKey.values()].reduce((a, b) => a + b, 0)}`, + ].join('\n') + ); + + expect(renderTelemetry.virtualListCommits).toBeGreaterThan(0); + }); + }); + + describe('Category 6: End-to-End Combined Pipeline Summary [bench:e2e-pipeline]', () => { + it('outputs combined 5-stage pipeline report across 10 -> 300 rounds', () => { + const summary = runLongRoundPipelineBenchmark({ + checkpoints: [10, 50, 100, 200, 300], + chunksPerCheckpoint: 5, + measureRetainedBytesEveryFlush: true, + }); + console.info('\n' + formatBenchmarkReport(summary)); + expect(summary.totalFlushes).toBe(20); + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/perf/longRoundPerfHarness.ts b/src/web-ui/src/flow_chat/perf/longRoundPerfHarness.ts new file mode 100644 index 0000000000..53da2685a6 --- /dev/null +++ b/src/web-ui/src/flow_chat/perf/longRoundPerfHarness.ts @@ -0,0 +1,891 @@ +/** + * Long-round FlowChat performance harness. + * + * Categorized into 5 focused benchmark suites so each optimization area can be + * measured, verified, and regressed independently: + * + * - Category 1 (`benchStoreProjection`): Store & Adjacent Group Projection + * (`sessionToVirtualItems` + `projectAdjacentFlowGroups`, reference stability of `VirtualItem`s) + * - Category 2 (`benchTimelineDocument`): Timeline Document & Rhythm Pass + * (`ConversationDocumentProjection.project` + `getNextVisibleVirtualItemIndexes`, `TimelineBlock` reference stability) + * - Category 3 (`benchGroupStateAndSearch`): Container Derived Passes + * (`indexFlowGroups`, `captureFlowGroupFeedbackSnapshot`, `collectFlowGroupReceiveFeedback`, `buildFlowChatSearchMatches`) + * - Category 4 (`benchBackgroundBudgetAndPersistence`): Background Budget & Persistence Overhead + * (`estimateRetainedBytes` full-turn object walk, `calculateTurnHash` JSON serialization) + * - Category 5 (in `longRoundPerfHarness.test.tsx`): React Framework & Virtualizer DOM Interaction + * (Streaming tail updates vs viewport scroll windowing) + */ + +import { sessionToVirtualItems, type VirtualItem } from '../store/modernFlowChatStore'; +import { indexFlowGroups, getVirtualItemFlowGroups } from '../grouping/selectors'; +import { + captureFlowGroupFeedbackSnapshot, + collectFlowGroupReceiveFeedback, +} from '../grouping/receiveFeedback'; +import { ConversationDocumentProjection, type TimelineProjectionOptions } from '../timeline/document'; +import { FlowChatReaderState } from '../timeline/readerState'; +import { getNextVisibleVirtualItemIndexes } from '../components/modern/flowChatRhythm'; +import { buildFlowChatSearchMatches } from '../components/modern/useFlowChatSearch'; +import { calculateTurnHash } from '../services/flow-chat-manager/PersistenceModule'; +import { estimateRetainedBytes } from '@/shared/utils/resourceBudget'; +import { getVirtualItemStableKey } from '../components/modern/virtualItemIdentity'; +import type { + AnyFlowItem, + DialogTurn, + FlowTextItem, + FlowThinkingItem, + FlowToolItem, + ModelRound, + Session, +} from '../types/flow-chat'; + +export interface LongRoundScenarioOptions { + roundCount: number; + priorCompletedTurns?: number; + sessionId?: string; + turnId?: string; +} + +export interface StreamingStepMetrics { + roundIndex: number; + stepKind: 'round-start' | 'text-chunk' | 'tool-partial' | 'round-complete'; + totalPipelineMs: number; + stageMs: { + storeProjectionMs: number; + groupIndexAndFeedbackMs: number; + documentProjectionMs: number; + rhythmPassMs: number; + retainedBytesEstimateMs: number; + }; + counts: { + rounds: number; + virtualItems: number; + timelineBlocks: number; + indexedGroups: number; + }; + churn: { + /** Total VirtualItems emitted before the active last round */ + settledVirtualItemCount: number; + /** Settled VirtualItems that kept exact `===` object identity from previous flush */ + reusedSettledVirtualItems: number; + /** Settled VirtualItem reference reuse ratio (0..1) */ + settledVirtualItemReuseRatio: number; + /** Total TimelineBlocks emitted before the active last round */ + settledTimelineBlockCount: number; + /** Settled TimelineBlocks that kept exact `===` object identity from previous flush */ + reusedSettledTimelineBlocks: number; + /** Settled TimelineBlock reference reuse ratio (0..1) */ + settledTimelineBlockReuseRatio: number; + }; +} + +export interface LongRoundBenchmarkSummary { + roundCount: number; + totalFlushes: number; + totalPipelineMs: number; + avgFlushMs: number; + p50FlushMs: number; + p95FlushMs: number; + maxFlushMs: number; + earlyFlushAvgMs: number; + lateFlushAvgMs: number; + lateToEarlyScalingRatio: number; + stageTotalsMs: { + storeProjectionMs: number; + groupIndexAndFeedbackMs: number; + documentProjectionMs: number; + rhythmPassMs: number; + retainedBytesEstimateMs: number; + }; + avgSettledVirtualItemReuseRatio: number; + avgSettledTimelineBlockReuseRatio: number; + steps: StreamingStepMetrics[]; +} + +export interface StoreProjectionBenchResult { + checkpoints: number[]; + totalFlushes: number; + totalMs: number; + avgFlushMs: number; + earlyFlushAvgMs: number; + lateFlushAvgMs: number; + scalingRatio: number; + settledVirtualItemReuseRatio: number; +} + +export interface TimelineDocumentBenchResult { + checkpoints: number[]; + totalFlushes: number; + documentProjectionTotalMs: number; + rhythmPassTotalMs: number; + avgFlushMs: number; + earlyFlushAvgMs: number; + lateFlushAvgMs: number; + scalingRatio: number; + settledTimelineBlockReuseRatio: number; + /** TimelineBlock reuse when upstream VirtualItem references are held stable for settled rounds */ + isolatedSettledTimelineBlockReuseRatio: number; + isolatedAvgFlushMs: number; +} + +export interface GroupStateAndSearchBenchResult { + checkpoints: number[]; + totalFlushes: number; + indexFlowGroupsTotalMs: number; + receiveFeedbackTotalMs: number; + searchPassTotalMs: number; + avgCombinedMs: number; + earlyCombinedAvgMs: number; + lateCombinedAvgMs: number; + scalingRatio: number; +} + +export interface BackgroundBudgetAndPersistenceBenchResult { + checkpoints: number[]; + iterationsPerCheckpoint: number; + retainedBytesTotalMs: number; + retainedBytesEarlyAvgMs: number; + retainedBytesLateAvgMs: number; + retainedBytesScalingRatio: number; + turnHashTotalMs: number; + turnHashEarlyAvgMs: number; + turnHashLateAvgMs: number; + estimatedActiveTurnBytesAtMaxRound: number; +} + +const SAMPLE_TOOL_SEQUENCE = [ + { toolName: 'Read', inputKey: 'file_path', valuePrefix: 'src/web-ui/src/flow_chat/Module' }, + { toolName: 'Grep', inputKey: 'pattern', valuePrefix: 'useVirtualItems|sessionToVirtualItems' }, + { toolName: 'Read', inputKey: 'file_path', valuePrefix: 'src/web-ui/src/flow_chat/timeline/Block' }, + { toolName: 'Edit', inputKey: 'file_path', valuePrefix: 'src/web-ui/src/flow_chat/store/Store' }, + { toolName: 'ExecCommand', inputKey: 'cmd', valuePrefix: 'pnpm --dir src/web-ui test' }, +] as const; + +export function buildCompletedRoundFixture(roundIndex: number, turnStartTime = 1_700_000_000_000): ModelRound { + const startTime = turnStartTime + roundIndex * 1_500; + const specA = SAMPLE_TOOL_SEQUENCE[roundIndex % SAMPLE_TOOL_SEQUENCE.length]; + const specB = SAMPLE_TOOL_SEQUENCE[(roundIndex + 1) % SAMPLE_TOOL_SEQUENCE.length]; + const items: AnyFlowItem[] = []; + + if (roundIndex % 3 === 0) { + const thinking: FlowThinkingItem = { + id: `thinking-${roundIndex}`, + type: 'thinking', + content: `Analyzing step ${roundIndex}: checking component boundaries and virtual row stability.`, + isStreaming: false, + isCollapsed: true, + timestamp: startTime + 50, + status: 'completed', + }; + items.push(thinking); + } + + const toolA: FlowToolItem = { + id: `tool-${roundIndex}-a`, + type: 'tool', + toolName: specA.toolName, + timestamp: startTime + 120, + startTime: startTime + 120, + endTime: startTime + 450, + status: 'completed', + toolCall: { + id: `tool-${roundIndex}-a`, + input: { [specA.inputKey]: `${specA.valuePrefix}_${roundIndex}.ts` }, + }, + toolResult: { + success: true, + result: `Result payload for ${specA.toolName} in round ${roundIndex}: ` + 'line content\n'.repeat(12), + }, + }; + items.push(toolA); + + if (roundIndex % 2 === 0) { + const toolB: FlowToolItem = { + id: `tool-${roundIndex}-b`, + type: 'tool', + toolName: specB.toolName, + timestamp: startTime + 480, + startTime: startTime + 480, + endTime: startTime + 820, + status: 'completed', + toolCall: { + id: `tool-${roundIndex}-b`, + input: { [specB.inputKey]: `${specB.valuePrefix}_${roundIndex}_b.ts` }, + }, + toolResult: { + success: true, + result: `Secondary payload for ${specB.toolName} in round ${roundIndex}: ` + 'match line\n'.repeat(8), + }, + }; + items.push(toolB); + } + + if (roundIndex % 4 === 0) { + const text: FlowTextItem = { + id: `text-${roundIndex}`, + type: 'text', + content: + `### Round ${roundIndex} Summary\n\n` + + `Inspected \`${specA.valuePrefix}_${roundIndex}.ts\` and verified projection invariants.\n\n` + + `- Checked item references\n- Checked group boundaries\n`, + isStreaming: false, + isMarkdown: true, + timestamp: startTime + 900, + status: 'completed', + }; + items.push(text); + } + + return { + id: `round-${roundIndex}`, + index: roundIndex, + items, + isStreaming: false, + isComplete: true, + status: 'completed', + startTime, + endTime: startTime + 1_200, + firstChunkMs: startTime + 40, + firstVisibleOutputMs: startTime + 50, + ttftMs: 50, + streamDurationMs: 1_150, + }; +} + +export function buildLongRoundSessionFixture(options: LongRoundScenarioOptions): Session { + const sessionId = options.sessionId ?? 'perf-session-long-round'; + const turnId = options.turnId ?? 'turn-active'; + const priorCount = options.priorCompletedTurns ?? 1; + const baseTime = 1_700_000_000_000; + + const priorTurns: DialogTurn[] = Array.from({ length: priorCount }, (_, idx) => ({ + id: `turn-prior-${idx}`, + sessionId, + storageTurnIndex: idx, + userMessage: { + id: `user-prior-${idx}`, + content: `Prior user request ${idx}`, + timestamp: baseTime - (priorCount - idx) * 60_000, + }, + modelRounds: [ + buildCompletedRoundFixture(0, baseTime - (priorCount - idx) * 60_000 + 100), + buildCompletedRoundFixture(1, baseTime - (priorCount - idx) * 60_000 + 2_000), + ], + status: 'completed', + startTime: baseTime - (priorCount - idx) * 60_000, + endTime: baseTime - (priorCount - idx) * 60_000 + 5_000, + })); + + const rounds: ModelRound[] = Array.from({ length: options.roundCount }, (_, idx) => + buildCompletedRoundFixture(idx, baseTime) + ); + + const activeTurn: DialogTurn = { + id: turnId, + sessionId, + storageTurnIndex: priorCount, + userMessage: { + id: `user-${turnId}`, + content: 'Optimize long-round session scrolling and rendering performance.', + timestamp: baseTime - 100, + }, + modelRounds: rounds, + status: 'processing', + startTime: baseTime - 100, + }; + + return { + sessionId, + sessionKind: 'normal', + dialogTurns: [...priorTurns, activeTurn], + status: 'active', + config: { workspaceId: 'workspace-perf', workspacePath: '/workspace/perf' }, + createdAt: baseTime - 300_000, + lastActiveAt: baseTime + options.roundCount * 1_500, + error: null, + historyState: 'ready', + }; +} + +function buildStreamingChunkSession( + baseSession: Session, + chunkIdx: number, + chunksPerCheckpoint: number, +): { session: Session; updatedTurn: DialogTurn; activeTailRound: ModelRound } { + const activeTurnIndex = baseSession.dialogTurns.length - 1; + const baseTurn = baseSession.dialogTurns[activeTurnIndex]; + const settledRounds = baseTurn.modelRounds.slice(0, -1); + const tailRoundIndex = baseTurn.modelRounds.length - 1; + const tailStartTime = 1_700_000_000_000 + tailRoundIndex * 1_500; + const isLastChunk = chunkIdx === chunksPerCheckpoint - 1; + + const streamingText: FlowTextItem = { + id: `stream-text-${tailRoundIndex}`, + type: 'text', + content: `Streaming response in round ${tailRoundIndex}: ` + 'chunk token payload '.repeat(chunkIdx + 1), + isStreaming: !isLastChunk, + isMarkdown: true, + timestamp: tailStartTime + 200 + chunkIdx * 32, + status: isLastChunk ? 'completed' : 'streaming', + }; + const streamingTool: FlowToolItem = { + id: `stream-tool-${tailRoundIndex}`, + type: 'tool', + toolName: 'Read', + timestamp: tailStartTime + 50, + startTime: tailStartTime + 50, + endTime: tailStartTime + 180, + status: 'completed', + toolCall: { + id: `stream-tool-${tailRoundIndex}`, + input: { file_path: `src/file_${tailRoundIndex}.ts` }, + }, + toolResult: { + success: true, + result: 'file line\n'.repeat(10), + }, + }; + + const activeTailRound: ModelRound = { + id: `round-${tailRoundIndex}`, + index: tailRoundIndex, + items: [streamingTool, streamingText], + isStreaming: !isLastChunk, + isComplete: isLastChunk, + status: isLastChunk ? 'completed' : 'streaming', + startTime: tailStartTime, + ...(isLastChunk ? { endTime: tailStartTime + 500 } : {}), + }; + + const updatedTurn: DialogTurn = { + ...baseTurn, + modelRounds: [...settledRounds, activeTailRound], + }; + const session: Session = { + ...baseSession, + dialogTurns: [...baseSession.dialogTurns.slice(0, activeTurnIndex), updatedTurn], + lastActiveAt: tailStartTime + chunkIdx * 32, + }; + + return { session, updatedTurn, activeTailRound }; +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * p))); + return sorted[idx]; +} + +/** + * Category 1: Store & Adjacent Group Projection (`sessionToVirtualItems` + `projectAdjacentFlowGroups`) + */ +export function benchStoreProjection(options?: { + checkpoints?: number[]; + chunksPerCheckpoint?: number; +}): StoreProjectionBenchResult { + const checkpoints = options?.checkpoints ?? [10, 50, 100, 200, 300]; + const chunksPerCheckpoint = options?.chunksPerCheckpoint ?? 5; + + let previousVirtualItems: VirtualItem[] = []; + const stepMs: Array<{ roundCount: number; ms: number; reuseRatio: number }> = []; + + for (const roundCount of checkpoints) { + const baseSession = buildLongRoundSessionFixture({ roundCount }); + for (let chunkIdx = 0; chunkIdx < chunksPerCheckpoint; chunkIdx += 1) { + const { session, activeTailRound } = buildStreamingChunkSession( + baseSession, + chunkIdx, + chunksPerCheckpoint, + ); + const t0 = performance.now(); + const virtualItems = sessionToVirtualItems(session); + const t1 = performance.now(); + + if (chunkIdx > 0) { + const settled = virtualItems.filter( + item => !(item.type === 'model-round' && item.data.id === activeTailRound.id), + ); + const prevSet = new Set(previousVirtualItems); + const reused = settled.filter(item => prevSet.has(item)).length; + stepMs.push({ + roundCount, + ms: t1 - t0, + reuseRatio: settled.length > 0 ? reused / settled.length : 1, + }); + } + previousVirtualItems = virtualItems; + } + } + + const totalMs = stepMs.reduce((s, x) => s + x.ms, 0); + const early = stepMs.filter(x => x.roundCount === checkpoints[0]); + const late = stepMs.filter(x => x.roundCount === checkpoints[checkpoints.length - 1]); + const earlyFlushAvgMs = early.reduce((s, x) => s + x.ms, 0) / Math.max(1, early.length); + const lateFlushAvgMs = late.reduce((s, x) => s + x.ms, 0) / Math.max(1, late.length); + + return { + checkpoints, + totalFlushes: stepMs.length, + totalMs, + avgFlushMs: totalMs / Math.max(1, stepMs.length), + earlyFlushAvgMs, + lateFlushAvgMs, + scalingRatio: earlyFlushAvgMs > 0 ? lateFlushAvgMs / earlyFlushAvgMs : 1, + settledVirtualItemReuseRatio: + stepMs.reduce((s, x) => s + x.reuseRatio, 0) / Math.max(1, stepMs.length), + }; +} + +/** + * Category 2: Timeline Document Projection & Rhythm Spacing (`ConversationDocumentProjection.project` + `getNextVisibleVirtualItemIndexes`) + */ +export function benchTimelineDocument(options?: { + checkpoints?: number[]; + chunksPerCheckpoint?: number; +}): TimelineDocumentBenchResult { + const checkpoints = options?.checkpoints ?? [10, 50, 100, 200, 300]; + const chunksPerCheckpoint = options?.chunksPerCheckpoint ?? 5; + + const reader = new FlowChatReaderState(); + const docProjection = new ConversationDocumentProjection(); + const docOptions: TimelineProjectionOptions = { + reader, + sessionId: 'perf-session-long-round', + groupStates: new Map(), + expandedToolCapsules: new Set(), + pendingPermissionToolCallIds: new Set(), + toolLabels: new Map(), + }; + + let previousTimelineBlocks: VirtualItem[] = []; + const steps: Array<{ roundCount: number; docMs: number; rhythmMs: number; reuseRatio: number }> = []; + + for (const roundCount of checkpoints) { + const baseSession = buildLongRoundSessionFixture({ roundCount }); + for (let chunkIdx = 0; chunkIdx < chunksPerCheckpoint; chunkIdx += 1) { + const { session, activeTailRound } = buildStreamingChunkSession( + baseSession, + chunkIdx, + chunksPerCheckpoint, + ); + const virtualItems = sessionToVirtualItems(session); + + const t0 = performance.now(); + const timelineBlocks = docProjection.project(virtualItems, docOptions); + const t1 = performance.now(); + getNextVisibleVirtualItemIndexes(timelineBlocks); + const t2 = performance.now(); + + if (chunkIdx > 0) { + const settled = timelineBlocks.filter( + block => !(block.type === 'model-round' && block.data.id === activeTailRound.id), + ); + const prevSet = new Set(previousTimelineBlocks); + const reused = settled.filter(block => prevSet.has(block)).length; + steps.push({ + roundCount, + docMs: t1 - t0, + rhythmMs: t2 - t1, + reuseRatio: settled.length > 0 ? reused / settled.length : 1, + }); + } + previousTimelineBlocks = timelineBlocks; + } + } + + // Also measure isolated document projection when settled VirtualItem references ARE held stable + const isolatedProjection = new ConversationDocumentProjection(); + const maxRoundCount = checkpoints[checkpoints.length - 1]; + const baseMaxSession = buildLongRoundSessionFixture({ roundCount: maxRoundCount }); + const initialVirtualItems = sessionToVirtualItems(baseMaxSession); + const stableSettledPrefix = initialVirtualItems.slice(0, -1); + let prevIsolatedBlocks = isolatedProjection.project(initialVirtualItems, docOptions); + let isolatedTotalMs = 0; + let isolatedReuseSum = 0; + const isolatedFlushes = chunksPerCheckpoint - 1; + + for (let chunkIdx = 1; chunkIdx < chunksPerCheckpoint; chunkIdx += 1) { + const { session, activeTailRound } = buildStreamingChunkSession( + baseMaxSession, + chunkIdx, + chunksPerCheckpoint, + ); + const freshVirtualItems = sessionToVirtualItems(session); + const stabilizedInput = [...stableSettledPrefix, freshVirtualItems[freshVirtualItems.length - 1]]; + const t0 = performance.now(); + const nextBlocks = isolatedProjection.project(stabilizedInput, docOptions); + const t1 = performance.now(); + isolatedTotalMs += t1 - t0; + + const settled = nextBlocks.filter( + block => !(block.type === 'model-round' && block.data.id === activeTailRound.id), + ); + const prevSet = new Set(prevIsolatedBlocks); + const reused = settled.filter(block => prevSet.has(block)).length; + isolatedReuseSum += settled.length > 0 ? reused / settled.length : 1; + prevIsolatedBlocks = nextBlocks; + } + + const documentProjectionTotalMs = steps.reduce((s, x) => s + x.docMs, 0); + const rhythmPassTotalMs = steps.reduce((s, x) => s + x.rhythmMs, 0); + const early = steps.filter(x => x.roundCount === checkpoints[0]); + const late = steps.filter(x => x.roundCount === checkpoints[checkpoints.length - 1]); + const earlyFlushAvgMs = + early.reduce((s, x) => s + x.docMs + x.rhythmMs, 0) / Math.max(1, early.length); + const lateFlushAvgMs = + late.reduce((s, x) => s + x.docMs + x.rhythmMs, 0) / Math.max(1, late.length); + + return { + checkpoints, + totalFlushes: steps.length, + documentProjectionTotalMs, + rhythmPassTotalMs, + avgFlushMs: (documentProjectionTotalMs + rhythmPassTotalMs) / Math.max(1, steps.length), + earlyFlushAvgMs, + lateFlushAvgMs, + scalingRatio: earlyFlushAvgMs > 0 ? lateFlushAvgMs / earlyFlushAvgMs : 1, + settledTimelineBlockReuseRatio: + steps.reduce((s, x) => s + x.reuseRatio, 0) / Math.max(1, steps.length), + isolatedSettledTimelineBlockReuseRatio: isolatedReuseSum / Math.max(1, isolatedFlushes), + isolatedAvgFlushMs: isolatedTotalMs / Math.max(1, isolatedFlushes), + }; +} + +/** + * Category 3: Container Group Indexing, Receive Feedback & Search Passes + * (`indexFlowGroups`, `captureFlowGroupFeedbackSnapshot`, `collectFlowGroupReceiveFeedback`, `buildFlowChatSearchMatches`) + */ +export function benchGroupStateAndSearch(options?: { + checkpoints?: number[]; + chunksPerCheckpoint?: number; + activeSearchQuery?: string; +}): GroupStateAndSearchBenchResult { + const checkpoints = options?.checkpoints ?? [10, 50, 100, 200, 300]; + const chunksPerCheckpoint = options?.chunksPerCheckpoint ?? 5; + const activeSearchQuery = options?.activeSearchQuery ?? 'projection'; + + let feedbackSnapshot: ReturnType | undefined; + + const steps: Array<{ + roundCount: number; + indexMs: number; + feedbackMs: number; + searchMs: number; + }> = []; + + for (const roundCount of checkpoints) { + const baseSession = buildLongRoundSessionFixture({ roundCount }); + for (let chunkIdx = 0; chunkIdx < chunksPerCheckpoint; chunkIdx += 1) { + const { session } = buildStreamingChunkSession(baseSession, chunkIdx, chunksPerCheckpoint); + const virtualItems = sessionToVirtualItems(session); + + const t0 = performance.now(); + indexFlowGroups(virtualItems); + const t1 = performance.now(); + collectFlowGroupReceiveFeedback( + feedbackSnapshot, + virtualItems, + session.sessionId, + ); + feedbackSnapshot = captureFlowGroupFeedbackSnapshot(virtualItems, session.sessionId); + const t2 = performance.now(); + buildFlowChatSearchMatches(virtualItems, activeSearchQuery); + const t3 = performance.now(); + + if (chunkIdx > 0) { + steps.push({ + roundCount, + indexMs: t1 - t0, + feedbackMs: t2 - t1, + searchMs: t3 - t2, + }); + } + } + } + + const indexFlowGroupsTotalMs = steps.reduce((s, x) => s + x.indexMs, 0); + const receiveFeedbackTotalMs = steps.reduce((s, x) => s + x.feedbackMs, 0); + const searchPassTotalMs = steps.reduce((s, x) => s + x.searchMs, 0); + const totalCombinedMs = indexFlowGroupsTotalMs + receiveFeedbackTotalMs + searchPassTotalMs; + + const early = steps.filter(x => x.roundCount === checkpoints[0]); + const late = steps.filter(x => x.roundCount === checkpoints[checkpoints.length - 1]); + const earlyCombinedAvgMs = + early.reduce((s, x) => s + x.indexMs + x.feedbackMs + x.searchMs, 0) / Math.max(1, early.length); + const lateCombinedAvgMs = + late.reduce((s, x) => s + x.indexMs + x.feedbackMs + x.searchMs, 0) / Math.max(1, late.length); + + return { + checkpoints, + totalFlushes: steps.length, + indexFlowGroupsTotalMs, + receiveFeedbackTotalMs, + searchPassTotalMs, + avgCombinedMs: totalCombinedMs / Math.max(1, steps.length), + earlyCombinedAvgMs, + lateCombinedAvgMs, + scalingRatio: earlyCombinedAvgMs > 0 ? lateCombinedAvgMs / earlyCombinedAvgMs : 1, + }; +} + +/** + * Category 4: Background Memory Budget & Persistence Hash Overhead + * (`estimateRetainedBytes(turn)` + `calculateTurnHash(turn)`) + */ +export function benchBackgroundBudgetAndPersistence(options?: { + checkpoints?: number[]; + iterationsPerCheckpoint?: number; +}): BackgroundBudgetAndPersistenceBenchResult { + const checkpoints = options?.checkpoints ?? [10, 50, 100, 200, 300]; + const iterationsPerCheckpoint = options?.iterationsPerCheckpoint ?? 5; + + const steps: Array<{ roundCount: number; bytesMs: number; hashMs: number; bytes: number }> = []; + + for (const roundCount of checkpoints) { + const baseSession = buildLongRoundSessionFixture({ roundCount }); + // Warm the initial turn prefix (first 500ms budget tick or initial hydrate) + const { updatedTurn: initialTurn } = buildStreamingChunkSession(baseSession, 0, iterationsPerCheckpoint + 1); + estimateRetainedBytes(initialTurn); + for (let i = 1; i <= iterationsPerCheckpoint; i += 1) { + const { updatedTurn } = buildStreamingChunkSession(baseSession, i, iterationsPerCheckpoint + 1); + const t0 = performance.now(); + const bytes = estimateRetainedBytes(updatedTurn); + const t1 = performance.now(); + calculateTurnHash(updatedTurn); + const t2 = performance.now(); + + steps.push({ + roundCount, + bytesMs: t1 - t0, + hashMs: t2 - t1, + bytes, + }); + } + } + + const retainedBytesTotalMs = steps.reduce((s, x) => s + x.bytesMs, 0); + const turnHashTotalMs = steps.reduce((s, x) => s + x.hashMs, 0); + const early = steps.filter(x => x.roundCount === checkpoints[0]); + const late = steps.filter(x => x.roundCount === checkpoints[checkpoints.length - 1]); + + const retainedBytesEarlyAvgMs = + early.reduce((s, x) => s + x.bytesMs, 0) / Math.max(1, early.length); + const retainedBytesLateAvgMs = + late.reduce((s, x) => s + x.bytesMs, 0) / Math.max(1, late.length); + const turnHashEarlyAvgMs = early.reduce((s, x) => s + x.hashMs, 0) / Math.max(1, early.length); + const turnHashLateAvgMs = late.reduce((s, x) => s + x.hashMs, 0) / Math.max(1, late.length); + + return { + checkpoints, + iterationsPerCheckpoint, + retainedBytesTotalMs, + retainedBytesEarlyAvgMs, + retainedBytesLateAvgMs, + retainedBytesScalingRatio: + retainedBytesEarlyAvgMs > 0 ? retainedBytesLateAvgMs / retainedBytesEarlyAvgMs : 1, + turnHashTotalMs, + turnHashEarlyAvgMs, + turnHashLateAvgMs, + estimatedActiveTurnBytesAtMaxRound: late[late.length - 1]?.bytes ?? 0, + }; +} + +/** + * Runs a combined end-to-end multi-stage benchmark simulating streaming flushes + * at the tail of a growing long-round turn. + */ +export function runLongRoundPipelineBenchmark(options: { + checkpoints?: number[]; + chunksPerCheckpoint?: number; + measureRetainedBytesEveryFlush?: boolean; +}): LongRoundBenchmarkSummary { + const checkpoints = options.checkpoints ?? [10, 50, 100, 150, 200]; + const chunksPerCheckpoint = options.chunksPerCheckpoint ?? 4; + const measureRetainedBytesEveryFlush = options.measureRetainedBytesEveryFlush ?? true; + + const reader = new FlowChatReaderState(); + const docProjection = new ConversationDocumentProjection(); + const groupStates = new Map(); + const expandedToolCapsules = new Set(); + const pendingPermissionToolCallIds = new Set(); + const toolLabels = new Map(); + + let previousVirtualItems: VirtualItem[] = []; + let previousTimelineBlocks: VirtualItem[] = []; + let feedbackSnapshot: ReturnType | undefined; + + const steps: StreamingStepMetrics[] = []; + + for (const roundCount of checkpoints) { + const baseSession = buildLongRoundSessionFixture({ roundCount }); + const tailRoundIndex = roundCount - 1; + + for (let chunkIdx = 0; chunkIdx < chunksPerCheckpoint; chunkIdx += 1) { + const isLastChunk = chunkIdx === chunksPerCheckpoint - 1; + const { session, updatedTurn, activeTailRound } = buildStreamingChunkSession( + baseSession, + chunkIdx, + chunksPerCheckpoint, + ); + + // Stage 1: Store projection (sessionToVirtualItems + projectAdjacentFlowGroups) + const t0 = performance.now(); + const virtualItems = sessionToVirtualItems(session); + const t1 = performance.now(); + + // Stage 2: Group indexing & receive feedback (useFlowGroupState equivalents) + const indexedGroups = indexFlowGroups(virtualItems); + collectFlowGroupReceiveFeedback( + feedbackSnapshot, + virtualItems, + session.sessionId, + ); + feedbackSnapshot = captureFlowGroupFeedbackSnapshot(virtualItems, session.sessionId); + const t2 = performance.now(); + + // Stage 3: ConversationDocumentProjection.project + const docOptions: TimelineProjectionOptions = { + reader, + sessionId: session.sessionId, + groupStates, + expandedToolCapsules, + pendingPermissionToolCallIds, + toolLabels, + }; + const timelineBlocks = docProjection.project(virtualItems, docOptions); + const t3 = performance.now(); + + // Stage 4: Rhythm visible-index pass + getNextVisibleVirtualItemIndexes(timelineBlocks); + const t4 = performance.now(); + + // Stage 5: Retained bytes estimation on active turn + if (measureRetainedBytesEveryFlush) { + estimateRetainedBytes(updatedTurn); + } + const t5 = performance.now(); + + // Audit reference stability of settled items (excluding the active tail round) + const activeRoundId = activeTailRound.id; + const settledVirtualItems = virtualItems.filter( + item => !(item.type === 'model-round' && item.data.id === activeRoundId) + ); + const prevVirtualSet = new Set(previousVirtualItems); + const reusedSettledVirtualItems = settledVirtualItems.filter(item => prevVirtualSet.has(item)).length; + + const settledTimelineBlocks = timelineBlocks.filter( + block => !(block.type === 'model-round' && block.data.id === activeRoundId) + ); + const prevTimelineSet = new Set(previousTimelineBlocks); + const reusedSettledTimelineBlocks = settledTimelineBlocks.filter(block => prevTimelineSet.has(block)).length; + + if (chunkIdx > 0) { + steps.push({ + roundIndex: tailRoundIndex, + stepKind: isLastChunk ? 'round-complete' : 'text-chunk', + totalPipelineMs: t5 - t0, + stageMs: { + storeProjectionMs: t1 - t0, + groupIndexAndFeedbackMs: t2 - t1, + documentProjectionMs: t3 - t2, + rhythmPassMs: t4 - t3, + retainedBytesEstimateMs: t5 - t4, + }, + counts: { + rounds: roundCount, + virtualItems: virtualItems.length, + timelineBlocks: timelineBlocks.length, + indexedGroups: indexedGroups.byId.size, + }, + churn: { + settledVirtualItemCount: settledVirtualItems.length, + reusedSettledVirtualItems, + settledVirtualItemReuseRatio: + settledVirtualItems.length > 0 ? reusedSettledVirtualItems / settledVirtualItems.length : 1, + settledTimelineBlockCount: settledTimelineBlocks.length, + reusedSettledTimelineBlocks, + settledTimelineBlockReuseRatio: + settledTimelineBlocks.length > 0 ? reusedSettledTimelineBlocks / settledTimelineBlocks.length : 1, + }, + }); + } + + previousVirtualItems = virtualItems; + previousTimelineBlocks = timelineBlocks; + } + } + + const durations = steps.map(s => s.totalPipelineMs).sort((a, b) => a - b); + const totalPipelineMs = steps.reduce((sum, s) => sum + s.totalPipelineMs, 0); + const stageTotalsMs = steps.reduce( + (acc, s) => ({ + storeProjectionMs: acc.storeProjectionMs + s.stageMs.storeProjectionMs, + groupIndexAndFeedbackMs: acc.groupIndexAndFeedbackMs + s.stageMs.groupIndexAndFeedbackMs, + documentProjectionMs: acc.documentProjectionMs + s.stageMs.documentProjectionMs, + rhythmPassMs: acc.rhythmPassMs + s.stageMs.rhythmPassMs, + retainedBytesEstimateMs: acc.retainedBytesEstimateMs + s.stageMs.retainedBytesEstimateMs, + }), + { + storeProjectionMs: 0, + groupIndexAndFeedbackMs: 0, + documentProjectionMs: 0, + rhythmPassMs: 0, + retainedBytesEstimateMs: 0, + }, + ); + + const firstCheckpoint = checkpoints[0]; + const lastCheckpoint = checkpoints[checkpoints.length - 1]; + const earlySteps = steps.filter(s => s.roundIndex === firstCheckpoint - 1); + const lateSteps = steps.filter(s => s.roundIndex === lastCheckpoint - 1); + const earlyFlushAvgMs = + earlySteps.reduce((sum, s) => sum + s.totalPipelineMs, 0) / Math.max(1, earlySteps.length); + const lateFlushAvgMs = + lateSteps.reduce((sum, s) => sum + s.totalPipelineMs, 0) / Math.max(1, lateSteps.length); + + const avgSettledVirtualItemReuseRatio = + steps.reduce((sum, s) => sum + s.churn.settledVirtualItemReuseRatio, 0) / Math.max(1, steps.length); + const avgSettledTimelineBlockReuseRatio = + steps.reduce((sum, s) => sum + s.churn.settledTimelineBlockReuseRatio, 0) / Math.max(1, steps.length); + + return { + roundCount: lastCheckpoint, + totalFlushes: steps.length, + totalPipelineMs, + avgFlushMs: totalPipelineMs / Math.max(1, steps.length), + p50FlushMs: percentile(durations, 0.5), + p95FlushMs: percentile(durations, 0.95), + maxFlushMs: durations[durations.length - 1] ?? 0, + earlyFlushAvgMs, + lateFlushAvgMs, + lateToEarlyScalingRatio: earlyFlushAvgMs > 0 ? lateFlushAvgMs / earlyFlushAvgMs : 1, + stageTotalsMs, + avgSettledVirtualItemReuseRatio, + avgSettledTimelineBlockReuseRatio, + steps, + }; +} + +/** + * Formats a human-readable diagnostic table of the benchmark summary. + */ +export function formatBenchmarkReport(summary: LongRoundBenchmarkSummary): string { + const lines: string[] = [ + `=== FlowChat Long-Round Harness Report (up to ${summary.roundCount} rounds, ${summary.totalFlushes} measured flushes) ===`, + `Total Pipeline Time : ${summary.totalPipelineMs.toFixed(2)} ms`, + `Flush Latency : avg=${summary.avgFlushMs.toFixed(2)}ms | p50=${summary.p50FlushMs.toFixed(2)}ms | p95=${summary.p95FlushMs.toFixed(2)}ms | max=${summary.maxFlushMs.toFixed(2)}ms`, + `Scaling (Late/Early): early=${summary.earlyFlushAvgMs.toFixed(2)}ms -> late=${summary.lateFlushAvgMs.toFixed(2)}ms (${summary.lateToEarlyScalingRatio.toFixed(2)}x)`, + `Stage Breakdown :`, + ` 1. Store Projection (sessionToVirtualItems + grouping): ${summary.stageTotalsMs.storeProjectionMs.toFixed(2)} ms`, + ` 2. Group Index & Feedback (useFlowGroupState) : ${summary.stageTotalsMs.groupIndexAndFeedbackMs.toFixed(2)} ms`, + ` 3. Document Timeline (ConversationDocumentProjection) : ${summary.stageTotalsMs.documentProjectionMs.toFixed(2)} ms`, + ` 4. Rhythm Spacing Pass (getNextVisibleIndexes) : ${summary.stageTotalsMs.rhythmPassMs.toFixed(2)} ms`, + ` 5. Retained Bytes Walk (estimateRetainedBytes) : ${summary.stageTotalsMs.retainedBytesEstimateMs.toFixed(2)} ms`, + `Reference Reuse :`, + ` - Settled VirtualItem Identity Reuse : ${(summary.avgSettledVirtualItemReuseRatio * 100).toFixed(1)}%`, + ` - Settled TimelineBlock Identity Reuse : ${(summary.avgSettledTimelineBlockReuseRatio * 100).toFixed(1)}%`, + ]; + return lines.join('\n'); +} + +export { getVirtualItemStableKey, getVirtualItemFlowGroups }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 2b237edecf..16080e1f5d 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -260,6 +260,15 @@ function logDroppedDataEvent( turnId: string | null, details: Record ): void { + if (details.reason === 'missing_state_machine') { + const knownSession = FlowChatStore.getInstance().getState().sessions.get(sessionId); + if (!knownSession) { + // Unregistered background sessions (e.g., MemoryPhase2 or internal subagents) + // have no UI transcript or state machine. Silently ignore their data events + // instead of spamming webview.log and scheduling repair timers every chunk. + return; + } + } requestRuntimeProjectionRepair(sessionId); log.debug('Dropped agentic data event', { eventName, diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 8ac8537529..aef65729c8 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -12,7 +12,7 @@ import { flowChatStore } from './FlowChatStore'; import { getTurnCompletionNotice } from '../utils/turnCompletionNotice'; import { createAbsoluteSessionTurnIndexResolver } from '../utils/flowChatTurnOrdinal'; import { getTurnOutputDurationMs, getTurnTtftMs } from '../utils/turnUsageMetrics'; -import { projectAdjacentFlowGroups } from '../grouping/groupProjection'; +import { projectAdjacentFlowGroups, canStartFreshFlowGroupRunAt } from '../grouping/groupProjection'; import { hasModelRoundNarrative } from '../grouping/roundGroups'; import { isSuppressedBackgroundTurn } from '../utils/backgroundDelivery'; @@ -119,6 +119,269 @@ let cachedTurnItems = new WeakMap< DialogTurn, { items: VirtualItem[]; hasNewerDialogTurn: boolean; absoluteTurnIndex: number; backgroundCollapsed?: boolean } >(); +interface CachedTurnPrefixProjection { + turnId: string; + isTurnComplete: boolean; + turnStartedAt: number; + turnEndedAt: number | undefined; + turnOutputDurationMs: number | undefined; + turnTtftMs: number | undefined; + turnTokenUsage: DialogTurn['tokenUsage']; + rawItems: VirtualItem[]; + projectedItems: VirtualItem[]; +} + +const previousTurnItemsById = new Map(); +const previousTurnModelRoundsById = new Map(); +const currentTurnModelRoundsById = new Map(); +const cachedTurnPrefixById = new Map(); + +function sameStringList(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function sameFlowGroupData( + a: import('../grouping/types').FlowGroupData | undefined, + b: import('../grouping/types').FlowGroupData | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + if ( + a.groupId !== b.groupId || + a.category !== b.category || + a.isGroupStreaming !== b.isGroupStreaming || + a.phase !== b.phase || + a.needsAttention !== b.needsAttention || + a.isLastGroupInTurn !== b.isLastGroupInTurn || + a.wasCutByCritical !== b.wasCutByCritical || + a.allItems.length !== b.allItems.length || + a.rounds.length !== b.rounds.length || + !sameStringList(a.sourceGroupIds, b.sourceGroupIds) + ) { + return false; + } + for (let i = 0; i < a.allItems.length; i += 1) { + if (a.allItems[i] !== b.allItems[i]) return false; + } + for (let i = 0; i < a.rounds.length; i += 1) { + if (a.rounds[i] !== b.rounds[i]) return false; + } + return true; +} + +function sameProjectedGroups( + a: readonly import('../grouping/types').ModelRoundItemGroup[] | undefined, + b: readonly import('../grouping/types').ModelRoundItemGroup[] | undefined, +): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + const ga = a[i]; + const gb = b[i]; + if (ga === gb) continue; + if (ga.type !== gb.type) return false; + if (ga.type === 'critical' && gb.type === 'critical') { + if (ga.item !== gb.item) return false; + continue; + } + if (ga.type !== 'critical' && gb.type !== 'critical') { + if ( + ga.isLast !== gb.isLast || + ga.items.length !== gb.items.length || + !sameFlowGroupData(ga.projection, gb.projection) + ) { + return false; + } + for (let j = 0; j < ga.items.length; j += 1) { + if (ga.items[j] !== gb.items[j]) return false; + } + } + } + return true; +} + +function canReuseVirtualItem(prev: VirtualItem, next: VirtualItem): boolean { + if (prev === next) return true; + if (prev.type !== next.type || prev.turnId !== next.turnId) return false; + switch (next.type) { + case 'user-message': { + const p = prev as typeof next; + const prevRounds = previousTurnModelRoundsById.get(next.turnId); + const currRounds = currentTurnModelRoundsById.get(next.turnId); + const firstRoundSame = prevRounds?.[0] !== undefined && prevRounds[0] === currRounds?.[0]; + return ( + firstRoundSame && + p.data === next.data && + p.absoluteTurnIndex === next.absoluteTurnIndex && + p.turnStatus === next.turnStatus && + p.submissionPhase === next.submissionPhase && + p.submissionError === next.submissionError && + p.hostQueueStatus === next.hostQueueStatus && + p.hostQueueAttachmentCount === next.hostQueueAttachmentCount + ); + } + case 'user-steering-message': { + const p = prev as typeof next; + return ( + p.steeringId === next.steeringId && + p.steeringStatus === next.steeringStatus && + p.data.id === next.data.id && + p.data.content === next.data.content && + p.data.timestamp === next.data.timestamp + ); + } + case 'model-round': { + const p = prev as typeof next; + return ( + p.data === next.data && + p.isLastRound === next.isLastRound && + p.isTurnComplete === next.isTurnComplete && + p.turnStartedAt === next.turnStartedAt && + p.turnEndedAt === next.turnEndedAt && + p.turnDurationMs === next.turnDurationMs && + p.turnOutputDurationMs === next.turnOutputDurationMs && + p.turnTtftMs === next.turnTtftMs && + p.turnTokenUsage === next.turnTokenUsage && + sameStringList(p.layoutHints?.expandedThinkingItemIds, next.layoutHints?.expandedThinkingItemIds) && + sameProjectedGroups(p.projectedGroups, next.projectedGroups) + ); + } + default: + return false; + } +} + +function reconcileProjectedGroupsInPlace( + prevGroups: readonly import('../grouping/types').ModelRoundItemGroup[] | undefined, + nextGroups: import('../grouping/types').ModelRoundItemGroup[] | undefined, +): import('../grouping/types').ModelRoundItemGroup[] | undefined { + if (!prevGroups || !nextGroups) return nextGroups; + let allSame = prevGroups.length === nextGroups.length; + const minLen = Math.min(prevGroups.length, nextGroups.length); + for (let i = 0; i < minLen; i += 1) { + const pg = prevGroups[i]; + const ng = nextGroups[i]; + if (pg === ng) continue; + if (pg.type === ng.type) { + if (pg.type === 'critical' && ng.type === 'critical') { + if (pg.item === ng.item) { + nextGroups[i] = pg; + continue; + } + } else if (pg.type !== 'critical' && ng.type !== 'critical') { + if (sameFlowGroupData(pg.projection, ng.projection)) { + ng.projection = pg.projection; + } + if ( + pg.isLast === ng.isLast && + pg.items.length === ng.items.length && + pg.projection === ng.projection && + pg.items.every((item, idx) => item === ng.items[idx]) + ) { + nextGroups[i] = pg; + continue; + } + } + } + allSame = false; + } + return allSame ? (prevGroups as import('../grouping/types').ModelRoundItemGroup[]) : nextGroups; +} + +function reconcileTurnVirtualItems(turnId: string, nextItems: VirtualItem[]): VirtualItem[] { + const prevItems = previousTurnItemsById.get(turnId); + if (!prevItems || prevItems.length === 0) { + previousTurnItemsById.set(turnId, nextItems); + return nextItems; + } + for (let i = 0; i < nextItems.length; i += 1) { + const candidate = prevItems[i]; + if (candidate && canReuseVirtualItem(candidate, nextItems[i])) { + nextItems[i] = candidate; + } else if (nextItems[i].type === 'model-round' && candidate?.type === 'model-round') { + const prevRound = candidate; + const nextRound = nextItems[i] as typeof prevRound; + nextRound.projectedGroups = reconcileProjectedGroupsInPlace( + prevRound.projectedGroups, + nextRound.projectedGroups, + ); + } + } + previousTurnItemsById.set(turnId, nextItems); + return nextItems; +} + +function projectTurnItemsWithPrefixCache( + turn: DialogTurn, + rawTurnItems: VirtualItem[], + isTurnComplete: boolean, + turnOutputDurationMs: number | undefined, + turnTtftMs: number | undefined, +): VirtualItem[] { + const prev = cachedTurnPrefixById.get(turn.id); + let reuseCount = 0; + + if ( + prev && + prev.isTurnComplete === isTurnComplete && + prev.turnStartedAt === turn.startTime && + prev.turnEndedAt === turn.endTime && + prev.turnOutputDurationMs === turnOutputDurationMs && + prev.turnTtftMs === turnTtftMs && + prev.turnTokenUsage === turn.tokenUsage && + prev.rawItems.length === prev.projectedItems.length + ) { + const limit = Math.min(prev.rawItems.length, rawTurnItems.length) - 1; + let commonLen = 0; + while ( + commonLen < limit && + ((commonLen === 0 && + prev.rawItems[0]?.type === 'user-message' && + rawTurnItems[0]?.type === 'user-message' && + prev.rawItems[0].data === rawTurnItems[0].data && + prev.rawItems[0].turnStatus === rawTurnItems[0].turnStatus && + prev.rawItems[0].absoluteTurnIndex === rawTurnItems[0].absoluteTurnIndex) || + canReuseVirtualItem(prev.rawItems[commonLen], rawTurnItems[commonLen])) + ) { + commonLen += 1; + } + for (let k = commonLen - 1; k >= 2; k -= 1) { + if (canStartFreshFlowGroupRunAt(rawTurnItems[k - 1], rawTurnItems[k])) { + reuseCount = k; + break; + } + } + } + + currentTurnModelRoundsById.set(turn.id, turn.modelRounds); + const projectedTurnItems = reuseCount > 1 && prev + ? [ + prev.projectedItems[0], + ...prev.projectedItems.slice(1, reuseCount), + ...projectAdjacentFlowGroups(rawTurnItems.slice(reuseCount), { isTurnComplete }), + ] + : projectAdjacentFlowGroups(rawTurnItems, { isTurnComplete }); + + const reconciled = reconcileTurnVirtualItems(turn.id, projectedTurnItems); + previousTurnModelRoundsById.set(turn.id, turn.modelRounds); + cachedTurnPrefixById.set(turn.id, { + turnId: turn.id, + isTurnComplete, + turnStartedAt: turn.startTime, + turnEndedAt: turn.endTime, + turnOutputDurationMs, + turnTtftMs, + turnTokenUsage: turn.tokenUsage, + rawItems: rawTurnItems, + projectedItems: reconciled, + }); + return reconciled; +} /** * Convert Session to virtualized render items @@ -139,6 +402,10 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { cachedTotalTurnCount = undefined; cachedVirtualItems = []; cachedTurnItems = new WeakMap(); + previousTurnItemsById.clear(); + previousTurnModelRoundsById.clear(); + currentTurnModelRoundsById.clear(); + cachedTurnPrefixById.clear(); } return cachedVirtualItems; } @@ -152,6 +419,12 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { ) { return cachedVirtualItems; } + if (cachedSession?.sessionId !== session.sessionId) { + previousTurnItemsById.clear(); + previousTurnModelRoundsById.clear(); + currentTurnModelRoundsById.clear(); + cachedTurnPrefixById.clear(); + } cachedSession = session; cachedDialogTurnsRef = session.dialogTurns; @@ -338,12 +611,18 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { }); } - const projectedTurnItems = projectAdjacentFlowGroups(items.slice(turnItemStart), { isTurnComplete }); + const projectedTurnItems = projectTurnItemsWithPrefixCache( + turn, + items.slice(turnItemStart), + isTurnComplete, + turnOutputDurationMs, + turnTtftMs, + ); items.splice(turnItemStart, items.length - turnItemStart, ...projectedTurnItems); if (isStableTurnProjection(turn)) { cachedTurnItems.set(turn, { - items: items.slice(turnItemStart), + items: projectedTurnItems, hasNewerDialogTurn, absoluteTurnIndex, }); @@ -412,6 +691,10 @@ export const createModernFlowChatStore = (initialSession?: Session | null) => cr cachedTotalTurnCount = undefined; cachedVirtualItems = []; cachedTurnItems = new WeakMap(); + previousTurnItemsById.clear(); + previousTurnModelRoundsById.clear(); + currentTurnModelRoundsById.clear(); + cachedTurnPrefixById.clear(); cachedNeedsUserAttention = undefined; set((state) => ({ diff --git a/src/web-ui/src/shared/utils/resourceBudget.ts b/src/web-ui/src/shared/utils/resourceBudget.ts index 77d508e0d6..2f5e1d35b7 100644 --- a/src/web-ui/src/shared/utils/resourceBudget.ts +++ b/src/web-ui/src/shared/utils/resourceBudget.ts @@ -88,15 +88,125 @@ if (import.meta.env.DEV || globalThis.__OPENBITFUN_PERF_TRACE_ENABLED__ === true globalThis.__OPENBITFUN_RESOURCE_BUDGET__ = { snapshot: () => resourceBudget.snapshot() }; } +const completedModelRoundBytesCache = new WeakMap(); +const turnCompletedRoundPrefixCache = new Map(); + +function isCacheableCompletedModelRound(obj: Record): boolean { + return ( + obj.status === 'completed' && + obj.isStreaming === false && + obj.isComplete === true && + typeof obj.id === 'string' && + Array.isArray(obj.items) + ); +} + +function estimateCompletedRoundBytes(round: Record, seen: Set): number { + const cached = completedModelRoundBytesCache.get(round); + if (cached && cached.itemsRef === round.items && cached.attemptsRef === round.attempts) { + return cached.bytes; + } + let roundBytes = 32; + for (const [key, child] of Object.entries(round)) { + roundBytes += key.length * 2 + 8 + estimateRetainedBytes(child, seen); + } + completedModelRoundBytesCache.set(round, { + itemsRef: round.items, + attemptsRef: round.attempts, + bytes: roundBytes, + }); + return roundBytes; +} + +function estimateModelRoundsArrayBytes( + turnId: string, + rounds: unknown[], + seen: Set, +): number { + if (seen.has(rounds)) return 0; + seen.add(rounds); + const totalLen = rounds.length; + if (totalLen === 0) return 32; + + const completedPrefixCount = + totalLen > 1 && + rounds[0] && + typeof rounds[0] === 'object' && + isCacheableCompletedModelRound(rounds[totalLen - 2] as Record) + ? totalLen - 1 + : 0; + + let startIdx = 0; + let bytes = 32; + + if (completedPrefixCount > 0) { + const firstRound = rounds[0] as object; + const lastCompleted = rounds[completedPrefixCount - 1] as object; + const prevPrefix = turnCompletedRoundPrefixCache.get(turnId); + if ( + prevPrefix && + prevPrefix.firstRound === firstRound && + prevPrefix.completedCount === completedPrefixCount && + prevPrefix.lastCompletedRound === lastCompleted + ) { + bytes += prevPrefix.prefixBytes; + startIdx = completedPrefixCount; + } else { + let prefixBytes = 0; + let allCompleted = true; + for (let i = 0; i < completedPrefixCount; i += 1) { + const round = rounds[i]; + if (!round || typeof round !== 'object' || !isCacheableCompletedModelRound(round as Record)) { + allCompleted = false; + break; + } + const indexDigits = String(i).length; + seen.add(round); + prefixBytes += indexDigits * 2 + 8 + estimateCompletedRoundBytes(round as Record, seen); + } + if (allCompleted) { + turnCompletedRoundPrefixCache.set(turnId, { + firstRound, + lastCompletedRound: lastCompleted, + completedCount: completedPrefixCount, + prefixBytes, + }); + bytes += prefixBytes; + startIdx = completedPrefixCount; + } + } + } + + for (let i = startIdx; i < totalLen; i += 1) { + const indexDigits = String(i).length; + bytes += indexDigits * 2 + 8 + estimateRetainedBytes(rounds[i], seen); + } + return bytes; +} + /** No stringify-sized temporary copy. Shared references are counted once per working set. */ export function estimateRetainedBytes(value: unknown, seen = new Set()): number { if (typeof value === 'string') return value.length * 2; if (!value || typeof value !== 'object') return 8; if (seen.has(value)) return 0; seen.add(value); + const record = value as Record; + if (isCacheableCompletedModelRound(record)) { + return estimateCompletedRoundBytes(record, seen); + } + const isDialogTurn = typeof record.id === 'string' && Array.isArray(record.modelRounds); let bytes = 32; - for (const [key, child] of Object.entries(value)) { - bytes += key.length * 2 + 8 + estimateRetainedBytes(child, seen); + for (const [key, child] of Object.entries(record)) { + if (isDialogTurn && key === 'modelRounds' && Array.isArray(child)) { + bytes += key.length * 2 + 8 + estimateModelRoundsArrayBytes(record.id as string, child, seen); + } else { + bytes += key.length * 2 + 8 + estimateRetainedBytes(child, seen); + } } return bytes; }