From 860fde1361741b78e45eb72fa5acfd76cce1bb67 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 11:12:09 +0800 Subject: [PATCH 1/7] perf(flow-chat): reuse measured rows and projection when reopening a session Switching sessions remounts the transcript, which used to discard the virtualizer's measured sizes and the timeline projection. A reopened session then re-reserved estimates, and the opening reveal corrected every mounted row and dropped rows it had selected from those estimates. Keep the projection and the measured row sizes (keyed by stable item key) for the three most recently left views, keyed by the view's reader store. A mount claims its entry and returns it on unmount. Retained sizes seed the rows' reservations, so mounted rows are still measured and still replace them. Follow, anchor, opening and viewport ownership stay per mount, and scroll restoration is unchanged. In the supplied-geometry fixture, a native-history reopen goes from 7 corrections / 6 commits / 7 mounts / 3 dropped rows to 0 / 5 / 3 / 0, and a live reopen goes from 6 corrections / 6 mounts to 0 / 5. The Turn rail no longer rebuilds on every streamed flush: its dialog-turn input is kept while Turn ids and user messages are unchanged, capsule previews are parsed once per stored presentation, and FlowChatTurnRail is memoized with a stable navigation callback. --- .../src/flow_chat/components/modern/AGENTS.md | 4 + .../modern/FLOWCHAT_VERIFICATION.md | 7 + .../modern/FLOWCHAT_VIRTUALIZATION.md | 51 +++ .../components/modern/FlowChatTurnRail.tsx | 10 +- .../modern/ModernFlowChatContainer.tsx | 74 ++++- ...rtualMessageList.session-boundary.test.tsx | 1 + ...VirtualMessageList.session-switch.test.tsx | 295 ++++++++++++++++++ .../components/modern/VirtualMessageList.tsx | 15 +- .../modern/useFlowChatVirtualizer.ts | 58 +++- .../flow_chat/timeline/retainedViewMemory.ts | 65 ++++ ...useConversationTimeline.retention.test.tsx | 63 ++++ .../timeline/useConversationTimeline.ts | 24 +- 12 files changed, 638 insertions(+), 29 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx create mode 100644 src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts create mode 100644 src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/AGENTS.md b/src/web-ui/src/flow_chat/components/modern/AGENTS.md index 2ffefd69b6..5c09c691d7 100644 --- a/src/web-ui/src/flow_chat/components/modern/AGENTS.md +++ b/src/web-ui/src/flow_chat/components/modern/AGENTS.md @@ -196,6 +196,10 @@ before reporting a defect as new. - Opening readiness follows the viewport's settled layout signal. The user bubble may legitimately be virtualized away at a long reply's end; do not reintroduce user-message polling or a fixed opening delay. +- A session switch remounts the transcript. Only derived, rebuildable state + survives it — the timeline projection and measured row sizes by stable item + key, in `timeline/retainedViewMemory.ts`, bounded to the three most recently + left views. Follow, anchor, opening and viewport ownership stay per mount. - Viewport snapshot callbacks stay current through a ref. Capture synchronously only on actual session unmount, not in effect cleanup on every history-window change: mutation-phase geometry can see half-replaced virtual padding. diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 14fc86ad03..6b2c4477fa 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -63,6 +63,7 @@ each missing what the other had. | `VirtualMessageList.session-boundary.test.tsx` | prepend compensation, consecutive paging with/without queued input, passive scroll suppression, navigation-target current Turn with gesture/follow/session handoff, search placement only outside the readable viewport, and real viewport-register acknowledgement before trajectory details/highlights commit for user/search/model/folded-tool/history targets | | `VirtualMessageList.resize.test.tsx` | first-frame node and screen-position retention during repeated CSS-only width previews, release, fractional row geometry, native clamping, both row/container observer orders and delayed scroll delivery; same-size viewport recovery observes suspended mounts without further output, manual re-render or scrolling in live/history layouts; completed follow and reader-owned positions on local/simulated peer surfaces, not native or live-transport evidence | | `VirtualMessageList.history-prepend.test.tsx` | full list with real virtualization/follow/ownership: stable row identity and screen offset through short/long completed answers, consecutive/dense prepends, delayed measurement/native scroll and reader-chosen positions on local/simulated peer surfaces; supplied row/DOM geometry, not native or live-transport evidence | +| `VirtualMessageList.session-switch.test.tsx`, `useConversationTimeline.retention.test.tsx` | reopening one of the three most recently left sessions reuses its projection rows and measured row sizes: zero size corrections, fewer commits and row mounts, no dropped rows in the native-history layout, the same settled placement, eviction of older sessions, and a single correction for a row that changed while hidden; supplied DOM geometry on the local surface, not native switching time, paint or remote evidence | | `VirtualMessageList.timeline-prepend.test.tsx`, `FlowChatPrependSnapshot.test.tsx` | production timeline whole/compound/single-Turn pages; native bottom-origin continuity before observer delivery and zero passive scroll writes, stable hydration lifetime, fractional measurements, bounded long histories, empty-projection navigation, chronological navigation, legacy whole-text and section bookmark coordinates, live handoff and reader-selected block retention; settled host ID replacement retains measured row nodes/positions on every frame and uses canonical bookmarks across reopen; supplied DOM geometry on local/simulated Peer, not native paint or live transport evidence | | `flowChatScrollbarMask.test.ts`, `VirtualMessageList.session-boundary.test.tsx` | idle gutter masking with symmetric, one-sided and overlay scrollbars; overflow restoration and content/viewport resizing without scroll writes; supplied DOM geometry, not native rendering or live remote evidence | | `VirtualMessageList.edgeFade.test.ts`, `VirtualMessageList.session-boundary.test.tsx` | repeated scroll-away/back restores the opaque top edge even when an earlier native listener synchronously commits a virtual window; fractional/top-overscroll offsets, one follow notification per event, current gesture callbacks and old-session cleanup; DOM/CSS contracts, not native repaint or live remote evidence | @@ -433,6 +434,12 @@ forced colors. Automated DOM/CSS checks do not establish this paint behavior. 6. From that reading position, switch to Settings and back. The session scene remains mounted with usable geometry while inactive, and the viewport must not enter host suspension or move to the tail. +7. Alternate between two long sessions several times. Each reopen must paint + its rows at their final heights, with no settling movement or scrollbar-thumb + jump. Repeat after resizing the pane while the other session is shown. Then + visit three other sessions and return: that reopen may settle as a first + open does. Pending: native switching time has not been measured with + retention. ### Submitting and revealing diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 911161dd2c..e799235635 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -103,6 +103,57 @@ summaries. Catalog corrections, partial-tail offsets, provisional usage reports and explicit history-window offsets keep their existing precedence. No Session, Turn body, viewport position or editor state is retained by this cache. +### Session switch retention + +`VirtualMessageList` keys its session subtree by session id, so a switch +remounts the virtualizer, its key cache, every row and the timeline projection. +Before retention, reopening a session cost exactly what its first open did: rows +reserved estimates again, the opening reveal corrected each mounted row, and +rows selected from those estimates mounted only to be dropped. In the +supplied-geometry fixture (24 Turns, real heights far from estimates) a +native-history open spent 7 size corrections, 6 commits, 7 row mounts and 3 +dropped rows; a live open 6 / 6 / 6 / 2. + +The remount itself stays. Follow, anchor, opening reveal and viewport +ownership start fresh on every mount, and scroll restoration still comes only +from the saved viewport snapshot. What survives is derived and rebuildable: + +- `retainedTimelineProjections` (`useConversationTimeline.ts`) keeps the + `ConversationDocumentProjection`, so a reopened view resumes its rows and its + view-local key aliases instead of rebuilding them. +- `retainedTranscriptRowSizes` (`timeline/retainedViewMemory.ts`) keeps the + measured row sizes by stable item key, newest first and at most 4096 per + view. `useFlowChatVirtualizer` seeds them as the rows' reservations on mount + (`retainedItemSizes`) and hands them back through `snapshotItemSizes()`. The + live tail seed sums reservations, so it also starts from retained sizes. + +Both are keyed by the view's reader store, which is already scoped by surface +epoch, viewport and session, so device sources never share an entry. A mounted +view claims its entry and returns it on unmount; only the three most recently +left views are held. A retained size is a reservation like any other: a +mounted row is still measured and still replaces it, and a width or font change +leaves it in place under the same rule as a live reservation. The snapshot +reads no geometry, so the unmount-phase DOM cannot skew it. A retained +projection holds its rows strongly, so up to three inactive views keep their +derived rows until they are reopened or evicted. + +With retention the same fixture reopens a native-history session with 0 +corrections, 5 commits, 3 mounts and 0 dropped rows, at the same placement. A +live reopen makes 0 corrections and 5 mounts at an unchanged 6 commits; it +still drops 2 rows, because the +one-time tail seed starts at the last item's start before follow takes the end. +A row that changed while the session was hidden costs one correction. +`VirtualMessageList.session-switch.test.tsx` and +`useConversationTimeline.retention.test.tsx` cover this with supplied geometry +on the local surface. Native switching time, paint and remote surfaces are not +measured by them. + +The Turn rail no longer rebuilds per flush. `ModernFlowChatContainer` keeps the +previous `dialogTurns` input while every Turn's id, user-message content and +composer presentation are unchanged, parses capsule previews once per stored +presentation object, and passes `FlowChatTurnRail` (now `React.memo`) a stable +navigation callback and empty visible-Turn list. + ### Settled record identity Live text/thinking items can have client-generated IDs, while the host's settled diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx index 4d2ec20694..c3020a951f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx @@ -34,12 +34,16 @@ interface FlowChatTurnRailViewportMetrics { clientHeight: number; } -export const FlowChatTurnRail: React.FC = ({ +/** + * Memoized: its parent re-renders on every streamed flush, while the rail's + * inputs change only with Turn identity, the visible Turn, or navigation. + */ +export const FlowChatTurnRail = React.memo(function FlowChatTurnRail({ turns, currentTurnId, visibleTurnIds, onNavigate, -}) => { +}: FlowChatTurnRailProps) { const { t } = useTranslation('flow-chat'); const railRef = useRef(null); const listRef = useRef(null); @@ -361,6 +365,6 @@ export const FlowChatTurnRail: React.FC = ({ ); -}; +}); FlowChatTurnRail.displayName = 'FlowChatTurnRail'; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 4032e08f98..ae123ae1e5 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -54,7 +54,7 @@ import { type VirtualItem, type VisibleTurnInfo, } from '../../store/modernFlowChatStore'; -import type { Session, SessionHistoryPresentation } from '../../types/flow-chat'; +import type { DialogTurn, Session, SessionHistoryPresentation } from '../../types/flow-chat'; import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; import { FLOWCHAT_MESSAGE_SUBMITTED_EVENT, @@ -212,6 +212,36 @@ type BackgroundCommandSummary = { }; const LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS = 8; +const NO_VISIBLE_TURN_IDS: readonly string[] = []; + +/** + * A rail entry's capsule preview, parsed once per stored presentation. + * Stored presentations are immutable records, so their identity is enough. + */ +const turnRailPreviews = new WeakMap(); +function turnRailPreviewFor(turn: DialogTurn | undefined): FlowChatTurnRailItem['capsulePreview'] { + const value: unknown = turn?.userMessage?.metadata?.composerPresentation; + if (!value || typeof value !== 'object') return undefined; + let preview = turnRailPreviews.get(value); + if (preview === undefined) { + preview = composerPresentationToTurnRailPreview(parseComposerPresentation(value)) ?? null; + turnRailPreviews.set(value, preview); + } + return preview ?? undefined; +} + +/** Streaming replaces Turn objects without changing anything the rail reads. */ +function sameTurnRailSources(left: readonly DialogTurn[], right: readonly DialogTurn[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const a = left[index], b = right[index]; + if (a === b) continue; + if (a.id !== b.id + || a.userMessage?.content !== b.userMessage?.content + || a.userMessage?.metadata?.composerPresentation !== b.userMessage?.metadata?.composerPresentation) return false; + } + return true; +} const HISTORY_LOADING_LAYER_STALL_WARN_MS = 800; const TURN_PIN_RETRY_MAX_ATTEMPTS = 120; const MOCK_BACKGROUND_COMMANDS_STORAGE_KEY = 'openbitfun.flowChat.mockBackgroundCommands'; @@ -1096,12 +1126,22 @@ export const ModernFlowChatContainer: React.FC = ( const absoluteRenderedTurnSummaryById = useMemo(() => { return new Map(absoluteRenderedTurnSummaries.map(turn => [turn.turnId, turn])); }, [absoluteRenderedTurnSummaries]); + // Every streamed flush replaces `dialogTurns`; the rail only reads each + // Turn's identity and user message, so it keeps the previous array until + // one of those changes. Paging and rollback change ids or length. + const turnRailSourcesRef = useRef([]); + const turnRailSources = useMemo(() => { + const next = activeSession?.dialogTurns ?? []; + if (sameTurnRailSources(turnRailSourcesRef.current, next)) return turnRailSourcesRef.current; + turnRailSourcesRef.current = next; + return next; + }, [activeSession?.dialogTurns]); const turnRailItems = useMemo(() => { const historyView = activeSession?.sessionId ? flowChatStore.getSessionHistoryViewState(activeSession.sessionId) : undefined; const loadedTurns = [ - ...(activeSession?.dialogTurns ?? []), + ...turnRailSources, ...(historyView?.loadedRanges.flatMap(range => range.turns) ?? []), ]; const dialogTurnById = new Map(loadedTurns.map(turn => [turn.id, turn])); @@ -1112,9 +1152,7 @@ export const ModernFlowChatContainer: React.FC = ( const loaded = { turnId: turn.id, content: turn.userMessage?.content ?? '', - capsulePreview: composerPresentationToTurnRailPreview( - parseComposerPresentation(turn.userMessage?.metadata?.composerPresentation), - ), + capsulePreview: turnRailPreviewFor(turn), }; loadedByOrdinal.set(range.startOrdinal + index, loaded); const storageTurnIndex = turn.storageTurnIndex ?? turn.backendTurnIndex; @@ -1128,9 +1166,7 @@ export const ModernFlowChatContainer: React.FC = ( const loaded = { turnId: summary.turnId, content: dialogTurn?.userMessage?.content ?? '', - capsulePreview: composerPresentationToTurnRailPreview( - parseComposerPresentation(dialogTurn?.userMessage?.metadata?.composerPresentation), - ), + capsulePreview: turnRailPreviewFor(dialogTurn), }; loadedByOrdinal.set(Math.max(0, summary.turnIndex - 1), loaded); if (typeof summary.storageTurnIndex === 'number') { @@ -1153,9 +1189,7 @@ export const ModernFlowChatContainer: React.FC = ( ?? (catalogEntry?.turnId && catalogDialogTurn ? { turnId: catalogEntry.turnId, content: catalogDialogTurn.userMessage?.content ?? '', - capsulePreview: composerPresentationToTurnRailPreview( - parseComposerPresentation(catalogDialogTurn.userMessage?.metadata?.composerPresentation), - ), + capsulePreview: turnRailPreviewFor(catalogDialogTurn), } : undefined) ?? loadedByOrdinal.get(ordinal); const turnId = loaded?.turnId ?? catalogEntry?.turnId ?? null; @@ -1184,16 +1218,14 @@ export const ModernFlowChatContainer: React.FC = ( ordinal: Math.max(0, summary.turnIndex - 1), turnIndex: summary.turnIndex, content: dialogTurnById.get(summary.turnId)?.userMessage?.content ?? '', - capsulePreview: composerPresentationToTurnRailPreview( - parseComposerPresentation(dialogTurnById.get(summary.turnId)?.userMessage?.metadata?.composerPresentation), - ), + capsulePreview: turnRailPreviewFor(dialogTurnById.get(summary.turnId)), }); } return items.sort((left, right) => left.turnIndex - right.turnIndex); }, [ absoluteRenderedTurnSummaries, - activeSession?.dialogTurns, + turnRailSources, activeSession?.sessionId, sessionTotalTurnCount, turnCatalog, @@ -1894,6 +1926,14 @@ export const ModernFlowChatContainer: React.FC = ( updateViewportIntent, ]); + // The rail is memoized; a click reads the committed navigation instead of + // re-rendering the rail whenever one of its many inputs changes. + const navigateToTurnRef = useRef(navigateToTurn); + useLayoutEffect(() => { navigateToTurnRef.current = navigateToTurn; }); + const navigateFromTurnRail = useCallback((target: FlowChatTurnRailItem) => { + void navigateToTurnRef.current(target); + }, []); + const handleNavigateToFocusTurn = useCallback(async (request: FlowChatFocusItemRequest) => { const sessionId = activeSession?.sessionId; if (!sessionId || request.sessionId !== sessionId) { @@ -2843,8 +2883,8 @@ export const ModernFlowChatContainer: React.FC = ( ) : null} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 650ba1ca0d..4e47008215 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -205,6 +205,7 @@ vi.mock('./useFlowChatVirtualizer', async () => { scrollItemIntoView: mocks.scrollItemIntoView, scrollToOffset: mocks.scrollToOffset, cancelAim: mocks.cancelAim, + snapshotItemSizes: () => new Map(), }; }, }; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx new file mode 100644 index 0000000000..ae30674ffc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx @@ -0,0 +1,295 @@ +// @vitest-environment jsdom +import React, { 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 './VirtualMessageList'; +import { retainedTranscriptRowSizes } from '../../timeline/retainedViewMemory'; +import { getVirtualItemStableKey } from './virtualItemIdentity'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import type { Session } from '../../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const state = vi.hoisted(() => ({ items: [] as VirtualItem[], session: null as Session | null, processing: false })); +const counters = vi.hoisted(() => ({ corrections: 0, commits: 0, mounts: 0, unmounts: 0 })); +vi.mock('../../store/modernFlowChatStore', () => { + const store = Object.assign((select: (state: unknown) => unknown) => select({ visibleTurnInfo: null }), + { getState: () => ({ setVisibleTurnInfo() {} }) }); + return { useVirtualItems: () => state.items, useActiveSession: () => state.session, + useModernFlowChatStore: store, useModernFlowChatStoreApi: () => store }; +}); +vi.mock('../../timeline/useConversationTimeline', async () => { + const { FlowChatReaderState } = await import('../../timeline/readerState'); + const readers = new Map>(); + const readerFor = (sessionId = '') => { + let reader = readers.get(sessionId); + if (!reader) readers.set(sessionId, reader = new FlowChatReaderState()); + return reader; + }; + return { useConversationTimeline: (items: VirtualItem[], sessionId?: string) => ({ items, reader: readerFor(sessionId) }) }; +}); +// Count the size corrections the virtualizer accepts: a row whose measured +// height differs from the size it had reserved for it. +vi.mock('@tanstack/react-virtual', async importOriginal => { + const actual = await importOriginal(); + class CountingVirtualizer extends actual.Virtualizer { + constructor(options: ConstructorParameters>[0]) { + super(options); + const resize = this.resizeItem; + this.resizeItem = (index, size) => { + const item = this.measurementsCache[index]; + const cache = (this as unknown as { itemSizeCache: Map }).itemSizeCache; + if (item && (cache.get(item.key) ?? item.size) !== size) counters.corrections += 1; + resize(index, size); + }; + } + } + return { ...actual, Virtualizer: CountingVirtualizer }; +}); +vi.mock('../../hooks/useActiveSessionState', () => ({ useActiveSessionState: () => ({ isProcessing: state.processing }) })); +vi.mock('../../store/chatInputStateStore', () => ({ useChatInputState: (select: (state: unknown) => unknown) => select({ inputHeight: 100 }) })); +vi.mock('../../hooks/useScrollToTurnHeader', () => ({ useScrollToTurnHeader: () => ({ shouldShowButton: false, handleClick() {} }) })); +vi.mock('./RuntimeStatusSlot', () => ({ RuntimeStatusSlot: () => null })); +vi.mock('../ScrollToLatestBar', () => ({ ScrollToLatestBar: () => null })); +vi.mock('../ScrollToTurnHeaderButton', () => ({ ScrollToTurnHeaderButton: () => null })); +vi.mock('./VirtualItemRenderer', async () => { + const { useLayoutEffect } = await import('react'); + return { + VirtualItemRenderer: ({ item, index, measureRef }: { + item: VirtualItem; index: number; measureRef: (element: HTMLElement | null) => void; + }) => { + useLayoutEffect(() => { counters.mounts += 1; return () => { counters.unmounts += 1; }; }, []); + return
; + }, + }; +}); + +// Full list, virtualizer, viewport owners and follow controller. Only row +// contents and jsdom's absent layout engine are supplied by this fixture, +// which is the one `VirtualMessageList.resize.test.tsx` uses. +describe('switching between long sessions (real controllers, supplied DOM geometry)', () => { + let root: Root, host: HTMLDivElement; + let frames: Map, frameId: number, now: number; + let observers: Array<{ callback: ResizeObserverCallback; targets: Set; sizes: WeakMap }>; + let columnWidth = 900; + let viewportHeight = 600; + let containerFirst = false; + const heights = new Map(); + const offsets = new WeakMap(); + const px = (value: string) => Number.parseFloat(value || '0') || 0; + const reversed = (element: HTMLElement) => element.dataset.flowchatScrollOrigin === 'end'; + // Cache only within one synchronous geometry read. DOM/style and supplied + // row heights can change between any two reads, so no frame-level cache is safe. + function height(element: HTMLElement, measured = new WeakMap()): number { + const cached = measured.get(element); + if (cached !== undefined) return cached; + const result = measureHeight(element, measured); + measured.set(element, result); + return result; + } + function measureHeight(element: HTMLElement, measured: WeakMap): number { + if (element.hasAttribute('data-flowchat-scroller')) return viewportHeight; + if (element.dataset.virtualItemKey) return heights.get(element.dataset.virtualItemKey) ?? 100; + if (element.style.height) return px(element.style.height); + const scroller = element.closest('[data-flowchat-scroller]'); + if (scroller && reversed(scroller)) { + const minimum = px(scroller.style.getPropertyValue('--_flow-chat-history-turn-min-height')); + if (element.classList.contains('virtual-message-list__footer-block')) { + const turn = element.parentElement!; + const preceding = [...turn.children].filter(child => child !== element).reduce((sum, child) => sum + height(child as HTMLElement, measured), 0); + const natural = [...element.children].reduce((sum, child) => sum + height(child as HTMLElement, measured), 0); + return Math.max(natural, minimum - preceding); + } + if (element.dataset.historyTail === 'true') return Math.max(minimum, [...element.children].reduce((sum, child) => sum + height(child as HTMLElement, measured), 0)); + } + return Math.max(element.classList.contains('virtual-message-list__extent') && reversed(element.parentElement!) ? 600 : px(element.style.minHeight), px(element.style.paddingTop) + px(element.style.paddingBottom) + + [...element.children].reduce((sum, child) => sum + height(child as HTMLElement, measured), 0)); + } + 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 += height(sibling as HTMLElement, measured); sibling = sibling.previousElementSibling; } + current = current.parentElement; + top += px(current.style.paddingTop); + if (current.hasAttribute('data-flowchat-scroller') && reversed(current)) top += 600 - height(current.firstElementChild as HTMLElement, measured); + } + return top; + } + function turn(id: string, answerHeight: number): VirtualItem[] { + const items = [ + { type: 'user-message', turnId: id, data: { id: `user-${id}`, content: id } }, + { type: 'model-round', turnId: id, data: { id: `answer-${id}`, items: [], content: id }, isLastRound: true, isTurnComplete: true }, + ] as VirtualItem[]; + heights.set(getVirtualItemStableKey(items[0]), 44); + heights.set(getVirtualItemStableKey(items[1]), answerHeight); + return items; + } + const render = (contentWidth = 900) => act(() => root.render( + { counters.commits += 1; }}> + + , + )); + const tick = () => { + now += 16; + const pending = [...frames.values()]; frames.clear(); + act(() => pending.forEach(callback => callback(now))); + }; + const resize = () => act(() => { + for (const observer of (containerFirst ? [...observers].reverse() : [...observers])) { + // ResizeObserver delivers changed boxes, not every observed node on every + // frame. Collect sizes before calling back, since callbacks can mutate DOM. + const measured = new WeakMap(); + const entries = [...observer.targets].filter(target => target.isConnected).flatMap(target => { + const blockSize = height(target as HTMLElement, measured); + const inlineSize = target.classList.contains('virtual-item-wrapper') ? columnWidth : 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'); columnWidth = 900; viewportHeight = 600; containerFirst = false; + state.processing = false; + frames = new Map(); frameId = 0; now = 0; observers = []; heights.clear(); + vi.useFakeTimers(); + vi.spyOn(performance, 'now').mockImplementation(() => now); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); 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(height(this)); }); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function () { return Math.round(height(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') ? height(this.firstElementChild as HTMLElement) : height(this)); + }); + vi.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation(function () { + const range = Math.max(0, this.scrollHeight - this.clientHeight); + return reversed(this) ? Math.max(-range, Math.min(0, offsets.get(this) ?? 0)) : Math.min(offsets.get(this) ?? 0, range); + }); + vi.spyOn(HTMLElement.prototype, 'scrollTop', 'set').mockImplementation(function (value) { + const range = Math.max(0, this.scrollHeight - this.clientHeight); + offsets.set(this, reversed(this) ? Math.max(-range, Math.min(0, value)) : 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 = this === scroller ? 0 : contentTop(this, measured) - (scroller?.scrollTop ?? 0); + return { x: 0, y: top, left: 0, right: 900, top, bottom: top + height(this, measured), height: height(this, measured), width: this.classList.contains('virtual-item-wrapper') ? columnWidth : 900, toJSON() {} }; + }); + Object.assign(counters, { corrections: 0, commits: 0, mounts: 0, unmounts: 0 }); + retainedTranscriptRowSizes.clear(); + sessions = undefined!; + host = document.createElement('div'); document.body.append(host); root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); host.remove(); activateSurface('local'); + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); + }); + + + // Two long transcripts whose real heights are far from their estimates. + function session(id: string, turns: number) { + const items = Array.from({ length: turns }, (_, index) => turn(`${id}-${index === turns - 1 ? 'latest' : index}`, + 380.5 + ((index * 7919) % 13) * 97.25)).flat(); + return { items, session: { sessionId: id, config: {}, dialogTurns: [], isHistorical: false } as unknown as Session }; + } + type SessionId = 'a' | 'b' | 'c' | 'd' | 'e'; + let sessions: Record>; + const settled = () => host.querySelector('[data-open-viewport-settled="true"]') !== null; + /** Open a session and run frames until the opening reveal settles; return what that cost. */ + function open(id: SessionId) { + sessions ??= {} as typeof sessions; + sessions[id] ??= session(id, 24); + const mountedBefore = host.querySelectorAll('[data-virtual-item-key]').length; + Object.assign(counters, { corrections: 0, commits: 0, mounts: 0, unmounts: 0 }); + state.items = sessions[id].items; + state.session = sessions[id].session; + render(); + let frames = 0; + for (; frames < 120 && (!settled() || frames < 4); frames++) { resize(); tick(); } + for (let frame = 0; frame < 8; frame++) { resize(); tick(); } + const latestRow = host.querySelector(`[data-virtual-item-key="model-round:${id}-latest:answer-${id}-latest"]`); + // Rows of this session that mounted and were unmounted again while it opened. + const churn = counters.unmounts - mountedBefore; + return { ...counters, churn, frames, settled: settled(), latestBottom: latestRow?.getBoundingClientRect().bottom ?? null }; + } + + // Measured baseline before retention, per open in this fixture: + // native history 7 corrections / 6 commits / 7 mounts / 3 dropped rows, + // live 6 / 6 / 6 / 2. Reopening cost exactly the same as a first open. + it('reopens a recent native-history session from its measured sizes', () => { + const firstA = open('a'); + const firstB = open('b'); + const reopenA = open('a'); + const reopenB = open('b'); + for (const [first, reopen] of [[firstA, reopenA], [firstB, reopenB]]) { + expect(first.settled && reopen.settled).toBe(true); + expect(first.corrections).toBeGreaterThan(0); + // No mounted row disagrees with what it reserved, so nothing re-measures + // into a new window and no row mounts only to be dropped again. + expect(reopen.corrections).toBe(0); + expect(reopen.churn).toBe(0); + expect(first.churn).toBeGreaterThan(0); + expect(reopen.mounts).toBeLessThan(first.mounts); + expect(reopen.commits).toBeLessThan(first.commits); + // The same placement the first open settled on. + expect(reopen.latestBottom).toBeCloseTo(first.latestBottom!, 0); + } + }); + + it('reopens a recent live session without correcting any mounted row', () => { + state.processing = true; + const firstA = open('a'); + open('b'); + const reopenA = open('a'); + expect(firstA.settled && reopenA.settled).toBe(true); + expect(firstA.corrections).toBeGreaterThan(0); + expect(reopenA.corrections).toBe(0); + expect(reopenA.mounts).toBeLessThan(firstA.mounts); + expect(reopenA.commits).toBeLessThanOrEqual(firstA.commits); + expect(reopenA.latestBottom).toBeCloseTo(firstA.latestBottom!, 0); + }); + + it('keeps only the most recently left sessions', () => { + const firstA = open('a'); + for (const id of ['b', 'c', 'd', 'e'] as const) open(id); + // a was left four sessions ago and has been evicted; d is still retained. + expect(retainedTranscriptRowSizes.size).toBe(3); + const evictedA = open('a'); + expect(evictedA.corrections).toBe(firstA.corrections); + const retainedD = open('d'); + expect(retainedD.corrections).toBe(0); + }); + + it('measures rows that changed since the session was left', () => { + const firstA = open('a'); + open('b'); + // The latest answer grew while a was not shown. + const latestKey = 'model-round:a-latest:answer-a-latest'; + heights.set(latestKey, heights.get(latestKey)! + 240); + const reopenA = open('a'); + expect(reopenA.settled).toBe(true); + expect(reopenA.corrections).toBe(1); + expect(reopenA.corrections).toBeLessThan(firstA.corrections); + const row = host.querySelector(`[data-virtual-item-key="${latestKey}"]`)!; + expect(row.getBoundingClientRect().height).toBe(heights.get(latestKey)); + }); +}); 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 eb335477a3..2eeb5b62da 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -99,6 +99,7 @@ import type { FlowChatViewportSnapshot } from './flowChatViewportSnapshot'; import { getVirtualItemBookmarkKey, getVirtualItemStableKey } from './virtualItemIdentity'; import { useConversationTimeline } from '../../timeline/useConversationTimeline'; import { FlowChatReaderProvider } from '../../timeline/readerState'; +import { retainedTranscriptRowSizes } from '../../timeline/retainedViewMemory'; import { findTimelineBlockIndex } from '../../timeline/document'; import { localizeMarkdownMatch } from '../../timeline/markdownSectionSearch'; import { useTimelineInteraction, type TimelineReaderInteraction } from '../../timeline/useTimelineInteraction'; @@ -139,12 +140,13 @@ const HISTORY_WINDOW_DIRECTIONS: readonly SessionHistoryWindowDirection[] = ['be * Transcripts mounted this session, counted. * * The list is keyed on the session id, so a switch is a full remount: new - * scroller, empty measurement cache, fresh opening reveal, and a follow hook - * that has never followed anything. Nothing in the trail said which instance a + * scroller, a measurement cache seeded only from retained row sizes, fresh + * opening reveal, and a follow hook that has never followed anything. Nothing in the trail said which instance a * line came from, and two of the three viewport faults measured here turned out * to be two instances disagreeing rather than one instance changing its mind. */ let nextViewportInstanceId = 0; + const IDLE_HISTORY_WINDOW_BOUNDARY_STATE: Record< SessionHistoryWindowDirection, 'idle' | 'loading' | 'error' @@ -419,6 +421,8 @@ const VirtualMessageListSession = forwardRef retainedTranscriptRowSizes.peek(reader)); const timelineItemsRef = useRef(virtualItems); timelineItemsRef.current = virtualItems; const nextVisibleItemIndexes = useMemo(() => getNextVisibleVirtualItemIndexes(virtualItems), [virtualItems]); @@ -600,7 +604,14 @@ const VirtualMessageListSession = forwardRef measurementAnchorKeyRef.current(), + retainedItemSizes, }); + const snapshotItemSizes = virtualizer.snapshotItemSizes; + useLayoutEffect(() => { + retainedTranscriptRowSizes.take(reader); + // Sizes only: no geometry is read, so unmount-phase DOM cannot skew them. + return () => retainedTranscriptRowSizes.put(reader, snapshotItemSizes()); + }, [reader, snapshotItemSizes]); const userMessageItems = useMemo(() => virtualItems .map((item, index) => ({ item, index })) 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 a2a6e3268b..715949527d 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -202,6 +202,20 @@ interface MeasuringVirtualizer { getMeasurements: () => ReadonlyArray<{ start: number; end: number }>; } +/** + * The keyed size cache, which the published types also mark `private`. + * + * Read only to hand the measured sizes to the next mount of the same + * transcript; the library replaces the map on every resize, so it is read + * through the instance each time rather than held. + */ +interface SizedVirtualizer { + itemSizeCache: ReadonlyMap; +} + +/** Retained sizes beyond this are older history; estimates serve it as before. */ +const MAX_RETAINED_ITEM_SIZES = 4096; + export interface UseFlowChatVirtualizerOptions { /** Native history grows toward negative scrollTop; indexes start at its tail. */ endOrigin?: boolean; @@ -266,6 +280,15 @@ export interface UseFlowChatVirtualizerOptions { shiftViewport?: (byPx: number) => boolean; /** Stable leading row while its earlier estimated rows finish measuring. */ getViewportAnchorKey?: () => string | null; + /** + * Sizes this transcript's rows measured in an earlier mount, by item key. + * + * Read once, on mount. They become the rows' reservations in place of + * estimates, so a reopened transcript lays out from heights it already + * measured and a row whose height has not changed costs no correction. + * Mounted rows are still measured and still replace them. + */ + retainedItemSizes?: ReadonlyMap; } export interface FlowChatVirtualizer { @@ -331,6 +354,11 @@ export interface FlowChatVirtualizer { * A no-op when no aim is in flight, so it is safe on every wheel notch. */ cancelAim: () => void; + /** + * The sizes worth handing to the next mount of this transcript, by key: + * every current item that has a measured height, the newest first. + */ + snapshotItemSizes: () => Map; } export interface FlowChatVisibleItemRange { @@ -419,6 +447,7 @@ export function useFlowChatVirtualizer({ writeViewport, shiftViewport = () => false, getViewportAnchorKey, + retainedItemSizes, }: UseFlowChatVirtualizerOptions): FlowChatVirtualizer { const items = useMemo(() => endOrigin ? [...sourceItems].reverse() : sourceItems, [sourceItems, endOrigin]); const endOriginRef = useRef(endOrigin); @@ -551,7 +580,11 @@ export function useFlowChatVirtualizer({ * the current items through a ref instead, which is sound because a change * that matters — an item added or removed — moves `count` as well. */ - const reservedEstimatesRef = useRef(new Map()); + // A retained size is a reservation that was once measured. Seeded once; a + // later prop change is not a remount and must not replace live reservations. + const [restoredItemKeys] = useState(() => new Set(retainedItemSizes?.keys() ?? [])); + const [initialReservations] = useState(() => new Map(retainedItemSizes ?? [])); + const reservedEstimatesRef = useRef(initialReservations); const estimateSize = useCallback((index: number) => { const item = itemsRef.current[index]; if (item === undefined) return 0; @@ -605,10 +638,10 @@ export function useFlowChatVirtualizer({ // at 1540.3 -> 806.7ms. These are single-trace timings, not paint guarantees. // Keep an empty hydration from consuming the one-time initial offset. enabled: !initialTailRef.current || hasInitialItemsRef.current, + // Reservations rather than raw estimates: a reopened transcript seeds from + // the sizes it retained. Without any, a reservation is that same estimate. initialOffset: () => !endOrigin && initialTailRef.current - ? itemsRef.current.slice(0, -1).reduce((offset, item) => ( - offset + estimateItemHeightRef.current(item, estimateContextRef.current) - ), 0) + ? itemsRef.current.slice(0, -1).reduce((offset, _item, index) => offset + estimateSize(index), 0) : 0, count: items.length, getScrollElement: () => scrollerRef.current, @@ -1038,6 +1071,22 @@ export function useFlowChatVirtualizer({ virtualizer.scrollToOffset(givenUpAtPx, { align: 'start', behavior: 'auto' }); }, [scrollerRef, virtualizer]); + const snapshotItemSizes = useCallback(() => { + const measured = (virtualizer as unknown as SizedVirtualizer).itemSizeCache; + const reservations = reservedEstimatesRef.current; + const keys = itemKeysRef.current; + const sizes = new Map(); + // Chronological keys, newest first, so the bound keeps the tail a reopen + // lands on. A restored size that matched its row never entered the + // library's cache, but it is still a measurement. + for (let index = keys.length - 1; index >= 0 && sizes.size < MAX_RETAINED_ITEM_SIZES; index -= 1) { + const key = keys[endOriginRef.current ? keys.length - 1 - index : index]; + const size = measured.get(key) ?? (restoredItemKeys.has(key) ? reservations.get(key) : undefined); + if (size !== undefined && Number.isFinite(size)) sizes.set(key, size); + } + return sizes; + }, [restoredItemKeys, virtualizer]); + return { rows, paddingTopPx, @@ -1050,5 +1099,6 @@ export function useFlowChatVirtualizer({ scrollItemIntoView, scrollToOffset, cancelAim, + snapshotItemSizes, }; } diff --git a/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts b/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts new file mode 100644 index 0000000000..c0b5774574 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts @@ -0,0 +1,65 @@ +import type { FlowChatReaderState } from './readerState'; + +/** + * What a transcript view leaves behind when it unmounts, for its next mount. + * + * Switching sessions remounts the whole transcript, and a remount starts from + * estimates: rows reserve guessed heights, the projection rebuilds its keys, + * and the opening reveal spends commits replacing guesses with measurements it + * already had. A view takes its entry back while it is mounted and returns it + * on unmount, so the memory only ever holds views nobody is showing, and only + * the few most recently left. + * + * Keys are the view's reader store, which is already scoped by surface epoch, + * viewport and session. Only derived, rebuildable data belongs here: never + * follow, anchor or viewport ownership, which every mount must start fresh. + */ +export class RetainedViewMemory { + private readonly entries = new Map(); + + constructor(private readonly capacity: number) {} + + /** Read without claiming, for a render that may not commit. */ + peek(key: K): V | undefined { + return this.entries.get(key); + } + + /** Claim the entry for a mounted view, so it does not count against capacity. */ + take(key: K): V | undefined { + const value = this.entries.get(key); + this.entries.delete(key); + return value; + } + + /** Return a view's state on unmount, dropping the least recently left view. */ + put(key: K, value: V): void { + this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.capacity) this.entries.delete(this.entries.keys().next().value as K); + } + + get size(): number { + return this.entries.size; + } + + clear(): void { + this.entries.clear(); + } +} + +/** About three: the session just left, and the one or two before it. */ +export const MAX_RETAINED_SESSION_VIEWS = 3; + +/** + * Measured row sizes of recently left transcripts, by the view's reader store. + * + * Switching sessions remounts this list, and with it the virtualizer's size + * cache. A reopened session reserves what its rows last measured instead of + * estimates, so the opening reveal has nothing to correct for rows that did not + * change. Follow, anchor and viewport ownership are not retained: they belong + * to the mount. Keys come from the retained projection in + * `useConversationTimeline`, so view-local aliases still match. + */ +export const retainedTranscriptRowSizes = new RetainedViewMemory>( + MAX_RETAINED_SESSION_VIEWS, +); diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx new file mode 100644 index 0000000000..94f8acfc05 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { retainedTimelineProjections, useConversationTimeline } from './useConversationTimeline'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const volatile = vi.hoisted(() => ({})); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('./useHostQueueTimeline', () => ({ useHostQueueTimeline: (items: VirtualItem[]) => items })); +vi.mock('../components/modern/FlowChatContext', () => ({ useFlowChatVolatileContext: () => volatile })); + +function transcript(id: string): VirtualItem[] { + return Array.from({ length: 6 }, (_, index) => ({ + type: 'model-round', turnId: `${id}-${index}`, isLastRound: true, isTurnComplete: true, + data: { id: `${id}-round-${index}`, index: 0, isStreaming: false, items: [ + { id: `${id}-text-${index}`, type: 'text', content: `${id} answer ${index}`, isMarkdown: true, isStreaming: false, timestamp: index }, + ] }, + }) as unknown as VirtualItem); +} + +let root: Root, host: HTMLDivElement; +const projected = new Map(); +function Transcript({ sessionId, sources }: { sessionId: string; sources: VirtualItem[] }) { + projected.set(sessionId, useConversationTimeline(sources, sessionId, 'main').items); + return null; +} +const sources = new Map(); +// The list keys its subtree by session, so every switch is a remount. +const open = (id: string) => { + if (!sources.has(id)) sources.set(id, transcript(id)); + act(() => root.render()); + return projected.get(id)!; +}; + +beforeEach(() => { + retainedTimelineProjections.clear(); + sources.clear(); projected.clear(); + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(() => { act(() => root.unmount()); host.remove(); }); + +it('reopens a recently left session with the rows its projection already built', () => { + const first = open('a'); + open('b'); + expect(retainedTimelineProjections.size).toBe(1); + const reopened = open('a'); + // Claimed while shown, so only the session just left is retained. + expect(retainedTimelineProjections.size).toBe(1); + expect(reopened).toHaveLength(first.length); + reopened.forEach((item, index) => expect(item).toBe(first[index])); +}); + +it('builds a fresh projection for a session left too long ago', () => { + const first = open('a'); + for (const id of ['b', 'c', 'd', 'e']) open(id); + expect(retainedTimelineProjections.size).toBe(3); + const reopened = open('a'); + expect(reopened).toHaveLength(first.length); + expect(reopened[0]).not.toBe(first[0]); + expect(reopened.map(item => item.turnId)).toEqual(first.map(item => item.turnId)); +}); diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts index 8125fb1a53..4fffbc3d93 100644 --- a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { prewarmMarkdownParse } from '@/infrastructure/markdown/markdownParseCache'; import { markdownSections } from '@/infrastructure/markdown/markdownSections'; import { useI18n } from '@/infrastructure/i18n'; @@ -7,9 +7,19 @@ import { flowGroupToolLabels } from '../grouping/browse'; import { useFlowChatVolatileContext } from '../components/modern/FlowChatContext'; import type { VirtualItem } from '../types/flow-chat-projection'; import { ConversationDocumentProjection } from './document'; -import { useFlowChatReaderScope } from './readerState'; +import { useFlowChatReaderScope, type FlowChatReaderState } from './readerState'; +import { MAX_RETAINED_SESSION_VIEWS, RetainedViewMemory } from './retainedViewMemory'; import { useHostQueueTimeline } from './useHostQueueTimeline'; +/** + * Projections of recently left views. A reopened session resumes its rows and + * view-local key aliases instead of rebuilding them, which is also what lets + * its retained row measurements match the keys it renders. + */ +export const retainedTimelineProjections = new RetainedViewMemory( + MAX_RETAINED_SESSION_VIEWS, +); + export function useConversationTimeline(sources: VirtualItem[], sessionId: string | undefined, viewport: string) { const { t } = useI18n('flow-chat'); const transcriptSources = useHostQueueTimeline(sources, sessionId); @@ -19,9 +29,17 @@ export function useConversationTimeline(sources: VirtualItem[], sessionId: strin const { groupStates, exploreGroupStates, expandedToolCapsules, pendingPermissionToolCallIds } = useFlowChatVolatileContext(); const projectionRef = useRef<{ reader: typeof reader; value: ConversationDocumentProjection } | null>(null); if (projectionRef.current?.reader !== reader) { - projectionRef.current = { reader, value: new ConversationDocumentProjection() }; + projectionRef.current = { + reader, + value: retainedTimelineProjections.peek(reader) ?? new ConversationDocumentProjection(), + }; } const projection = projectionRef.current.value; + // Claim while mounted; hand back on unmount or when this view changes scope. + useLayoutEffect(() => { + retainedTimelineProjections.take(reader); + return () => retainedTimelineProjections.put(reader, projection); + }, [reader, projection]); const labels = useMemo(() => flowGroupToolLabels(t), [t]); // Every streamed flush produces a new sources array; skip texts already // queued or parsed instead of re-walking the whole timeline each time. From 95a17ba7da93c1544fc14625d3df8bef01c56d58 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 11:19:24 +0800 Subject: [PATCH 2/7] test(flow-chat): treat retained row sizes as measurements A row restored from a recently left session was measured by an earlier mount. Cover that it costs no correction, is handed back on unmount, and that its next resize while partly visible does not shift the viewport, while a row's real first measurement above the reader still does. --- .../modern/FLOWCHAT_VERIFICATION.md | 2 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 8 +- ...lowChatVirtualizer.retained-sizes.test.tsx | 116 ++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 6b2c4477fa..4d1ace5fd1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -63,7 +63,7 @@ each missing what the other had. | `VirtualMessageList.session-boundary.test.tsx` | prepend compensation, consecutive paging with/without queued input, passive scroll suppression, navigation-target current Turn with gesture/follow/session handoff, search placement only outside the readable viewport, and real viewport-register acknowledgement before trajectory details/highlights commit for user/search/model/folded-tool/history targets | | `VirtualMessageList.resize.test.tsx` | first-frame node and screen-position retention during repeated CSS-only width previews, release, fractional row geometry, native clamping, both row/container observer orders and delayed scroll delivery; same-size viewport recovery observes suspended mounts without further output, manual re-render or scrolling in live/history layouts; completed follow and reader-owned positions on local/simulated peer surfaces, not native or live-transport evidence | | `VirtualMessageList.history-prepend.test.tsx` | full list with real virtualization/follow/ownership: stable row identity and screen offset through short/long completed answers, consecutive/dense prepends, delayed measurement/native scroll and reader-chosen positions on local/simulated peer surfaces; supplied row/DOM geometry, not native or live-transport evidence | -| `VirtualMessageList.session-switch.test.tsx`, `useConversationTimeline.retention.test.tsx` | reopening one of the three most recently left sessions reuses its projection rows and measured row sizes: zero size corrections, fewer commits and row mounts, no dropped rows in the native-history layout, the same settled placement, eviction of older sessions, and a single correction for a row that changed while hidden; supplied DOM geometry on the local surface, not native switching time, paint or remote evidence | +| `VirtualMessageList.session-switch.test.tsx`, `useFlowChatVirtualizer.retained-sizes.test.tsx`, `useConversationTimeline.retention.test.tsx` | reopening one of the three most recently left sessions reuses its projection rows and measured row sizes: zero size corrections, fewer commits and row mounts, no dropped rows in the native-history layout, the same settled placement, eviction of older sessions, a single correction for a row that changed while hidden, and no viewport shift when a partly visible retained row grows; supplied DOM geometry on the local surface, not native switching time, paint or remote evidence | | `VirtualMessageList.timeline-prepend.test.tsx`, `FlowChatPrependSnapshot.test.tsx` | production timeline whole/compound/single-Turn pages; native bottom-origin continuity before observer delivery and zero passive scroll writes, stable hydration lifetime, fractional measurements, bounded long histories, empty-projection navigation, chronological navigation, legacy whole-text and section bookmark coordinates, live handoff and reader-selected block retention; settled host ID replacement retains measured row nodes/positions on every frame and uses canonical bookmarks across reopen; supplied DOM geometry on local/simulated Peer, not native paint or live transport evidence | | `flowChatScrollbarMask.test.ts`, `VirtualMessageList.session-boundary.test.tsx` | idle gutter masking with symmetric, one-sided and overlay scrollbars; overflow restoration and content/viewport resizing without scroll writes; supplied DOM geometry, not native rendering or live remote evidence | | `VirtualMessageList.edgeFade.test.ts`, `VirtualMessageList.session-boundary.test.tsx` | repeated scroll-away/back restores the opaque top edge even when an earlier native listener synchronously commits a virtual window; fractional/top-overscroll offsets, one follow notification per event, current gesture callbacks and old-session cleanup; DOM/CSS contracts, not native repaint or live remote evidence | diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index e799235635..0a9edc4762 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -132,7 +132,10 @@ epoch, viewport and session, so device sources never share an entry. A mounted view claims its entry and returns it on unmount; only the three most recently left views are held. A retained size is a reservation like any other: a mounted row is still measured and still replaces it, and a width or font change -leaves it in place under the same rule as a live reservation. The snapshot +leaves it in place under the same rule as a live reservation. It is also a +measurement: the next resize of a retained row is judged as a resize of content +the reader may be looking at, never as a first measurement, so a partly visible +retained row that grows does not shift the viewport. The snapshot reads no geometry, so the unmount-phase DOM cannot skew it. A retained projection holds its rows strongly, so up to three inactive views keep their derived rows until they are reopened or evicted. @@ -143,7 +146,8 @@ live reopen makes 0 corrections and 5 mounts at an unchanged 6 commits; it still drops 2 rows, because the one-time tail seed starts at the last item's start before follow takes the end. A row that changed while the session was hidden costs one correction. -`VirtualMessageList.session-switch.test.tsx` and +`VirtualMessageList.session-switch.test.tsx`, +`useFlowChatVirtualizer.retained-sizes.test.tsx` and `useConversationTimeline.retention.test.tsx` cover this with supplied geometry on the local surface. Native switching time, paint and remote surfaces are not measured by them. diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx new file mode 100644 index 0000000000..040624efd7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +/** + * Sizes a transcript retained from an earlier mount, seeded on remount. + * + * A retained size is a measurement, not an estimate: rows that still have it + * cost no correction, and the next resize of one is judged as a resize of + * content the reader may be looking at, not as a first measurement. + */ + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFlowChatVirtualizer, type FlowChatVirtualizer } from './useFlowChatVirtualizer'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const ESTIMATE_PX = 100; + +interface Item { key: string } + +function Harness({ scroller, header, items, retainedItemSizes, shiftViewport, onApi }: { + scroller: HTMLElement; + header: HTMLElement; + items: Item[]; + retainedItemSizes?: ReadonlyMap; + shiftViewport: (byPx: number) => boolean; + onApi: (api: FlowChatVirtualizer) => void; +}) { + const scrollerRef = React.useRef(scroller); + const headerRef = React.useRef(header); + onApi(useFlowChatVirtualizer({ + items, + scrollerRef, + headerRef, + getItemKey: (item: Item) => item.key, + estimateItemHeightPx: () => ESTIMATE_PX, + scrollPaddingStartPx: 0, + writeViewport: () => true, + shiftViewport, + retainedItemSizes, + })); + return null; +} + +describe('useFlowChatVirtualizer retained item sizes', () => { + let container: HTMLDivElement, root: Root, scroller: HTMLDivElement, header: HTMLDivElement; + let api: FlowChatVirtualizer; + const heights = new Map(); + const items = [{ key: 'a' }, { key: 'b' }, { key: 'c' }]; + + function renderRow(index: number, heightPx: number) { + heights.set(index, heightPx); + let element = scroller.querySelector(`[data-virtual-index="${index}"]`); + if (!element) { + element = document.createElement('div'); + element.setAttribute('data-virtual-index', String(index)); + element.getBoundingClientRect = () => ({ top: 0, bottom: heights.get(index)!, height: heights.get(index)!, width: 500 }) as DOMRect; + scroller.appendChild(element); + } + } + const mount = (retainedItemSizes: ReadonlyMap | undefined, shiftViewport: (byPx: number) => boolean) => act(() => root.render( + { api = next; }} />, + )); + + beforeEach(() => { + heights.clear(); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + scroller = document.createElement('div'); + header = document.createElement('div'); + container.appendChild(scroller); + // The reader is 200px into the first row: it starts above the viewport. + Object.defineProperty(scroller, 'scrollTop', { configurable: true, get: () => 200, set() {} }); + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 }); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('reserves retained sizes in place of estimates and hands them back', () => { + mount(new Map([['a', 300], ['b', 40]]), () => true); + expect(api.getItemBounds(1)).toEqual({ startPx: 300, endPx: 340 }); + expect(api.getItemBounds(2)).toEqual({ startPx: 340, endPx: 340 + ESTIMATE_PX }); + // Unmeasured c is an estimate, not something to hand to the next mount. + expect([...api.snapshotItemSizes()]).toEqual([['b', 40], ['a', 300]]); + }); + + it('judges a retained row that resizes while partly visible as a measured row', () => { + const shiftViewport = vi.fn(() => true); + mount(new Map([['a', 300], ['b', 300], ['c', 300]]), shiftViewport); + renderRow(0, 300); + act(() => api.measureRenderedItems()); + // Still its retained size: nothing to correct. + expect(shiftViewport).not.toHaveBeenCalled(); + renderRow(0, 500); + act(() => api.measureRenderedItems()); + // The reader is looking at its grown content; the viewport stays put. + expect(shiftViewport).not.toHaveBeenCalled(); + expect(api.getItemBounds(1)).toEqual({ startPx: 500, endPx: 800 }); + }); + + it('still owes a first measurement for a row that starts above the reader', () => { + const shiftViewport = vi.fn(() => true); + mount(undefined, shiftViewport); + renderRow(0, 300); + act(() => api.measureRenderedItems()); + expect(shiftViewport).toHaveBeenCalledWith(300 - ESTIMATE_PX); + }); +}); From 5a88e3bdacae079879bab7cd138083e965fe2d73 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 11:29:55 +0800 Subject: [PATCH 3/7] perf(flow-chat): smooth session switching, scrolling and running tool groups Session switching with several streaming sessions: - Bind the viewport ResizeObserver to the scroller lifetime and keep the Turn-boundary list stable, so a streamed flush no longer rebuilds the observer and re-measures every mounted row. - Keep streaming Markdown section boundaries in a separate small cache so intermediate prefixes do not evict settled replies' sections. - Skip unchanged sources in the timeline prewarm walk. - Clean only a bounded tail for the agent companion snippet. Scroll jitter: - A row's first measurement replaces an estimate, so a row that starts above the viewport owes its whole delta instead of being judged by its estimated end. Running tool groups: - Automatic disclosure of a running group lists only in-flight members; settled calls fold into the header summary. Explicit expansion, held groups, expanded capsules and failed file revisions still list all. --- .../flow_chat/components/FlowTextBlock.tsx | 4 +- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 4 +- .../modern/FLOWCHAT_VERIFICATION.md | 4 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 31 ++++++++- .../components/modern/FlowGroupRenderer.tsx | 4 ++ .../modern/VirtualItemRenderer.scss | 2 +- .../components/modern/VirtualItemRenderer.tsx | 3 +- .../modern/VirtualMessageList.resize.test.tsx | 65 +++++++++++++++++++ .../components/modern/VirtualMessageList.tsx | 54 ++++++++++----- .../modern/useFlowChatVirtualizer.test.ts | 12 ++++ .../modern/useFlowChatVirtualizer.ts | 19 +++++- .../src/flow_chat/grouping/lifecycle.ts | 21 ++++++ .../src/flow_chat/timeline/document.test.ts | 58 +++++++++++++++++ src/web-ui/src/flow_chat/timeline/document.ts | 55 +++++++++++----- .../timeline/useConversationTimeline.ts | 6 ++ .../flow_chat/types/flow-chat-projection.ts | 2 + .../utils/agentCompanionActivity.test.ts | 36 ++++++++++ .../flow_chat/utils/agentCompanionActivity.ts | 33 +++++++++- .../markdown/markdownSections.test.ts | 13 ++++ .../markdown/markdownSections.ts | 14 +++- 20 files changed, 395 insertions(+), 45 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index 7eba231d54..e0a0c6a59f 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -170,8 +170,8 @@ export const FlowTextBlock = React.memo(({ // A live message stays one selection/reveal owner. Only its changing suffix // enters ReactMarkdown; sealed semantic prefixes keep memoized renderers. const sections = useMemo(() => markdownSection || parsedContent.hasProtocol || !textItem.isMarkdown - ? [{ start: 0, end: displayContent.length }] : markdownSections(displayContent), - [displayContent, markdownSection, parsedContent.hasProtocol, textItem.isMarkdown]); + ? [{ start: 0, end: displayContent.length }] : markdownSections(displayContent, { streaming: isVisuallyStreaming }), + [displayContent, isVisuallyStreaming, markdownSection, parsedContent.hasProtocol, textItem.isMarkdown]); const renderMarkdown = (markdownContent: string, key?: React.Key, streaming = markdownStreaming, continuation = false) => ( = React.memo(({ expandedThinkingItemIds, placement, timelineExpanded, + timelineLiveMembers, }) => { const { t, formatNumber } = useI18n('flow-chat'); const category = getFlowGroupCategory(data); @@ -183,6 +186,7 @@ export const FlowGroupRenderer: React.FC = React.memo(({ || (item.type === 'tool' && (item as FlowToolItem).toolResult?.success === false)) ? 'immediate' : undefined, streaming: isGroupStreaming, itemCount: toolItemCount, 'data-flow-group-phase': data.phase, + 'data-flow-group-live-members': timelineLiveMembers ? 'true' : undefined, ...summaryPresentation, ...policy.attributes(allItems), onExpandedChange: handleExpandedChange, // The transcript owns scrolling; individual completions do not close or pulse the group. receiveFeedback: data.phase === undefined ? groupReceiveFeedback?.get(groupId) : undefined, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index 442173fabe..a1f95b48c7 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -112,7 +112,7 @@ // Collection geometry is expressed in one transcript, including when only its // middle is resident. The public header and card anatomy stay unchanged. -.virtual-item-wrapper[data-timeline-kind='group-header'] > .explore-region--expanded { +.virtual-item-wrapper[data-timeline-kind='group-header'] > :is(.explore-region--expanded, [data-flow-group-live-members='true']) { margin-block-end: 0; } .flowchat-timeline-members.explore-region--collapsible { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index 1080aad64a..265ecb2431 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -91,7 +91,8 @@ export const VirtualItemRenderer = React.memo( case 'model-round': if (item.timeline?.kind === 'group-header') return ; + turnId={item.turnId} placement="standalone" timelineExpanded={item.timeline.expanded} + timelineLiveMembers={item.timeline.liveMembers} />; if (item.timeline?.kind === 'group-members' || item.timeline?.kind === 'content') return ; return ( observer.targets.has(row))).toBe(true); }); + it.each(['local', 'peer-scroll'].flatMap(surface => [0, 250].map(delayMs => ({ surface, delayMs }))))( + 'keeps visible rows still while wheeling into rows never measured on $surface (scroll delay=$delayMs ms)', ({ surface, delayMs }) => { + activateSurface(surface); + state.processing = true; + const rows = [{ type: 'user-message', turnId: 'latest', data: { id: 'user-latest', content: 'latest' } }] as VirtualItem[]; + heights.set(getVirtualItemStableKey(rows[0]), 44); + for (let index = 0; index < 90; index += 1) { + const row = { type: 'model-round', turnId: 'latest', data: { id: `tool-${index}`, items: [], content: `tool ${index}` }, isLastRound: index === 89, isTurnComplete: true } as VirtualItem; + rows.push(row); + // Estimates miss in both directions: short tool rows and tall output. + heights.set(getVirtualItemStableKey(row), index % 7 === 3 ? 640.5 : index % 3 === 0 ? 34 : 96.25); + } + state.items = [...turn('older', 400), ...rows]; + render(); + for (let frame = 0; frame < 60; frame++) { resize(); tick(); } + state.processing = false; + render(); + for (let frame = 0; frame < 30; frame++) { resize(); tick(); } + const scroller = host.querySelector('[data-flowchat-scroller]')!; + const visibleRows = () => new Map([...host.querySelectorAll('.virtual-item-wrapper[data-virtual-item-key]')] + .map(row => [row.dataset.virtualItemKey!, row.getBoundingClientRect().top - scroller.getBoundingClientRect().top] as const) + .filter(([, top]) => top > -400 && top < viewportHeight)); + const unexplained: string[] = []; + for (const delta of [...Array(40).fill(-260), ...Array(40).fill(260)]) { + const before = visibleRows(); + act(() => { scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: delta, bubbles: true })); }); + // A busy main thread delivers the scroll after the gesture window closed. + now += delayMs; + act(() => { + scroller.scrollTop += delta; + scroller.dispatchEvent(new Event('scroll')); + }); + resize(); tick(); + for (const [key, top] of visibleRows()) { + const previous = before.get(key); + if (previous !== undefined && Math.abs(top - previous + delta) > 1) unexplained.push(`${key}:${(top - previous + delta).toFixed(1)}`); + } + } + expect(unexplained).toEqual([]); + }); + + it.each(['local', 'peer-stream'])('keeps one viewport observer while output streams on %s', surface => { + activateSurface(surface); + state.processing = true; + const [user, answer] = turn('latest', 180); + state.items = [user, answer]; + render(); + for (let frame = 0; frame < 60; frame++) { resize(); tick(); } + const scroller = host.querySelector('[data-flowchat-scroller]')!; + const viewportObservers = observers.filter(observer => observer.targets.has(scroller)); + expect(viewportObservers.length).toBeGreaterThan(0); + const created = observers.length; + // Each flush replaces the live round and the item array; the Turn + // boundary (its user message) is unchanged. + for (let flush = 0; flush < 12; flush += 1) { + const streamed = { ...answer, data: { ...(answer as { data: object }).data, content: `latest ${flush}` } } as VirtualItem; + state.items = [user, streamed]; + render(); + tick(); + } + // A rebound observer is a new instance and re-measures every mounted row. + expect(observers.filter(observer => observer.targets.has(scroller))).toEqual(viewportObservers); + expect(observers.length).toBe(created); + }); + it.each(['local', 'peer-resize'].flatMap(surface => [false, true].map(first => ({ surface, first }))))( 'keeps a loaded transcript fixed on $surface, container observer first=$first', ({ surface, first }) => { containerFirst = first; 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 eb335477a3..1f19e3d289 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -602,9 +602,22 @@ const VirtualMessageListSession = forwardRef measurementAnchorKeyRef.current(), }); - const userMessageItems = useMemo(() => virtualItems - .map((item, index) => ({ item, index })) - .filter(({ item }) => item.type === 'user-message' || item.type === 'background-run'), [virtualItems]); + // Streaming replaces `virtualItems` every flush while its Turn boundaries + // stay the same. Keep the previous array then, so callbacks and observers + // derived from it are not rebuilt for each streamed chunk. + const userMessageItemsRef = useRef>([]); + const userMessageItems = useMemo(() => { + const next = virtualItems + .map((item, index) => ({ item, index })) + .filter(({ item }) => item.type === 'user-message' || item.type === 'background-run'); + const previous = userMessageItemsRef.current; + if (previous.length === next.length + && next.every((entry, position) => entry.item === previous[position].item && entry.index === previous[position].index)) { + return previous; + } + userMessageItemsRef.current = next; + return next; + }, [virtualItems]); const isStreamingOutput = useMemo(() => { if (viewportMode === 'history-reading') return false; @@ -1729,9 +1742,30 @@ const VirtualMessageListSession = forwardRef { + resizeCallbacksRef.current = { + resumeSuspendedViewport, scheduleFollowToLatest, scheduleViewportSnapshot, + scheduleVisibleTurnInfoUpdate, updateIsAtScrollStart, updateIsAtBottom, viewportAnchor, + }; + }); + useEffect(() => { if (!scrollerElement) return; + const resumeFromFrame = () => resizeCallbacksRef.current.resumeSuspendedViewport(); const observer = new ResizeObserver(() => { + const { + scheduleFollowToLatest, scheduleViewportSnapshot, scheduleVisibleTurnInfoUpdate, + updateIsAtScrollStart, updateIsAtBottom, viewportAnchor, + } = resizeCallbacksRef.current; const nextViewportBox = { width: scrollerElement.clientWidth, height: scrollerElement.clientHeight, @@ -1760,7 +1794,7 @@ const VirtualMessageListSession = forwardRef observer.disconnect(); - }, [ - resumeSuspendedViewport, - scheduleFollowToLatest, - scheduleViewportSnapshot, - scheduleVisibleTurnInfoUpdate, - scrollerElement, - updateIsAtScrollStart, - updateIsAtBottom, - viewportAnchor, - viewportOwner, - ]); + }, [scrollerElement]); /* * One re-measure for a pane drag, against the width the drag ended on. diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.test.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.test.ts index 9e3a1f51e8..d02873b710 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { isItemFullyAboveViewport, + isItemStartAboveViewport, virtualWindowPaddingPx, visibleRowRange, timelineOverscanRange, @@ -28,6 +29,17 @@ describe('isItemFullyAboveViewport', () => { }); }); +describe('isItemStartAboveViewport', () => { + it('owes a first measurement for a reservation that starts above the reader', () => { + expect(isItemStartAboveViewport(5300, 5450)).toBe(true); + }); + + it('leaves a reservation that starts at or below the viewport top', () => { + expect(isItemStartAboveViewport(5450, 5450)).toBe(false); + expect(isItemStartAboveViewport(5600, 5450)).toBe(false); + }); +}); + /** Header above the items, which every offset below is measured past. */ const CONTENT_START = 24; 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 a2a6e3268b..061f19b77a 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -182,6 +182,10 @@ export function isItemFullyAboveViewport(itemEndPx: number, scrollTopPx: number) return itemEndPx <= scrollTopPx; } +export function isItemStartAboveViewport(itemStartPx: number, scrollTopPx: number): boolean { + return itemStartPx < scrollTopPx; +} + /** Normal-flow padding must replace the exact occupied box, including fractions. */ function measureRowHeight(element: HTMLElement, entry?: ResizeObserverEntry): number { return entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height; @@ -552,6 +556,8 @@ export function useFlowChatVirtualizer({ * that matters — an item added or removed — moves `count` as well. */ const reservedEstimatesRef = useRef(new Map()); + /** Keys whose size the virtualizer has measured at least once. */ + const measuredKeysRef = useRef(new Set()); const estimateSize = useCallback((index: number) => { const item = itemsRef.current[index]; if (item === undefined) return 0; @@ -702,6 +708,8 @@ export function useFlowChatVirtualizer({ // An instance field rather than an option, so it is assigned here — before // any measurement callback can reach `resizeItem`. virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta) => { + const firstMeasurement = !measuredKeysRef.current.has(item.key); + measuredKeysRef.current.add(item.key); if (resettingMeasurementsRef.current) return false; pendingMeasurementRef.current = true; const scroller = scrollerRef.current; @@ -726,8 +734,16 @@ export function useFlowChatVirtualizer({ // Estimated bounds are not proof that an older row is now visible. During // a prepend a predecessor can grow through the old viewport coordinates; // its identity still precedes the reading row and its whole delta is owed. + // A row's first measurement replaces a reservation, not content the reader + // saw: before this commit its space was an estimate. If it starts above the + // viewport, everything visible follows it and the whole delta is owed — + // judging it by its estimated end let a row that mounted while scrolling up + // push or pull the transcript by its estimate error. A measured row that + // resizes keeps the visible-row rule above. const fullyAboveViewport = anchorIndex === undefined - ? isItemFullyAboveViewport(item.end, beforeScrollTopPx) + ? firstMeasurement + ? isItemStartAboveViewport(item.start, beforeScrollTopPx) + : isItemFullyAboveViewport(item.end, beforeScrollTopPx) : item.index < anchorIndex; const applied = fullyAboveViewport ? shiftViewport(delta) : false; if (applied) pendingMeasurementShiftRef.current = true; @@ -929,6 +945,7 @@ export function useFlowChatVirtualizer({ for (const key of previousKeysRef.current) if (!keys.has(key)) { retiredMeasurementsRef.current++; reservedEstimatesRef.current.delete(key); + measuredKeysRef.current.delete(key); } previousKeysRef.current = keys; if (measuredLayoutRef.current === layoutSignature && retiredMeasurementsRef.current < 2048 diff --git a/src/web-ui/src/flow_chat/grouping/lifecycle.ts b/src/web-ui/src/flow_chat/grouping/lifecycle.ts index 970c1a68f4..5d863cc53f 100644 --- a/src/web-ui/src/flow_chat/grouping/lifecycle.ts +++ b/src/web-ui/src/flow_chat/grouping/lifecycle.ts @@ -10,6 +10,27 @@ export function isFlowGroupMemberActive(item: FlowItem): boolean { || ((item.type === 'thinking' || item.type === 'text') && (item as FlowTextItem).isStreaming); } +/** + * Members a running group shows while its disclosure is automatic. + * + * Settled members fold into the header summary: a serial run shows the call + * in flight, a parallel batch shows only the calls still running, and a call + * waiting on approval always stays visible. Between two serial calls the most + * recent call stays, so the group does not empty and refill for each step. + */ +export function liveFlowGroupMembers( + items: readonly FlowItem[], + pendingPermissionToolCallIds?: ReadonlySet, +): FlowItem[] { + const live = items.filter(item => isFlowGroupMemberActive(item) + || (item.type === 'tool' && pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id) === true)); + if (live.length > 0) return live; + for (let index = items.length - 1; index >= 0; index -= 1) { + if (items[index].type === 'tool') return [items[index]]; + } + return []; +} + const lifecycleCache = new WeakMap; sealedFalse?: Pick; diff --git a/src/web-ui/src/flow_chat/timeline/document.test.ts b/src/web-ui/src/flow_chat/timeline/document.test.ts index 7169271041..491920a73c 100644 --- a/src/web-ui/src/flow_chat/timeline/document.test.ts +++ b/src/web-ui/src/flow_chat/timeline/document.test.ts @@ -5,6 +5,7 @@ import type { FlowItem, FlowToolItem } from '../types/flow-chat'; import type { VirtualItem } from '../types/flow-chat-projection'; import { estimateVirtualItemHeight } from '../components/modern/virtualItemHeightEstimators'; import { buildInlineToolGroupData } from '../grouping/roundGroups'; +import { projectAdjacentFlowGroups } from '../grouping/groupProjection'; import { toolCapsuleStateKey } from '../tool-cards/toolCapsuleModel'; import { localizeMarkdownMatch } from './markdownSectionSearch'; import { getVirtualItemBookmarkKey } from '../components/modern/virtualItemIdentity'; @@ -282,6 +283,63 @@ describe('desktop conversation document', () => { .not.toBe(first.find(item => item.timeline?.memberIds[0] === 'b')); }); + describe('running group presentation', () => { + // Group lifecycle comes from the same projection the transcript uses. + const live = (items: FlowItem[]) => projectAdjacentFlowGroups([{ + type: 'model-round', turnId: 'turn', isLastRound: true, isTurnComplete: false, + data: { id: 'round', index: 0, startTime: 1, status: 'streaming', isStreaming: true, isComplete: false, items }, + } as VirtualItem], { isTurnComplete: false })[0]; + const shown = (rows: VirtualItem[]) => rows.filter(row => row.timeline?.kind === 'group-members') + .flatMap(row => row.timeline!.memberIds); + const header = (rows: VirtualItem[]) => rows.find(row => row.timeline?.kind === 'group-header')!.timeline!; + + it('shows only the call in flight for a serial run', () => { + const rows = new ConversationDocumentProjection().project([live([tool('a'), tool('b'), tool('c', 'running')])], options()); + expect(header(rows)).toMatchObject({ expanded: false, liveMembers: true }); + expect(header(rows).group!.phase).toBe('collecting'); + expect(shown(rows)).toEqual(['c']); + }); + + it('shows only the calls still running in a parallel batch', () => { + const rows = new ConversationDocumentProjection().project( + [live([tool('a'), tool('b', 'running'), tool('c'), tool('d', 'running')])], options()); + expect(shown(rows)).toEqual(['b', 'd']); + }); + + it('keeps the latest call between serial steps instead of emptying the group', () => { + const rows = new ConversationDocumentProjection().project([live([tool('a'), tool('b')])], options()); + expect(header(rows).liveMembers).toBe(true); + expect(shown(rows)).toEqual(['b']); + }); + + it('lists every member once the reader expands the running group', () => { + const projection = new ConversationDocumentProjection(); + const input = live([tool('a'), tool('b'), tool('c', 'running')]); + const id = header(projection.project([input], options())).group!.groupId; + const rows = projection.project([input], { ...options(), groupStates: new Map([[id, true]]) }); + expect(header(rows)).toMatchObject({ expanded: true }); + expect(header(rows).liveMembers).toBeUndefined(); + expect(shown(rows)).toEqual(['a', 'b', 'c']); + }); + + it('keeps an approval answerable inside a folded group without the settled calls', () => { + const projection = new ConversationDocumentProjection(); + const input = live([tool('a'), tool('b'), tool('c')]); + const id = header(projection.project([input], options())).group!.groupId; + const rows = projection.project([input], { + ...options(), groupStates: new Map([[id, false]]), pendingPermissionToolCallIds: new Set(['b']), + }); + expect(shown(rows)).toEqual(['b']); + }); + + it('folds a settled group to its header', () => { + const rows = new ConversationDocumentProjection().project([source([tool('a'), tool('b'), tool('c')])], options()); + expect(header(rows)).toMatchObject({ expanded: false }); + expect(header(rows).liveMembers).toBeUndefined(); + expect(shown(rows)).toEqual([]); + }); + }); + it('honors explicit folding without destroying child choices and always exposes permission requests', () => { const projection = new ConversationDocumentProjection(); const opts = options(); diff --git a/src/web-ui/src/flow_chat/timeline/document.ts b/src/web-ui/src/flow_chat/timeline/document.ts index 6ae9f5a96b..1f54dcf905 100644 --- a/src/web-ui/src/flow_chat/timeline/document.ts +++ b/src/web-ui/src/flow_chat/timeline/document.ts @@ -4,6 +4,7 @@ import type { VirtualItem, TimelineBlock } from '../types/flow-chat-projection'; import { buildInlineToolGroupData, getProjectedModelRoundGroups, hasModelRoundLeadingControls } from '../grouping/roundGroups'; import { getFlowGroupDisclosureChoice, isFlowGroupExpanded, type FlowGroupData } from '../grouping/types'; import { filterFlowGroupItems, type FlowGroupStatusFilter } from '../grouping/browse'; +import { liveFlowGroupMembers } from '../grouping/lifecycle'; import { isFlowItemVisible } from '../utils/flowItemVisibility'; import type { FlowChatReaderState } from './readerState'; import { getVirtualItemStableKey } from '../components/modern/virtualItemIdentity'; @@ -67,18 +68,32 @@ function capsuleExpansionIndex(options: TimelineProjectionOptions): ExpandedCaps return index; } -function readerExpanded( +/** + * `all` lists every member, `live` only the ones still running, `none` only + * the header. Automatic disclosure of a running group is `live`: a long serial + * run no longer spreads every finished call through the transcript. A reader's + * explicit choice, a held group, an expanded member capsule and a failed file + * revision keep the full list. + */ +type GroupMemberPresentation = 'none' | 'live' | 'all'; + +function readerMemberPresentation( group: FlowGroupData, turnId: string, options: TimelineProjectionOptions, expandedCapsules?: ExpandedCapsuleIndex, -): boolean { +): GroupMemberPresentation { const choice = getFlowGroupDisclosureChoice(group, options.groupStates); + if (choice === true) return 'all'; const permission = group.allItems.some(item => item.type === 'tool' && (item.status === 'pending_confirmation' || options.pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id))); - if (permission) return true; - if (choice !== undefined) return choice; + // An approval stays answerable even inside a group the reader folded. + if (permission) return 'live'; + if (choice === false) return 'none'; const expandedMembers = expandedCapsules?.get(turnId); - return isFlowGroupExpanded(group, options.groupStates) - || options.reader.isGroupHeld(group.groupId) - || (expandedMembers !== undefined && group.allItems.some(item => expandedMembers.has(item.id))); + if (options.reader.isGroupHeld(group.groupId) + || (expandedMembers !== undefined && group.allItems.some(item => expandedMembers.has(item.id)))) return 'all'; + if (!isFlowGroupExpanded(group, options.groupStates)) return 'none'; + const running = group.phase === 'collecting' || group.phase === 'settling' + || (group.phase === undefined && group.isGroupStreaming); + return running && !(group.category === 'file-edit' && group.needsAttention === true) ? 'live' : 'all'; } type RoundItem = Extract; @@ -192,6 +207,7 @@ export class ConversationDocumentProjection { && previous.timeline.memberOrdinal === extra.memberOrdinal : previous.timeline.group === extra.group) && previous.timeline.expanded === extra.expanded + && previous.timeline.liveMembers === extra.liveMembers && previous.timeline.layout === layout && previous.timeline.markdownSection?.start === extra.markdownSection?.start && previous.timeline.markdownSection?.end === extra.markdownSection?.end @@ -233,7 +249,7 @@ export class ConversationDocumentProjection { // Even one short section keeps its renderer and bookmark coordinate. const sections = text && text.isMarkdown && options.reader.get('navigation:whole-text', '') !== identity - && !parseDeepResearchContent(text.content).hasProtocol ? markdownSections(text.content) : undefined; + && !parseDeepResearchContent(text.content).hasProtocol ? markdownSections(text.content, { streaming: text.isStreaming }) : undefined; if (!sections) { add(kind, this.itemIdentity.key(run[0]), run, extra); return; } const displayOffsets = this.itemIdentity.sectionOffsets(text!, sections); sections.forEach((section, index) => add(kind, index === 0 ? this.itemIdentity.key(run[0]) : `${this.itemIdentity.key(text!)}:markdown:${displayOffsets[index]}`, index === 0 ? run : [text!], { @@ -250,21 +266,30 @@ export class ConversationDocumentProjection { if (group.type === 'critical') { if (isFlowItemVisible(group.item)) critical.push(group.item); return; } flush(); const data = buildInlineToolGroupData(source.data.id, group, source.isLastRound && group.isLast); - const expanded = readerExpanded(data, source.turnId, options, expandedCapsules); - add('group-header', `group:${data.groupId}`, emptyItems, { group: data, expanded }); - if (!expanded) return; + const presentation = readerMemberPresentation(data, source.turnId, options, expandedCapsules); + const expanded = presentation === 'all'; + const liveMembers = presentation === 'live' ? true : undefined; + add('group-header', `group:${data.groupId}`, emptyItems, { group: data, expanded, liveMembers }); + if (presentation === 'none') return; + const all = data.allItems.filter(isFlowItemVisible); + // Member blocks hold only group metadata, never the full source tree. + const memberGroup = { ...data, rounds: [], allItems: [] }; + const ordinals = new Map(all.filter(member => member.type === 'tool').map((member, index) => [member.id, index])); + if (liveMembers) { + const runs = conversationContentRuns(liveFlowGroupMembers(all, options.pendingPermissionToolCallIds)); + runs.forEach((run, index) => addRun('group-members', run, + { group: memberGroup, expanded: true, liveMembers, first: index === 0, last: index === runs.length - 1, + memberOrdinal: ordinals.get(run.find(member => member.type === 'tool')?.id ?? '') })); + return; + } const prefix = `group:${data.groupId}:`; const filter = { query: options.reader.get(`${prefix}query`, ''), tool: options.reader.get(`${prefix}tool`, 'all'), status: options.reader.get(`${prefix}status`, 'all') as FlowGroupStatusFilter, }; - const all = data.allItems.filter(isFlowItemVisible); const browse = filterFlowGroupItems(all, filter, options.toolLabels, options.pendingPermissionToolCallIds); const runs = conversationContentRuns(all.filter(item => browse.visibleIds.has(item.id))); - // Member blocks hold only group metadata, never the full source tree. - const memberGroup = { ...data, rounds: [], allItems: [] }; - const ordinals = new Map(all.filter(member => member.type === 'tool').map((member, index) => [member.id, index])); runs.forEach((run, index) => addRun('group-members', run, { group: memberGroup, expanded, first: index === 0, last: index === runs.length - 1, memberOrdinal: ordinals.get(run.find(member => member.type === 'tool')?.id ?? ''), diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts index 8125fb1a53..541a3b90c5 100644 --- a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts @@ -26,10 +26,16 @@ export function useConversationTimeline(sources: VirtualItem[], sessionId: strin // Every streamed flush produces a new sources array; skip texts already // queued or parsed instead of re-walking the whole timeline each time. const prewarmedTextsRef = useRef>(new WeakSet()); + // A projected source is reused by identity only while its members are the + // same objects, so a source already walked has nothing new to queue. + const prewarmedSourcesRef = useRef>(new WeakSet()); useEffect(() => { const seen = prewarmedTextsRef.current; + const seenSources = prewarmedSourcesRef.current; const texts = new Set(); for (const source of sources) { + if (seenSources.has(source)) continue; + seenSources.add(source); const members = source.type === 'explore-group' ? source.data.allItems : source.type === 'model-round' ? source.projectedGroups?.flatMap(group => group.type === 'critical' ? [group.item] : group.projection?.allItems ?? group.items) ?? source.data.items : []; diff --git a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts index 5633d56f59..7df5052a15 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts @@ -10,6 +10,8 @@ export interface TimelineBlock { memberIds: string[]; group?: FlowGroupData; expanded?: boolean; + /** A running group's automatic disclosure shows only its in-flight members. */ + liveMembers?: boolean; first?: boolean; last?: boolean; memberOrdinal?: number; diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts index d1151f5e53..af4762ac8f 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts @@ -248,6 +248,42 @@ describe('buildAgentCompanionActivity', () => { expect(activity.tasks[0].latestOutput?.endsWith('...')).toBe(false); }); + it('cleans only a bounded tail of a long reply with the same visible result', async () => { + // Oracle: the whole-text cleanup the companion applied before windowing. + const wholeText = (markdown: string) => markdown + .replace(/```[\s\S]*?```/g, match => match.replace(/```[^\n]*\n?|```/g, ' ')) + .replace(/`([^`]*)`/g, '$1') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/^\s{0,3}#{1,6}\s+/gm, '') + .replace(/^\s{0,3}>\s?/gm, '') + .replace(/^\s*[-*+]\s+/gm, '') + .replace(/^\s*\d+\.\s+/gm, '') + .replace(/[*_~]{1,3}/g, '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(-512); + const block = (index: number) => `## Part ${index}\n\nSome **bold** and \`code\` with a [link](https://example.com/${index}).\n\n- item _${index}_\n> quoted\n\n`; + const longFence = '```ts\n' + 'const value = `x` * 2; // **not bold**\n'.repeat(200) + '```\n'; + const documents = [ + Array.from({ length: 300 }, (_, index) => block(index)).join(''), + // The window starts inside a fence that opened long before it. + Array.from({ length: 40 }, (_, index) => block(index)).join('') + longFence + 'Closing words.', + Array.from({ length: 40 }, (_, index) => block(index)).join('') + '```ts\n' + 'still streaming code\n'.repeat(400), + // Mostly link targets: the window cleans below the visible budget. + Array.from({ length: 400 }, (_, index) => `[x](https://example.com/${'p'.repeat(60)}/${index}) `).join(''), + ]; + await putStateMachineInStreaming(); + for (const content of documents) { + flowChatStore.setState(() => ({ + sessions: new Map([['session-1', createStreamingSessionWithText(content)]]), + activeSessionId: 'session-1', + })); + expect(buildAgentCompanionActivity().tasks[0].latestOutput).toBe(wholeText(content)); + } + }); + it('keeps truncated latest output well-formed for desktop pet events', async () => { const content = '\uD83D\uDE00' + 'a'.repeat(511); flowChatStore.setState(() => ({ diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index 174614764c..29b4492015 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -119,6 +119,33 @@ function truncateLatestOutput(text: string): string { return toWellFormedText(wellFormedText.slice(-LATEST_OUTPUT_MAX_CHARS)); } +// Plain-text cleanup only shortens text, and the companion shows at most +// LATEST_OUTPUT_MAX_CHARS of it. Clean a bounded tail instead of the whole +// reply: this runs on every store notify for each running session. +const LATEST_OUTPUT_SOURCE_WINDOW_CHARS = LATEST_OUTPUT_MAX_CHARS * 8; + +function sourceTail(source: string): string { + if (source.length <= LATEST_OUTPUT_SOURCE_WINDOW_CHARS) return source; + let start = source.length - LATEST_OUTPUT_SOURCE_WINDOW_CHARS; + // Start on a line so line-anchored Markdown rules see the same lines. + const lineStart = source.indexOf('\n', start); + if (lineStart !== -1 && lineStart < source.length - 1) start = lineStart + 1; + // Keep an open fence open so fenced text is not read as inline Markdown. + let fences = 0; + for (let index = source.indexOf('```'); index !== -1 && index < start; index = source.indexOf('```', index + 3)) { + fences += 1; + } + return `${fences % 2 === 1 ? '```\n' : ''}${source.slice(start)}`; +} + +function plainTextTail(source: string, convert: (text: string) => string): string { + const tail = sourceTail(source); + const text = convert(tail); + // A window that cleaned down below the visible budget (a long link or image + // run, for example) falls back to the exact whole-text result. + return tail === source || text.length >= LATEST_OUTPUT_MAX_CHARS ? text : convert(source); +} + function latestAssistantSnippet(turn: DialogTurn | undefined): string | undefined { if (!turn) { return undefined; @@ -129,11 +156,11 @@ function latestAssistantSnippet(turn: DialogTurn | undefined): string | undefine for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = round.items[itemIndex]; const plainText = item.type === 'thinking' - ? markdownToPlainText((item as FlowThinkingItem).content) + ? plainTextTail((item as FlowThinkingItem).content, markdownToPlainText) : item.type === 'text' ? (item as FlowTextItem).isMarkdown === false - ? (item as FlowTextItem).content.replace(/\s+/g, ' ').trim() - : markdownToPlainText((item as FlowTextItem).content) + ? plainTextTail((item as FlowTextItem).content, text => text.replace(/\s+/g, ' ').trim()) + : plainTextTail((item as FlowTextItem).content, markdownToPlainText) : ''; if (plainText) { return truncateLatestOutput(plainText); diff --git a/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts b/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts index 5497dbf6a2..bc17b48f1d 100644 --- a/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts +++ b/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts @@ -20,6 +20,19 @@ describe('Markdown presentation sections', () => { expect(clean(slices.flatMap(slice => parser.parse(slice).children))).toEqual(clean(parser.parse(source).children)); }); + it('keeps settled sections cached while a reply streams past many prefixes', () => { + const settled = prefix + '# Settled\n\n' + prefix; + const sections = markdownSections(settled); + let streaming = prefix; + for (let index = 0; index < 80; index += 1) { + streaming += `Streamed paragraph ${index}.\n\n`; + markdownSections(streaming, { streaming: true }); + } + expect(markdownSections(settled)).toBe(sections); + // A streamed source and its settled read share the same boundaries. + expect(markdownSections(streaming)).toEqual(markdownSections(streaming, { streaming: true })); + }); + it.each(['\n', '\r\n', '\r'])('keeps complete semantic blocks and exact source with %j line endings', newline => { const document = (prefix + [ '# Heading\n\nSetext heading\n---\n\n', diff --git a/src/web-ui/src/infrastructure/markdown/markdownSections.ts b/src/web-ui/src/infrastructure/markdown/markdownSections.ts index 2c9aed201d..15b7874ed9 100644 --- a/src/web-ui/src/infrastructure/markdown/markdownSections.ts +++ b/src/web-ui/src/infrastructure/markdown/markdownSections.ts @@ -7,6 +7,10 @@ export interface MarkdownSection { start: number; end: number } // it: splitting a list, fence, table or HTML document would change its meaning. const SECTION_CHARS = 3000; const cache = new BoundedResourceCache(2 * 1024 * 1024, 64); +// A streaming reply has a new source on every flush. Its intermediate results +// are almost never read again, so they stay out of the settled cache instead +// of evicting the sections every other reply needs when its session reopens. +const streamingCache = new BoundedResourceCache(1024 * 1024, 4); const parser = getMarkdown('flowchat-section-boundaries', { enableContainers: false, enableFixIndentedCodeBlock: false, markdownItOptions: { typographer: false }, @@ -15,10 +19,14 @@ const parser = getMarkdown('flowchat-section-boundaries', { /** Block tokenization only (~linear), never inline parsing or a second renderer. * Actual sections still use the product's remark/rehype/sanitizer pipeline. * Documents with cross-block references/raw HTML stay together. */ -export function markdownSections(source: string): readonly MarkdownSection[] { +export function markdownSections( + source: string, + options: { streaming?: boolean } = {}, +): readonly MarkdownSection[] { const whole = () => [{ start: 0, end: source.length }]; if (source.length < SECTION_CHARS) return whole(); - const cached = cache.get(source); + const target = options.streaming ? streamingCache : cache; + const cached = cache.get(source) ?? (options.streaming ? streamingCache.get(source) : undefined); if (cached) return cached; const started = isStartupRenderTraceEnabled() ? performance.now() : undefined; const normalized = source.replace(/\r\n?|\0/g, match => match === '\0' ? '\uFFFD' : '\n'); @@ -54,7 +62,7 @@ export function markdownSections(source: string): readonly MarkdownSection[] { } sections = boundaries.map((start, index) => ({ start, end: boundaries[index + 1] ?? source.length })); } - cache.set(source, sections, source.length * 2 + sections.length * 32); + target.set(source, sections, source.length * 2 + sections.length * 32); if (started !== undefined) startupTrace.markPhase('flowchat_markdown_sections', { durationMs: performance.now() - started, contentLength: source.length, sections: sections.length, }); From ec04ddb22ba53fd1a2bfd9592f6686e702104f7f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 11:47:10 +0800 Subject: [PATCH 4/7] fix(flow-chat): keep running tool groups live under holds and keep narrative A reveal or reader hold expanded a running group to every finished call, then folded it back when the hold released. The transcript crossed the viewport edge on each streamed reveal, flashing the scrollbar thumb and moving the content. Holds now keep a live group live, and only settled tool calls fold; the assistant's text and thinking stay visible. --- .../modern/FLOWCHAT_VERIFICATION.md | 2 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 23 +++++++----- .../src/flow_chat/grouping/lifecycle.ts | 20 ++++++----- .../src/flow_chat/timeline/document.test.ts | 24 +++++++++++++ src/web-ui/src/flow_chat/timeline/document.ts | 36 ++++++++++++------- 5 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index b12b211c92..1d43197bd4 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -22,7 +22,7 @@ each missing what the other had. | `../../store/modernFlowChatStore.test.ts`, `../../grouping/selectors.test.ts`, `../../grouping/activityGrouping.test.ts`, `../../../shared/utils/resourceBudget.test.ts` | cached/full projection equivalence across repeated thinking/tool-parameter streams, interior updates, rollback, completion and legacy retry/recovery; alternating live/history/side projections, completed-row reuse on append, same-store clear/reopen invalidation, warm equal-ID source isolation, group alias restoration and group-position independence; interrupted-prefix and shared-reference byte accounting | | `useFlowChatVirtualizer.initial-window.test.tsx`, `useFlowChatSearch.test.ts` | delayed observer delivery after live prepend / history tail insertion resizes the surviving key; suspended mounts are observed on resume with or without origin handoff; closing one search releases its match cache without affecting another view | | `../../perf/longRoundPerfHarness.test.tsx` | deterministic settled-row/block identity reuse, including interleaved history and side projections, plus diagnostic stage timings; supplied DOM fixtures, not native frame-rate or live remote evidence | -| `../../../infrastructure/markdown/markdownSections.test.ts`, `../../timeline/document.test.ts` | exact source reconstruction and equivalent Markdown ASTs across LF/CRLF/CR, lists/fences/tables/quotes/math, conservative reference/HTML fallback, canonical identities, Unicode search counts, bounded section estimates, no live completion repartition, and display-only narrative ID handoff with canonical bookmark/navigation IDs, retry/source isolation and alias-collision recovery; native single-attempt metadata omission and restored blank-line section coordinates without modifying host text; running groups list only in-flight members (serial, parallel, between steps, folded approval) while explicit expansion lists all and settled groups fold | +| `../../../infrastructure/markdown/markdownSections.test.ts`, `../../timeline/document.test.ts` | exact source reconstruction and equivalent Markdown ASTs across LF/CRLF/CR, lists/fences/tables/quotes/math, conservative reference/HTML fallback, canonical identities, Unicode search counts, bounded section estimates, no live completion repartition, and display-only narrative ID handoff with canonical bookmark/navigation IDs, retry/source isolation and alias-collision recovery; native single-attempt metadata omission and restored blank-line section coordinates without modifying host text; running groups list only in-flight tool calls (serial, parallel, between steps, folded approval, retained narrative) and keep that under reveal/reader holds, while explicit expansion lists all and settled groups fold | | `../FlowTextBlock.test.tsx`, `../../../infrastructure/markdown/MarkdownCodeBody.test.tsx` | sealed live-prefix DOM/parser reuse, one shared decoration observer, selected-node retention, source-only oversized fences and cleanup; DOM fixtures, not native paint evidence | | `../../trajectory` | bounded long-session rail, stable range identities, keyboard preview vs selection, searchable summary pagination, legacy-peer gating, stale device/query rejection, canonical peer body navigation, shared detail components and bounded output; local DOM/transport fixtures, not native rendering or live remote evidence | | `../FlowChatWidthBoundary.test.tsx`, `../../../infrastructure/appearance/preferences/flowChatWidthStore.test.ts`, `../../../app/scenes/settings/pages/application/FlowChatWidthSettingsSection.test.tsx` | symmetric pointer/keyboard column resizing, token-derived bounds, cancellation/cleanup, narrow-pane restoration, persisted preference compatibility and settings/reset synchronization; supplied DOM geometry, not visual or live remote evidence | diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 2363878a47..61963df297 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -485,14 +485,21 @@ not establish browser performance. ## Running tool group presentation -A running group's automatic disclosure lists only its in-flight members below a -collapsed header that keeps the group summary. A serial run therefore shows one -call, and a parallel batch shows the calls still running. Between two serial calls -the most recent call stays, so the group does not empty and refill for each step. -A member awaiting approval stays visible even in a group the reader folded. The -reader's explicit expansion, a held group, an expanded member capsule and a failed -file revision still list every member; a settled group folds to its header as -before. This is a timeline projection choice (`readerMemberPresentation` in +A running group's automatic disclosure lists only its in-flight tool calls below a +collapsed header that keeps the group summary; the assistant's text and thinking +inside the group stay visible. A serial run therefore shows one call, and a +parallel batch shows the calls still running. Between two serial calls the most +recent call stays, so the group does not empty and refill for each step. A member +awaiting approval stays visible even in a group the reader folded. The reader's +explicit expansion, an expanded member capsule and a failed file revision still +list every member; a settled group folds to its header as before. + +A hold (a revealing text member, or member rows on screen while the reader is not +following) never widens a live group: a group that settles under a hold keeps its +live presentation until the hold releases, and a held folded group still opens. +Treating every hold as expansion flipped a running group between its live call and +every finished call on each streamed reveal, which moved the transcript across the +viewport edge and toggled the scrollbar thumb. This is a timeline projection choice (`readerMemberPresentation` in `timeline/document.ts`, `liveFlowGroupMembers` in `grouping/lifecycle.ts`). It does not change group membership, lifecycle, persisted records or the embedded inline renderer. diff --git a/src/web-ui/src/flow_chat/grouping/lifecycle.ts b/src/web-ui/src/flow_chat/grouping/lifecycle.ts index 5d863cc53f..633abefb37 100644 --- a/src/web-ui/src/flow_chat/grouping/lifecycle.ts +++ b/src/web-ui/src/flow_chat/grouping/lifecycle.ts @@ -13,8 +13,9 @@ export function isFlowGroupMemberActive(item: FlowItem): boolean { /** * Members a running group shows while its disclosure is automatic. * - * Settled members fold into the header summary: a serial run shows the call - * in flight, a parallel batch shows only the calls still running, and a call + * Only settled tool calls fold into the header summary; the assistant's own + * text and thinking inside the group stay where they are. A serial run shows + * the call in flight, a parallel batch the calls still running, and a call * waiting on approval always stays visible. Between two serial calls the most * recent call stays, so the group does not empty and refill for each step. */ @@ -22,13 +23,16 @@ export function liveFlowGroupMembers( items: readonly FlowItem[], pendingPermissionToolCallIds?: ReadonlySet, ): FlowItem[] { - const live = items.filter(item => isFlowGroupMemberActive(item) - || (item.type === 'tool' && pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id) === true)); - if (live.length > 0) return live; - for (let index = items.length - 1; index >= 0; index -= 1) { - if (items[index].type === 'tool') return [items[index]]; + const isLiveTool = (item: FlowItem) => isFlowGroupMemberActive(item) + || pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id) === true; + let latestTool: FlowItem | undefined; + let hasLiveTool = false; + for (const item of items) { + if (item.type !== 'tool') continue; + latestTool = item; + if (isLiveTool(item)) hasLiveTool = true; } - return []; + return items.filter(item => item.type !== 'tool' || (hasLiveTool ? isLiveTool(item) : item === latestTool)); } const lifecycleCache = new WeakMap { expect(shown(rows)).toEqual(['b']); }); + it('keeps a held running group on its live call instead of expanding it', () => { + const projection = new ConversationDocumentProjection(); + const opts = options(); + const input = live([tool('a'), tool('b'), tool('c', 'running')]); + const id = header(projection.project([input], opts)).group!.groupId; + // A revealing member and an on-screen reader both hold the group. + const release = opts.reader.holdGroup(id, 'reveal:text'); + expect(shown(projection.project([input], opts))).toEqual(['c']); + // Settling under the hold keeps what the reader sees until it releases. + const settled = source([tool('a'), tool('b'), tool('c')]); + const held = projection.project([settled], opts); + expect(header(held).liveMembers).toBe(true); + expect(shown(held)).toEqual(['c']); + release(); + expect(shown(projection.project([settled], opts))).toEqual([]); + }); + + it('keeps the assistant narrative visible while finished calls fold', () => { + const note = { id: 'note', type: 'text', content: 'Checking the branch.', isMarkdown: true, isStreaming: false, + status: 'completed', timestamp: 1 } as FlowItem; + const rows = new ConversationDocumentProjection().project([live([tool('a'), note, tool('b'), tool('c', 'running')])], options()); + expect(shown(rows)).toEqual(['note', 'c']); + }); + it('folds a settled group to its header', () => { const rows = new ConversationDocumentProjection().project([source([tool('a'), tool('b'), tool('c')])], options()); expect(header(rows)).toMatchObject({ expanded: false }); diff --git a/src/web-ui/src/flow_chat/timeline/document.ts b/src/web-ui/src/flow_chat/timeline/document.ts index 1f54dcf905..237e149cff 100644 --- a/src/web-ui/src/flow_chat/timeline/document.ts +++ b/src/web-ui/src/flow_chat/timeline/document.ts @@ -69,16 +69,22 @@ function capsuleExpansionIndex(options: TimelineProjectionOptions): ExpandedCaps } /** - * `all` lists every member, `live` only the ones still running, `none` only - * the header. Automatic disclosure of a running group is `live`: a long serial - * run no longer spreads every finished call through the transcript. A reader's - * explicit choice, a held group, an expanded member capsule and a failed file + * `all` lists every member, `live` only the tool calls still running, `none` + * only the header. Automatic disclosure of a running group is `live`: a long + * serial run no longer spreads every finished call through the transcript. A + * reader's explicit expansion, an expanded member capsule and a failed file * revision keep the full list. + * + * A hold never widens a live group. Holds come from a revealing text member + * and from rows on screen while the reader is not following, so treating one + * as "expand" flipped a running group between its live call and every finished + * call on each streamed reveal. A held folded group still opens as before. */ type GroupMemberPresentation = 'none' | 'live' | 'all'; function readerMemberPresentation( - group: FlowGroupData, turnId: string, options: TimelineProjectionOptions, expandedCapsules?: ExpandedCapsuleIndex, + group: FlowGroupData, turnId: string, options: TimelineProjectionOptions, + expandedCapsules: ExpandedCapsuleIndex | undefined, previous: GroupMemberPresentation | undefined, ): GroupMemberPresentation { const choice = getFlowGroupDisclosureChoice(group, options.groupStates); if (choice === true) return 'all'; @@ -88,12 +94,15 @@ function readerMemberPresentation( if (permission) return 'live'; if (choice === false) return 'none'; const expandedMembers = expandedCapsules?.get(turnId); - if (options.reader.isGroupHeld(group.groupId) - || (expandedMembers !== undefined && group.allItems.some(item => expandedMembers.has(item.id)))) return 'all'; - if (!isFlowGroupExpanded(group, options.groupStates)) return 'none'; - const running = group.phase === 'collecting' || group.phase === 'settling' - || (group.phase === undefined && group.isGroupStreaming); - return running && !(group.category === 'file-edit' && group.needsAttention === true) ? 'live' : 'all'; + if (expandedMembers !== undefined && group.allItems.some(item => expandedMembers.has(item.id))) return 'all'; + if (isFlowGroupExpanded(group, options.groupStates)) { + const running = group.phase === 'collecting' || group.phase === 'settling' + || (group.phase === undefined && group.isGroupStreaming); + return running && !(group.category === 'file-edit' && group.needsAttention === true) ? 'live' : 'all'; + } + // A hold opens a folded group (search, selection), but a live one stays live. + if (options.reader.isGroupHeld(group.groupId)) return previous === 'live' ? 'live' : 'all'; + return 'none'; } type RoundItem = Extract; @@ -266,7 +275,10 @@ export class ConversationDocumentProjection { if (group.type === 'critical') { if (isFlowItemVisible(group.item)) critical.push(group.item); return; } flush(); const data = buildInlineToolGroupData(source.data.id, group, source.isLastRound && group.isLast); - const presentation = readerMemberPresentation(data, source.turnId, options, expandedCapsules); + const previousHeader = this.previous.get(`${source.turnId}:group:${data.groupId}`)?.timeline; + const previousPresentation: GroupMemberPresentation | undefined = previousHeader?.kind !== 'group-header' ? undefined + : previousHeader.liveMembers ? 'live' : previousHeader.expanded ? 'all' : 'none'; + const presentation = readerMemberPresentation(data, source.turnId, options, expandedCapsules, previousPresentation); const expanded = presentation === 'all'; const liveMembers = presentation === 'live' ? true : undefined; add('group-header', `group:${data.groupId}`, emptyItems, { group: data, expanded, liveMembers }); From 5a5f8599b269bb21ecf814d6add58b13dedf06de Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 11:52:36 +0800 Subject: [PATCH 5/7] fix(flow-chat): fold settled thinking in running tool groups Settled thoughts accumulated as rows while a group ran. Fold them with the finished calls; keep a streaming thought and one that just finished with nothing after it, and keep the assistant's text. --- .../modern/FLOWCHAT_VIRTUALIZATION.md | 6 ++++-- src/web-ui/src/flow_chat/grouping/lifecycle.ts | 18 ++++++++++++------ .../src/flow_chat/timeline/document.test.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 61963df297..ba92a77133 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -486,8 +486,10 @@ not establish browser performance. ## Running tool group presentation A running group's automatic disclosure lists only its in-flight tool calls below a -collapsed header that keeps the group summary; the assistant's text and thinking -inside the group stay visible. A serial run therefore shows one call, and a +collapsed header that keeps the group summary. Settled thinking folds with the +finished calls; the assistant's text and any thinking still streaming stay visible, +and a thought that just finished stays while nothing follows it so its reveal is +not cut off. A serial run therefore shows one call, and a parallel batch shows the calls still running. Between two serial calls the most recent call stays, so the group does not empty and refill for each step. A member awaiting approval stays visible even in a group the reader folded. The reader's diff --git a/src/web-ui/src/flow_chat/grouping/lifecycle.ts b/src/web-ui/src/flow_chat/grouping/lifecycle.ts index 633abefb37..ce985d06c7 100644 --- a/src/web-ui/src/flow_chat/grouping/lifecycle.ts +++ b/src/web-ui/src/flow_chat/grouping/lifecycle.ts @@ -13,11 +13,12 @@ export function isFlowGroupMemberActive(item: FlowItem): boolean { /** * Members a running group shows while its disclosure is automatic. * - * Only settled tool calls fold into the header summary; the assistant's own - * text and thinking inside the group stay where they are. A serial run shows - * the call in flight, a parallel batch the calls still running, and a call - * waiting on approval always stays visible. Between two serial calls the most - * recent call stays, so the group does not empty and refill for each step. + * Settled tool calls and settled thinking fold into the header; the + * assistant's own text stays where it is. A serial run shows the call in + * flight, a parallel batch the calls still running, and a call waiting on + * approval always stays visible. Between two serial calls the most recent call + * stays, so the group does not empty and refill for each step. Thinking that + * just finished stays while nothing follows it, so its reveal is not cut off. */ export function liveFlowGroupMembers( items: readonly FlowItem[], @@ -32,7 +33,12 @@ export function liveFlowGroupMembers( latestTool = item; if (isLiveTool(item)) hasLiveTool = true; } - return items.filter(item => item.type !== 'tool' || (hasLiveTool ? isLiveTool(item) : item === latestTool)); + const trailing = items[items.length - 1]; + return items.filter(item => { + if (item.type === 'tool') return hasLiveTool ? isLiveTool(item) : item === latestTool; + if (item.type === 'thinking') return isFlowGroupMemberActive(item) || item === trailing; + return true; + }); } const lifecycleCache = new WeakMap { expect(shown(rows)).toEqual(['note', 'c']); }); + it('folds settled thinking with finished calls and keeps the live thought', () => { + const thought = (id: string, isStreaming = false) => ({ id, type: 'thinking', content: `Thinking ${id}`, + isStreaming, isCollapsed: true, status: isStreaming ? 'streaming' : 'completed', timestamp: 1 }) as FlowItem; + const rows = new ConversationDocumentProjection().project([live([ + thought('t1'), tool('a'), thought('t2'), tool('b'), thought('t3'), tool('c', 'running'), thought('t4', true), + ])], options()); + expect(shown(rows)).toEqual(['c', 't4']); + // A thought that just finished stays until something follows it. + const settledThought = new ConversationDocumentProjection().project([live([ + thought('t1'), tool('a'), thought('t2'), + ])], options()); + expect(shown(settledThought)).toEqual(['a', 't2']); + }); + it('folds a settled group to its header', () => { const rows = new ConversationDocumentProjection().project([source([tool('a'), tool('b'), tool('c')])], options()); expect(header(rows)).toMatchObject({ expanded: false }); From d22957c067388051f9e054a960b692483b90322d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 12:17:49 +0800 Subject: [PATCH 6/7] fix(flow-chat): keep pinned live-group members and qualify retained sizes - A running group keeps the member row that holds focus, a selection or a live instance, so a settling call no longer drops the reader's caret or selection. - Retained row sizes record their content width; at another width they only reserve space and their rows owe the first-measurement repair. - A retained key that leaves and returns no longer hands its estimate to the next mount as a measurement. --- .../modern/FLOWCHAT_VIRTUALIZATION.md | 10 +++++-- .../components/modern/VirtualMessageList.tsx | 12 ++++++-- ...lowChatVirtualizer.retained-sizes.test.tsx | 29 +++++++++++++++++-- .../modern/useFlowChatVirtualizer.ts | 17 ++++++++++- .../src/flow_chat/timeline/document.test.ts | 13 +++++++++ src/web-ui/src/flow_chat/timeline/document.ts | 8 ++++- .../src/flow_chat/timeline/readerState.tsx | 9 ++++++ .../flow_chat/timeline/retainedViewMemory.ts | 8 ++++- .../timeline/useTimelineInteraction.ts | 2 ++ 9 files changed, 97 insertions(+), 11 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index ba92a77133..79e4fb50e3 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -135,8 +135,12 @@ mounted row is still measured and still replaces it, and a width or font change leaves it in place under the same rule as a live reservation. It is also a measurement: the next resize of a retained row is judged as a resize of content the reader may be looking at, never as a first measurement, so a partly visible -retained row that grows does not shift the viewport. The snapshot -reads no geometry, so the unmount-phase DOM cannot skew it. A retained +retained row that grows does not shift the viewport. That holds only at the +content width the sizes were measured at, which the snapshot records: reopened at +another width, retained sizes still reserve space but their rows owe the +first-measurement repair again. A retained key that leaves the projection and +returns starts from an estimate rather than handing one back as a measurement. +The snapshot reads no geometry, so the unmount-phase DOM cannot skew it. A retained projection holds its rows strongly, so up to three inactive views keep their derived rows until they are reopened or evicted. @@ -496,6 +500,8 @@ awaiting approval stays visible even in a group the reader folded. The reader's explicit expansion, an expanded member capsule and a failed file revision still list every member; a settled group folds to its header as before. +A member row holding focus, a selection or a live instance stays in a live group, +so a settling call never takes the reader's caret or highlighted text with it. A hold (a revealing text member, or member rows on screen while the reader is not following) never widens a live group: a group that settles under a hold keeps its live presentation until the hold releases, and a held folded group still opens. 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 0a2d225fbf..f264be8d28 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -422,7 +422,7 @@ const VirtualMessageListSession = forwardRef retainedTranscriptRowSizes.peek(reader)); + const [retainedRows] = useState(() => retainedTranscriptRowSizes.peek(reader)); const timelineItemsRef = useRef(virtualItems); timelineItemsRef.current = virtualItems; const nextVisibleItemIndexes = useMemo(() => getNextVisibleVirtualItemIndexes(virtualItems), [virtualItems]); @@ -604,13 +604,19 @@ const VirtualMessageListSession = forwardRef measurementAnchorKeyRef.current(), - retainedItemSizes, + retainedItemSizes: retainedRows?.sizes, + retainedItemWidthPx: retainedRows?.widthPx, }); const snapshotItemSizes = virtualizer.snapshotItemSizes; + const measuredWidthRef = useRef(availableWidthPx); + measuredWidthRef.current = availableWidthPx; useLayoutEffect(() => { retainedTranscriptRowSizes.take(reader); // Sizes only: no geometry is read, so unmount-phase DOM cannot skew them. - return () => retainedTranscriptRowSizes.put(reader, snapshotItemSizes()); + return () => retainedTranscriptRowSizes.put(reader, { + sizes: snapshotItemSizes(), + widthPx: measuredWidthRef.current > 0 ? measuredWidthRef.current : undefined, + }); }, [reader, snapshotItemSizes]); // Streaming replaces `virtualItems` every flush while its Turn boundaries diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx index 040624efd7..8feb7f50c6 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.retained-sizes.test.tsx @@ -19,11 +19,13 @@ const ESTIMATE_PX = 100; interface Item { key: string } -function Harness({ scroller, header, items, retainedItemSizes, shiftViewport, onApi }: { +function Harness({ scroller, header, items, retainedItemSizes, retainedItemWidthPx, widthPx, shiftViewport, onApi }: { scroller: HTMLElement; header: HTMLElement; items: Item[]; retainedItemSizes?: ReadonlyMap; + retainedItemWidthPx?: number; + widthPx?: number; shiftViewport: (byPx: number) => boolean; onApi: (api: FlowChatVirtualizer) => void; }) { @@ -39,6 +41,8 @@ function Harness({ scroller, header, items, retainedItemSizes, shiftViewport, on writeViewport: () => true, shiftViewport, retainedItemSizes, + retainedItemWidthPx, + estimateContext: widthPx === undefined ? undefined : { availableWidthPx: widthPx }, })); return null; } @@ -59,8 +63,10 @@ describe('useFlowChatVirtualizer retained item sizes', () => { scroller.appendChild(element); } } - const mount = (retainedItemSizes: ReadonlyMap | undefined, shiftViewport: (byPx: number) => boolean) => act(() => root.render( - | undefined, shiftViewport: (byPx: number) => boolean, + layout: { items?: Item[]; retainedItemWidthPx?: number; widthPx?: number } = {}) => act(() => root.render( + { api = next; }} />, )); @@ -106,6 +112,23 @@ describe('useFlowChatVirtualizer retained item sizes', () => { expect(api.getItemBounds(1)).toEqual({ startPx: 500, endPx: 800 }); }); + it('treats sizes retained at another content width as estimates', () => { + const shiftViewport = vi.fn(() => true); + mount(new Map([['a', 300], ['b', 300], ['c', 300]]), shiftViewport, { retainedItemWidthPx: 700, widthPx: 500 }); + renderRow(0, 500); + act(() => api.measureRenderedItems()); + // Measured for the first time at this width: the reader is owed the repair. + expect(shiftViewport).toHaveBeenCalledWith(200); + }); + + it('does not hand back an estimate for a retained row that left and returned', () => { + const retained = new Map([['a', 300], ['b', 40]]); + mount(retained, () => true, { items: [{ key: 'a' }, { key: 'b' }] }); + mount(retained, () => true, { items: [{ key: 'b' }] }); + mount(retained, () => true, { items: [{ key: 'a' }, { key: 'b' }] }); + expect([...api.snapshotItemSizes()]).toEqual([['b', 40]]); + }); + it('still owes a first measurement for a row that starts above the reader', () => { const shiftViewport = vi.fn(() => true); mount(undefined, shiftViewport); 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 22fca18e83..3f40c47bd7 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -293,6 +293,11 @@ export interface UseFlowChatVirtualizerOptions { * Mounted rows are still measured and still replace them. */ retainedItemSizes?: ReadonlyMap; + /** + * Content width the retained sizes were measured at. At another width they + * still reserve space, but no longer count as measurements. + */ + retainedItemWidthPx?: number; } export interface FlowChatVirtualizer { @@ -452,6 +457,7 @@ export function useFlowChatVirtualizer({ shiftViewport = () => false, getViewportAnchorKey, retainedItemSizes, + retainedItemWidthPx, }: UseFlowChatVirtualizerOptions): FlowChatVirtualizer { const items = useMemo(() => endOrigin ? [...sourceItems].reverse() : sourceItems, [sourceItems, endOrigin]); const endOriginRef = useRef(endOrigin); @@ -586,7 +592,14 @@ export function useFlowChatVirtualizer({ */ // A retained size is a reservation that was once measured. Seeded once; a // later prop change is not a remount and must not replace live reservations. - const [restoredItemKeys] = useState(() => new Set(retainedItemSizes?.keys() ?? [])); + // Sizes measured at another content width are reservations, not measurements: + // their rows owe the first-measurement repair when they mount. + const [restoredItemKeys] = useState(() => { + const widthPx = estimateContext?.availableWidthPx; + const sameWidth = retainedItemWidthPx === undefined || widthPx === undefined + || Math.abs(retainedItemWidthPx - widthPx) < 0.5; + return new Set(sameWidth ? retainedItemSizes?.keys() ?? [] : []); + }); const [initialReservations] = useState(() => new Map(retainedItemSizes ?? [])); const reservedEstimatesRef = useRef(initialReservations); /** Keys whose size the virtualizer has measured at least once, including retained sizes. */ @@ -979,6 +992,8 @@ export function useFlowChatVirtualizer({ retiredMeasurementsRef.current++; reservedEstimatesRef.current.delete(key); measuredKeysRef.current.delete(key); + // A key that returns later starts from an estimate, not its retained size. + restoredItemKeys.delete(key); } previousKeysRef.current = keys; if (measuredLayoutRef.current === layoutSignature && retiredMeasurementsRef.current < 2048 diff --git a/src/web-ui/src/flow_chat/timeline/document.test.ts b/src/web-ui/src/flow_chat/timeline/document.test.ts index 4446962a93..8deb2a7e87 100644 --- a/src/web-ui/src/flow_chat/timeline/document.test.ts +++ b/src/web-ui/src/flow_chat/timeline/document.test.ts @@ -370,6 +370,19 @@ describe('desktop conversation document', () => { expect(shown(settledThought)).toEqual(['a', 't2']); }); + it('keeps a settled call that holds focus or a selection', () => { + const projection = new ConversationDocumentProjection(); + const opts = options(); + const first = projection.project([live([tool('a'), tool('b', 'running')])], opts); + const row = first.find(item => item.timeline?.kind === 'group-members' && item.timeline.memberIds.includes('b'))!; + opts.reader.setPinnedRows(new Set([row.timeline!.key])); + const next = projection.project([live([tool('a'), tool('b'), tool('c', 'running')])], opts); + expect(shown(next)).toEqual(['b', 'c']); + expect(next.find(item => item.timeline?.memberIds.includes('b'))!.timeline!.key).toBe(row.timeline!.key); + opts.reader.setPinnedRows(new Set()); + expect(shown(projection.project([live([tool('a'), tool('b'), tool('c', 'running')])], opts))).toEqual(['c']); + }); + it('folds a settled group to its header', () => { const rows = new ConversationDocumentProjection().project([source([tool('a'), tool('b'), tool('c')])], options()); expect(header(rows)).toMatchObject({ expanded: false }); diff --git a/src/web-ui/src/flow_chat/timeline/document.ts b/src/web-ui/src/flow_chat/timeline/document.ts index 237e149cff..7e6a244a9e 100644 --- a/src/web-ui/src/flow_chat/timeline/document.ts +++ b/src/web-ui/src/flow_chat/timeline/document.ts @@ -288,7 +288,13 @@ export class ConversationDocumentProjection { const memberGroup = { ...data, rounds: [], allItems: [] }; const ordinals = new Map(all.filter(member => member.type === 'tool').map((member, index) => [member.id, index])); if (liveMembers) { - const runs = conversationContentRuns(liveFlowGroupMembers(all, options.pendingPermissionToolCallIds)); + // A member holding focus or a selection stays, so a settling call + // never takes the reader's caret or highlighted text with it. + const live = new Set(liveFlowGroupMembers(all, options.pendingPermissionToolCallIds)); + const pinned = new Set(conversationContentRuns(all) + .filter(run => options.reader.isRowPinned(`${source.turnId}:${this.itemIdentity.key(run[0])}`)).flat()); + const shown = all.filter(member => live.has(member) || pinned.has(member)); + const runs = conversationContentRuns(shown); runs.forEach((run, index) => addRun('group-members', run, { group: memberGroup, expanded: true, liveMembers, first: index === 0, last: index === runs.length - 1, memberOrdinal: ordinals.get(run.find(member => member.type === 'tool')?.id ?? '') })); diff --git a/src/web-ui/src/flow_chat/timeline/readerState.tsx b/src/web-ui/src/flow_chat/timeline/readerState.tsx index dd4a5a356a..15cda512c3 100644 --- a/src/web-ui/src/flow_chat/timeline/readerState.tsx +++ b/src/web-ui/src/flow_chat/timeline/readerState.tsx @@ -40,6 +40,15 @@ export class FlowChatReaderState { }; } + private pinnedRows: ReadonlySet = new Set(); + /** Rows holding focus, a selection or a live instance; a live group keeps them. */ + isRowPinned(key: string) { return this.pinnedRows.has(key); } + setPinnedRows(keys: ReadonlySet) { + if (keys.size === this.pinnedRows.size && [...keys].every(key => this.pinnedRows.has(key))) return; + this.pinnedRows = new Set(keys); + this.notifyProjection(); + } + isGroupHeld(id: string) { return Boolean(this.groupHolds.get(id)?.size); } holdGroup(id: string, owner: string): () => void { const owners = this.groupHolds.get(id) ?? new Set(); diff --git a/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts b/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts index c0b5774574..131ce56c98 100644 --- a/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts +++ b/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts @@ -60,6 +60,12 @@ export const MAX_RETAINED_SESSION_VIEWS = 3; * to the mount. Keys come from the retained projection in * `useConversationTimeline`, so view-local aliases still match. */ -export const retainedTranscriptRowSizes = new RetainedViewMemory>( +export interface RetainedTranscriptRowSizes { + sizes: Map; + /** Content width the sizes were measured at; another width only reserves them. */ + widthPx?: number; +} + +export const retainedTranscriptRowSizes = new RetainedViewMemory( MAX_RETAINED_SESSION_VIEWS, ); diff --git a/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts b/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts index 4245381de7..61cc4bc548 100644 --- a/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts +++ b/src/web-ui/src/flow_chat/timeline/useTimelineInteraction.ts @@ -72,6 +72,7 @@ export function useTimelineInteraction( // Mere visibility does not acquire an instance lease. scroller.querySelectorAll('[data-flowchat-retain-instance="true"]').forEach(rowFor); setPinnedKeys(previous => previous.size === pinned.size && [...pinned].every(key => previous.has(key)) ? previous : pinned); + reader.setPinnedRows(pinned); const groups = new Set(); const bounds = scroller.getBoundingClientRect(); scroller.querySelectorAll('.virtual-item-wrapper[data-timeline-kind="group-members"]').forEach(row => { @@ -134,6 +135,7 @@ export function useTimelineInteraction( scroller.removeEventListener('focusout', schedule); scroller.removeEventListener('pointerup', update); document.removeEventListener('selectionchange', selectionChanged); holds.forEach(release => release()); + reader.setPinnedRows(new Set()); }; }, [reader, scrollerRef, rowSelector]); useEffect(() => { From ab356ab7861f92e93b6514359ff9f8f9a68db4e1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 12 Nov 2026 12:28:01 +0800 Subject: [PATCH 7/7] fix(flow-chat): declare retained keys in the measurement reset effect --- .../src/flow_chat/components/modern/useFlowChatVirtualizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3f40c47bd7..9fe316917f 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -1031,7 +1031,7 @@ export function useFlowChatVirtualizer({ retiredMeasurementsRef.current = 0; pendingLayoutMeasureRef.current = false; }, [itemKeyRevision, layoutSignature, items, measureRenderedItems, virtualizer, - measurementSuspended]); + measurementSuspended, restoredItemKeys]); const getItemBounds = useCallback((index: number): FlowChatItemBounds | null => { const measurement = (virtualizer as unknown as MeasuringVirtualizer)