From a81f7df75340e66f3508b5cfb8f3675675fa216d Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:00:02 +0800 Subject: [PATCH 1/9] perf(flowchat): collapse embedded streaming cards Keep thinking, file-operation, and process cards compact while embedded subagent output is streaming. This avoids mounting their full previews by default and preserves explicit expansion for readers who need the details. --- .../flow-chat-presentation/src/thinking.ts | 13 ++++++++++-- .../tool-cards/ExecCommandToolCard.tsx | 2 ++ .../tool-cards/ExecProcessToolCardView.tsx | 17 ++++++++++++++-- .../tool-cards/FileOperationToolCard.tsx | 20 ++++++++++++++++++- .../tool-cards/ModelThinkingDisplay.tsx | 19 +++++++++++++++--- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/flow-chat-presentation/src/thinking.ts b/packages/flow-chat-presentation/src/thinking.ts index ffb007f537..ba42306f8e 100644 --- a/packages/flow-chat-presentation/src/thinking.ts +++ b/packages/flow-chat-presentation/src/thinking.ts @@ -7,11 +7,20 @@ export interface ThinkingDisclosureInput { isLastItem: boolean; forceExpanded?: boolean; displayContext?: 'default' | 'subagent-projection'; + /** Embedded subagent transcripts stay compact until the reader opens them. */ + compactByDefault?: boolean; } -export function defaultThinkingExpanded({ isSummary, isActive, isRevealing = false, isLastItem, forceExpanded = false }: ThinkingDisclosureInput): boolean { +export function defaultThinkingExpanded({ + isSummary, + isActive, + isRevealing = false, + isLastItem, + forceExpanded = false, + compactByDefault = false, +}: ThinkingDisclosureInput): boolean { // A successor can arrive before the reasoning stream/typewriter has drained. - return forceExpanded || (!isSummary && (isActive || isRevealing || isLastItem)); + return forceExpanded || (!compactByDefault && !isSummary && (isActive || isRevealing || isLastItem)); } /** Stream/reveal and the inner/outer scroll owners stay outside this hook. */ diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx index d7ee7dc1b4..1401949b93 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx @@ -8,6 +8,7 @@ export const ExecCommandToolCard: React.FC = ({ toolItem, onExpand, isLastItem, + displayContext, }) => { const { t } = useTranslation('flow-chat'); const model = useMemo( @@ -21,6 +22,7 @@ export const ExecCommandToolCard: React.FC = ({ model={model} onExpand={onExpand} isLastItem={isLastItem} + displayContext={displayContext} /> ); }; diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx index 1277c00036..0147bb618f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx @@ -1,13 +1,14 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ExecProcessPresentation, type ExecProcessCardModel } from '@openbitfun/flow-chat-presentation/exec'; -import type { FlowToolItem } from '../types/flow-chat'; +import type { FlowToolItem, ToolCardDisplayContext } from '../types/flow-chat'; import { LazyTerminalOutputRenderer } from '@/tools/terminal/components/LazyTerminalOutputRenderer'; import { ToolCardCopyAction } from './ToolCardCopyAction'; import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { useCopyTextAction } from '../hooks/useCopyTextAction'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { getToolItemCardConfig } from './toolCardMetadata'; +import { useFlowChatContext } from '../components/modern/FlowChatContext'; export type { ExecProcessCardModel } from '@openbitfun/flow-chat-presentation/exec'; interface ExecProcessToolCardViewProps { @@ -15,11 +16,20 @@ interface ExecProcessToolCardViewProps { model: ExecProcessCardModel; onExpand?: () => void; isLastItem?: boolean; + displayContext?: ToolCardDisplayContext; } /** Host actions stay here; all card projection and disclosure live in the shared presenter. */ -export const ExecProcessToolCardView: React.FC = ({ toolItem, model, onExpand }) => { +export const ExecProcessToolCardView: React.FC = ({ + toolItem, + model, + onExpand, + displayContext, +}) => { const { t } = useTranslation('flow-chat'); + const { activeSessionOverride } = useFlowChatContext(); + const isSubagentSurface = displayContext === 'subagent-projection' + || activeSessionOverride?.sessionKind === 'subagent'; const { cardRootRef, dispatchToolCardToggle } = useToolCardHeightContract({ toolId: toolItem.id, toolName: toolItem.toolName, @@ -40,6 +50,9 @@ export const ExecProcessToolCardView: React.FC = ( toolItem={toolItem} model={model} attention={getToolItemCardConfig(toolItem).attention} + // Embedded output remains compact while it streams; the presentation + // still owns the explicit expand action and completion lifecycle. + initialExpanded={isSubagentSurface ? false : undefined} t={t} rootRef={cardRootRef} onExpandedChange={onExpandedChange} diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 5aaf9b5051..5db6091ece 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -38,6 +38,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { WritePlanDisplay } from './WritePlanDisplay'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { hasSessionFileProvider, openFileThroughSession } from '../session-drivers/sessionFileNavigation'; +import { useFlowChatContext } from '../components/modern/FlowChatContext'; const log = createLogger('FileOperationToolCard'); const FILE_OPERATION_STREAMING_MAX_HEIGHT = 4 * 22; // 88px – compact while streaming @@ -127,6 +128,7 @@ interface FileOperationToolCardProps extends ToolCardProps { const GenericFileOperationToolCard: React.FC = ({ toolItem, config, + displayContext, sessionId, onOpenInEditor, isLastItem, @@ -144,7 +146,14 @@ const GenericFileOperationToolCard: React.FC = ({ const status = getToolCardStatus(toolItem); const isFailed = status === 'error'; - const [isContentExpanded, setIsContentExpanded] = useState(status !== 'completed' && !isFailed); + const { activeSessionOverride } = useFlowChatContext(); + const isSubagentProjection = displayContext === 'subagent-projection'; + const isSubagentSurface = isSubagentProjection || activeSessionOverride?.sessionKind === 'subagent'; + // Tool payloads can keep arriving while an embedded subagent is hidden; + // avoid mounting the full preview until the reader expands the card. + const [isContentExpanded, setIsContentExpanded] = useState( + !isSubagentSurface && status !== 'completed' && !isFailed, + ); const [isFailureExpanded, setIsFailureExpanded] = useState(false); const [retainLiveCompletionPreview, setRetainLiveCompletionPreview] = useState(false); const [operationDiffStats, setOperationDiffStats] = useState<{ surfaceEpoch: number; additions: number; deletions: number } | null>(null); @@ -251,10 +260,12 @@ const GenericFileOperationToolCard: React.FC = ({ const writeTypewriter = useTypewriter( toolItem.toolName === 'Write' ? contentPreview : '', isWriteContentAnimating, + { revealImmediately: !isContentExpanded }, ); const editTypewriter = useTypewriter( toolItem.toolName === 'Edit' ? newStringContent : '', isEditContentAnimating, + { revealImmediately: !isContentExpanded }, ); useReportTypewriterReveal(`${toolId ?? 'file-op'}:write`, writeTypewriter.isRevealing); useReportTypewriterReveal(`${toolId ?? 'file-op'}:edit`, editTypewriter.isRevealing); @@ -390,6 +401,12 @@ const GenericFileOperationToolCard: React.FC = ({ }, [isFailed, isFailureExpanded]); useLayoutEffect(() => { + if (isSubagentSurface && !userToggledContentRef.current) { + setRetainLiveCompletionPreview(false); + applyContentExpandedState(false, 'auto'); + return; + } + if (isFailed) { setRetainLiveCompletionPreview(false); applyContentExpandedState(false, 'auto'); @@ -422,6 +439,7 @@ const GenericFileOperationToolCard: React.FC = ({ isContentExpanded, isFailed, isLastItem, + isSubagentSurface, status, ]); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index b9822b3111..74650a4af7 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -51,12 +51,19 @@ export const ModelThinkingDisplay: React.FC = ({ sourceSessionId, }) => { const { t } = useTranslation('flow-chat'); - const { sessionId, workspaceId, workspacePath, remoteConnectionId } = useFlowChatContext(); + const { sessionId, workspaceId, workspacePath, remoteConnectionId, activeSessionOverride } = useFlowChatContext(); const { content, isStreaming, status } = thinkingItem; const isSummary = thinkingItem.reasoningKind === 'summary'; const isActive = isStreaming || status === 'streaming'; + const isSubagentSurface = displayContext === 'subagent-projection' + || activeSessionOverride?.sessionKind === 'subagent'; const shouldDefaultExpanded = defaultThinkingExpanded({ - isSummary, isActive, isLastItem: !withinGroup && isLastItem, forceExpanded, displayContext, + isSummary, + isActive, + isLastItem: !withinGroup && isLastItem, + forceExpanded, + displayContext, + compactByDefault: isSubagentSurface, }); const [retainClosingContent, setRetainClosingContent] = useState(shouldDefaultExpanded); const expandContainerRef = useRef(null); @@ -74,7 +81,13 @@ export const ModelThinkingDisplay: React.FC = ({ }); const { expanded: isExpanded } = useThinkingDisclosure({ // Opening a collection does not open its completed reasoning, including its tail. - isSummary, isActive, isRevealing, isLastItem: !withinGroup && isLastItem, forceExpanded, displayContext, + isSummary, + isActive, + isRevealing, + isLastItem: !withinGroup && isLastItem, + forceExpanded, + displayContext, + compactByDefault: isSubagentSurface, }, dispatchToolCardToggle); const shouldMountContent = isExpanded || retainClosingContent; From 8ebb72518dc02db3ef4975440981cd3f5c8c151f Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:00:17 +0800 Subject: [PATCH 2/9] perf(flowchat): skip hidden typewriter state updates Return the latest target directly while immediate reveal mode is active instead of committing every streamed chunk to React state. Seed the state once when animated visibility resumes so hidden content does not build update work or stale playback state. --- .../src/flow_chat/hooks/useTypewriter.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts index e08ea25d37..9b05e31e4a 100644 --- a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts +++ b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts @@ -220,6 +220,7 @@ export function useTypewriter( const revealedRef = useRef(shouldReplayInitialText ? 0 : targetText.length); const targetRef = useRef(targetText); const animateRef = useRef(animate); + const revealImmediatelyRef = useRef(revealImmediately); const rafRef = useRef(null); const lastTickMsRef = useRef(null); const lastPaintMsRef = useRef(0); @@ -229,6 +230,8 @@ export function useTypewriter( && (animate || displayText.length < targetText.length); useEffect(() => { + const wasRevealImmediately = revealImmediatelyRef.current; + revealImmediatelyRef.current = revealImmediately; animateRef.current = animate; targetRef.current = targetText; @@ -243,10 +246,24 @@ export function useTypewriter( lastPaintMsRef.current = 0; fractionalCarryRef.current = 0; revealedRef.current = targetText.length; - setDisplayText(targetText); + // The immediate result is returned directly below. Avoid scheduling a + // React state update for every streaming chunk while the owner is + // collapsed. When the mode changes later, the animated branch syncs the + // state once before deciding whether a reveal is needed. + if (!revealImmediately || !wasRevealImmediately) { + setDisplayText(targetText); + } return; } + if (wasRevealImmediately) { + // The state was intentionally left untouched while immediate mode was + // active. Seed it once when the content becomes visible again so a + // subsequent animated update starts from the current target. + setDisplayText(targetText); + revealedRef.current = targetText.length; + } + // Reset when target shrinks (e.g. new round). if (targetText.length < revealedRef.current) { revealedRef.current = 0; From d99aab44d997053b91160a251a37933c760e7fce Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:10:29 +0800 Subject: [PATCH 3/9] fix(flowchat): preserve embedded row height containment Keep the shared flow-root boundary on embedded virtual rows so trailing child margins remain part of the measured row height. Add layout and virtualizer regression coverage for the feedback loop that could remount rows while tail-following. --- .../components/btw/BtwSessionPanel.scss | 2 +- .../btw/BtwSessionPanelLayout.test.ts | 10 +++ .../btw/BtwVirtualSessionList.test.tsx | 74 ++++++++++++++++++- .../modern/FLOWCHAT_VIRTUALIZATION.md | 11 +++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss index f037909d98..6e128b1ed9 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss @@ -82,7 +82,7 @@ .virtual-item-wrapper { width: 100%; - display: block; + // Preserve the shared flow-root: virtual padding must include child margins. } } diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts index af4549362a..a6abcb9d49 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts @@ -17,6 +17,16 @@ function extractBlock(stylesheet: string, selector: string): string { } describe('BtwSessionPanel layout styles', () => { + it('preserves the shared virtual row margin containment', () => { + const stylesheet = readBtwSessionPanelStylesheet(); + const shared = readFileSync( + fileURLToPath(new URL('../modern/VirtualItemRenderer.scss', import.meta.url)), 'utf8', + ); + expect(extractBlock(shared, '.virtual-item-wrapper')).toContain('display: flow-root;'); + // A more specific sidebar rule must not undo the measured row boundary. + expect(extractBlock(stylesheet, '.virtual-item-wrapper')).not.toMatch(/display\s*:/); + }); + it('keeps the review action bar wrapper bounded inside the panel viewport', () => { const stylesheet = readBtwSessionPanelStylesheet(); const wrapper = extractBlock(stylesheet, '&__action-bar-wrapper'); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx index b67b0dc9e1..41108ad8de 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx @@ -28,10 +28,10 @@ describe('BtwVirtualSessionList', () => { type: 'image-analyzing', turnId: `turn-${index}`, } as VirtualItem)); - function render() { + function render(renderedItems = items) { act(() => root.render( { owner: 'follow-output', topPx: 120000, holdForMs: 0, }); }); + + it.each([false, true])('settles tail rows when trailing margins are measured: %s', contained => { + // Supplied CSS geometry: a 100px body and an 8px trailing child margin. + // With flow-root both occupy the measured border box; with block the + // margin escapes, still occupies layout space, and vanishes on unmount. + // jsdom does not perform margin collapsing; this tests its consequences + // against the real virtualizer and the panel's real follow scheduler. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { + return this.hasAttribute('data-virtual-index') ? (contained ? 108 : 100) : 0; + }); + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => { + const window = host.firstElementChild as HTMLElement | null; + if (!window) return 500; + return Math.max(500, 15 + Number.parseFloat(window.style.paddingTop || '0') + + Number.parseFloat(window.style.paddingBottom || '0') + window.children.length * 108); + } }); + let offset = 0; + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => Math.max(0, Math.min(offset, scroller.scrollHeight - scroller.clientHeight)), + set: (value: number) => { offset = Math.max(0, Math.min(value, scroller.scrollHeight - scroller.clientHeight)); }, + }); + vi.mocked(owner.write).mockImplementation(({ topPx }) => { + scroller.scrollTop = topPx; + return true; + }); + vi.mocked(owner.shift).mockImplementation(delta => { + scroller.scrollTop += delta; + return true; + }); + followRef.current = true; + render(items.slice(0, 20)); + const oldRowNodes: Array = []; + const deliveredSizes = new WeakMap>(); + for (let frame = 0; frame < 40; frame++) { + act(() => { + // New and resized rows are measured after layout, including rows + // whose inline measurement was skipped during native scrolling. + for (const observer of [...resizeObservers]) { + const sizes = deliveredSizes.get(observer) ?? new Map(); + deliveredSizes.set(observer, sizes); + const entries: ResizeObserverEntry[] = []; + for (const target of observer.targets) { + const blockSize = target === host.firstElementChild + ? scroller.scrollHeight - 15 : (target as HTMLElement).offsetHeight; + if (sizes.get(target) === blockSize) continue; + sizes.set(target, blockSize); + entries.push({ target, borderBoxSize: [{ blockSize, inlineSize: 500 }] } as unknown as ResizeObserverEntry); + } + if (entries.length) observer.callback(entries, {} as ResizeObserver); + } + }); + act(() => { + // Browser scroll delivery precedes the next animation-frame write. + scroller.dispatchEvent(new Event('scroll')); + const pending = [...frames.values()]; + frames.clear(); + pending.forEach(callback => callback(frame * 16)); + }); + if (frame >= 10) oldRowNodes.push(host.querySelector('[data-virtual-index="9"]')); + } + const identities = new Set(oldRowNodes.filter(node => node !== null)); + if (contained) { + expect(oldRowNodes).not.toContain(null); + expect(identities.size).toBe(1); + } else { + expect(oldRowNodes).toContain(null); + expect(identities.size).toBeGreaterThan(10); + } + }); }); 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 a390881d2d..cacd10c5be 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 @@ -9,6 +9,17 @@ Do not substitute clipping overflow: user-message decorations and controls can extend outside the reading column. The rhythm test protects this stylesheet contract; it does not prove browser layout or end-to-end scroll stability. +Embedded panels must preserve that shared formatting context. The Btw sidebar +previously overrode the wrapper with `display: block`, allowing trailing child +margins to escape measurement. Removing a boundary row then removed space not +represented by virtual padding; tail follow could bring the row back, restoring +the space and removing it again. `BtwSessionPanelLayout.test.ts` protects the +cascade contract. `BtwVirtualSessionList.test.tsx` reproduces the feedback with +supplied contained/escaping margin geometry, the real virtualizer, and delayed +resize/scroll delivery. This isolates a window-churn mechanism; native WebView2 +memory savings and the fraction of observed runtime churn it explains still +require a retest. + ## Interrupted turn continuity Cancelled rounds remain in the ordinary transcript. The display projection removes From 6d85dee79956f6c6e2f7cd6b598eede05128fd4d Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:10:49 +0800 Subject: [PATCH 4/9] perf(i18n): cache repeated locale formatters Reuse bounded date and number formatters across repeated transcript renders, while clearing them when the locale changes and bypassing unsupported option objects. This reduces repeated ICU construction during streaming updates without changing formatted values. --- .../i18n/core/I18nService.test.ts | 28 +++++ .../infrastructure/i18n/core/I18nService.ts | 110 +++++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/infrastructure/i18n/core/I18nService.test.ts b/src/web-ui/src/infrastructure/i18n/core/I18nService.test.ts index afc526165c..c4080f9da0 100644 --- a/src/web-ui/src/infrastructure/i18n/core/I18nService.test.ts +++ b/src/web-ui/src/infrastructure/i18n/core/I18nService.test.ts @@ -104,4 +104,32 @@ describe('I18nService shared namespace contract', () => { expect(service.getCurrentLocale()).toBe(initialLocale); vi.unstubAllGlobals(); }); + + it('reuses equivalent date and number formatters', () => { + const service = new I18nService(); + const createDateTimeFormatter = ( + service as unknown as { + createDateTimeFormatter(options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat; + } + ).createDateTimeFormatter.bind(service); + + const dateFormatter = createDateTimeFormatter({ year: 'numeric', month: '2-digit' }); + expect(createDateTimeFormatter({ month: '2-digit', year: 'numeric' })).toBe(dateFormatter); + + const firstNumber = service.formatNumber(1234.5, { style: 'decimal', useGrouping: true }); + const secondNumber = service.formatNumber(1234.5, { useGrouping: true, style: 'decimal' }); + expect(secondNumber).toBe(firstNumber); + }); + + it('does not cache options with custom prototypes', () => { + const service = new I18nService(); + const createDateTimeFormatter = ( + service as unknown as { + createDateTimeFormatter(options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat; + } + ).createDateTimeFormatter.bind(service); + const options = Object.create({ dateStyle: 'medium' }) as Intl.DateTimeFormatOptions; + + expect(createDateTimeFormatter(options)).not.toBe(createDateTimeFormatter(options)); + }); }); diff --git a/src/web-ui/src/infrastructure/i18n/core/I18nService.ts b/src/web-ui/src/infrastructure/i18n/core/I18nService.ts index 3e29230dd3..db47e13049 100644 --- a/src/web-ui/src/infrastructure/i18n/core/I18nService.ts +++ b/src/web-ui/src/infrastructure/i18n/core/I18nService.ts @@ -36,6 +36,76 @@ import { logDuration, measureSync, nowMs, elapsedMs } from '@/shared/utils/timin const log = createLogger('I18nService'); +// Streaming transcript updates can format the same timestamps and counters +// many times. Keep a small locale-scoped cache so those renders do not create +// a new ICU formatter for every update. +const FORMATTER_CACHE_CAPACITY = 64; +const DATE_FORMATTER_CACHE_TTL_MS = 30_000; + +type FormatterOptions = Intl.DateTimeFormatOptions | Intl.NumberFormatOptions; + +function formatterOptionsKey(options?: FormatterOptions): string | null { + if (options === undefined) return '{}'; + try { + if (options === null || typeof options !== 'object') return null; + + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) return null; + + const entries: Array<[string, string | number | boolean | null | undefined]> = []; + for (const key of Object.getOwnPropertyNames(options)) { + const descriptor = Object.getOwnPropertyDescriptor(options, key); + if (!descriptor || !('value' in descriptor)) return null; + const value = descriptor.value; + if (value === undefined) continue; + if (typeof value === 'number' && !Number.isFinite(value)) return null; + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + return null; + } + entries.push([key, value]); + } + + entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); + const key = entries.length === 0 ? '{}' : JSON.stringify(entries); + return key.length <= 128 ? key : null; + } catch { + return null; + } +} + +class BoundedFormatterCache { + private readonly entries = new Map(); + + get(key: string, now: number, ttlMs?: number): T | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (ttlMs !== undefined && now - entry.createdAt >= ttlMs) { + this.entries.delete(key); + return undefined; + } + this.entries.delete(key); + this.entries.set(key, entry); + return entry.formatter; + } + + set(key: string, formatter: T, now: number): void { + this.entries.delete(key); + this.entries.set(key, { formatter, createdAt: now }); + while (this.entries.size > FORMATTER_CACHE_CAPACITY) { + this.entries.delete(this.entries.keys().next().value as string); + } + } + + clear(): void { + this.entries.clear(); + } +} + const lazyLocaleModules = import.meta.glob('../../../locales/**/*.json', { import: 'default', }) as Record Promise>>; @@ -140,6 +210,8 @@ export class I18nService { private initialized: boolean = false; // Monotonic counter to detect mid-flight locale changes and avoid racey overrides. private localeChangeSeq: number = 0; + private readonly dateFormatterCache = new BoundedFormatterCache(); + private readonly numberFormatterCache = new BoundedFormatterCache(); constructor() { this.i18nInstance = i18next.createInstance(); @@ -191,6 +263,7 @@ export class I18nService { await this.loadNamespacesForLocale(WEB_UI_BOOTSTRAP_NAMESPACES, localeToUse); await this.i18nInstance.changeLanguage(localeToUse); this.currentLocaleId = localeToUse; + this.clearFormatterCaches(); } @@ -375,6 +448,7 @@ export class I18nService { await this.i18nInstance.changeLanguage(locale); this.currentLocaleId = locale; + this.clearFormatterCaches(); this.updateHtmlLang(locale); @@ -482,7 +556,20 @@ export class I18nService { private createDateTimeFormatter( options?: Intl.DateTimeFormatOptions, ): Intl.DateTimeFormat { - return new Intl.DateTimeFormat(this.currentLocaleId, options); + const key = formatterOptionsKey(options); + if (key === null) { + return new Intl.DateTimeFormat(this.currentLocaleId, options); + } + + const now = Date.now(); + const cached = this.dateFormatterCache.get(`${this.currentLocaleId}|${key}`, now, DATE_FORMATTER_CACHE_TTL_MS); + if (cached) { + return cached; + } + + const formatter = new Intl.DateTimeFormat(this.currentLocaleId, options); + this.dateFormatterCache.set(`${this.currentLocaleId}|${key}`, formatter, now); + return formatter; } formatDate(date: Date | number, options?: Intl.DateTimeFormatOptions): string { @@ -495,7 +582,26 @@ export class I18nService { formatNumber(number: number, options?: Intl.NumberFormatOptions): string { - return new Intl.NumberFormat(this.currentLocaleId, options).format(number); + const key = formatterOptionsKey(options); + if (key === null) { + return new Intl.NumberFormat(this.currentLocaleId, options).format(number); + } + + const now = Date.now(); + const cacheKey = `${this.currentLocaleId}|${key}`; + const cached = this.numberFormatterCache.get(cacheKey, now); + if (cached) { + return cached.format(number); + } + + const formatter = new Intl.NumberFormat(this.currentLocaleId, options); + this.numberFormatterCache.set(cacheKey, formatter, now); + return formatter.format(number); + } + + private clearFormatterCaches(): void { + this.dateFormatterCache.clear(); + this.numberFormatterCache.clear(); } From 12f69d69be085bccc4413943ab2be1eab34f56a5 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:11:08 +0800 Subject: [PATCH 5/9] perf(flowchat): defer runtime hint translation Avoid translating the full processing-hint catalog on every RuntimeStatusSlot render. Cache translated hints by i18n instance and language, and only resolve them when an unlabeled runtime status needs a generated hint. --- .../modern/RuntimeStatusSlot.test.tsx | 36 ++++++++++++++-- .../components/modern/RuntimeStatusSlot.tsx | 43 ++++++++++++++++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx index d186b3d482..bce582faf3 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx @@ -8,10 +8,14 @@ import { useRuntimeStatusStore } from '../../store/runtimeStatusStore'; import { activateSurface, getActiveSurfaceScope, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; import { registerSubmittedMessage } from '../../services/submittedMessagePresentation'; +const translationMock = vi.hoisted(() => ({ + t: vi.fn(() => ['Working on it']), + i18n: { language: 'en-US', resolvedLanguage: 'en-US' }, + ready: true, +})); + vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: () => ['Working on it'], - }), + useTranslation: () => translationMock, })); describe('RuntimeStatusSlot', () => { @@ -23,6 +27,7 @@ describe('RuntimeStatusSlot', () => { vi.useFakeTimers(); activateSurface(LOCAL_SURFACE_ID); useRuntimeStatusStore.getState().reset(); + translationMock.t.mockClear(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -95,4 +100,29 @@ describe('RuntimeStatusSlot', () => { expect(slot?.dataset.runtimeStatusVisible).toBe('false'); expect(container.querySelector('[data-openbitfun-part="leadingIcon"]')).toBe(iconSlot); }); + + it('only translates generated hints when an unlabeled status is visible', () => { + translationMock.i18n.language = 'test-hints'; + translationMock.i18n.resolvedLanguage = 'test-hints'; + act(() => root.render()); + expect(translationMock.t).not.toHaveBeenCalled(); + + act(() => useRuntimeStatusStore.getState().show({ + sessionId: 'session-1', turnId: 'turn-1', roundId: 'round-1', + })); + expect(translationMock.t).toHaveBeenCalledTimes(1); + + act(() => useRuntimeStatusStore.getState().show({ + sessionId: 'session-1', turnId: 'turn-2', roundId: 'round-2', + })); + expect(translationMock.t).toHaveBeenCalledTimes(1); + + act(() => useRuntimeStatusStore.getState().show({ + sessionId: 'session-1', + turnId: 'turn-3', + roundId: 'round-3', + label: 'Transferring workspace', + })); + expect(translationMock.t).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx index d4dbc10e4d..04b8614ccc 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx @@ -5,6 +5,35 @@ import { useRuntimeStatusStore } from '../../store/runtimeStatusStore'; import { submittedMessageStatusDelay } from '../../services/submittedMessagePresentation'; import './RuntimeStatusSlot.scss'; +const EMPTY_HINTS: readonly string[] = []; +const translatedHintsCache = new WeakMap>(); + +// Status labels supplied by the runtime do not need the generated hint list; +// defer translation work until an unlabeled status is actually visible. +function getTranslatedHints( + cacheOwner: object, + t: (key: string, options?: Record) => unknown, + language: string, + ready: boolean, +): readonly string[] { + const cacheKey = `${language}:${ready ? 'ready' : 'loading'}`; + let hintsByLanguage = translatedHintsCache.get(cacheOwner); + if (!hintsByLanguage) { + hintsByLanguage = new Map(); + translatedHintsCache.set(cacheOwner, hintsByLanguage); + } + + const cachedHints = hintsByLanguage.get(cacheKey); + if (cachedHints) return cachedHints; + + const rawHints = t('items', { returnObjects: true }); + const hints = Array.isArray(rawHints) + ? rawHints.filter((item): item is string => typeof item === 'string') + : EMPTY_HINTS; + hintsByLanguage.set(cacheKey, hints); + return hints; +} + interface RuntimeStatusSlotProps { sessionId?: string | null; placement?: 'footer' | 'inline'; @@ -25,11 +54,15 @@ export const RuntimeStatusSlot: React.FC = ({ const status = useRuntimeStatusStore(state => ( sessionId ? state.bySessionId.get(sessionId) : undefined )); - const { t } = useTranslation('flow-chat/processing-hints'); - const rawHints = t('items', { returnObjects: true }); - const hints = Array.isArray(rawHints) - ? rawHints.filter((item): item is string => typeof item === 'string') - : []; + const { t, i18n, ready } = useTranslation('flow-chat/processing-hints'); + const needsGeneratedHint = Boolean(status && !status.label); + const language = i18n.resolvedLanguage ?? i18n.language ?? 'default'; + const hints = React.useMemo( + () => needsGeneratedHint + ? getTranslatedHints(i18n, t, language, ready) + : EMPTY_HINTS, + [i18n, language, needsGeneratedHint, ready, t], + ); const hint = status ? status.label || hints[stableHintIndex(`${status.turnId}:${status.roundId}`, hints.length)] From f4cc0b497b48259d9f85538c486245cf1da4201a Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:11:20 +0800 Subject: [PATCH 6/9] perf(nav): stabilize sessions navigation selector Track only the session fields rendered by the navigation and return a primitive revision from a memoized selector. Streaming updates that do not affect navigation no longer rebuild the full joined signature string. --- .../sections/sessions/SessionsSection.tsx | 24 +--- .../sessions/sessionsNavSelector.test.ts | 39 ++++++ .../sections/sessions/sessionsNavSelector.ts | 130 ++++++++++++++++++ 3 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 51cab9743f..ea19ee3e5f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -106,6 +106,7 @@ import { } from './sessionNavExpand'; import { useSessionRowRemovalTransition } from './sessionRowShift'; import { SessionStatusIndicator } from './SessionStatusIndicator'; +import { createSessionsNavSelector } from './sessionsNavSelector'; import './SessionsSection.scss'; const log = createLogger('SessionsSection'); @@ -339,29 +340,10 @@ const SessionsSection: React.FC = ({ const bufferPrefetchSignatureRef = useRef(null); useEffect(() => { - const selector = (s: FlowChatState): string => { - const parts: string[] = [s.activeSessionId ?? '']; - for (const session of s.sessions.values()) { - const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; - const dispatchTarget = session.config.dispatchTarget; - const dispatchTargetSnapshot = dispatchTarget?.kind === 'ssh' - ? `ssh:${dispatchTarget.connectionId}:${dispatchTarget.workspacePath}:${dispatchTarget.displayName}` - : dispatchTarget?.kind === 'device' - ? `device:${dispatchTarget.deviceId}:${dispatchTarget.workspacePath}:${dispatchTarget.displayName}` - : 'local'; - parts.push( - `${session.sessionId}|${session.isTransient ? '1':'0'}|${session.sessionKind}|` + - `${session.parentSessionId ?? ''}|${session.parentToolCallId ?? ''}|${session.subagentType ?? ''}|` + - `${session.workspacePath ?? ''}|${session.mode ?? ''}|${session.needsUserAttention ?? ''}|` + - `${session.hasUnreadCompletion ?? ''}|${latestTurn?.status ?? ''}|` + - `${session.title ?? ''}|${dispatchTargetSnapshot}|${session.config.dispatchJobState ?? ''}` - ); - } - return parts.join(';'); - }; + const selector = createSessionsNavSelector(); const unsub = flowChatStore.subscribeSelector(selector, (() => { setFlowChatState(flowChatStore.getState()); - }), { isEqual: (a, b) => a === b }); + })); return () => unsub(); }, []); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts new file mode 100644 index 0000000000..4b3b913dbf --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat'; +import { createSessionsNavSelector } from './sessionsNavSelector'; + +function session(overrides: Partial = {}): Session { + return { + sessionId: 'session-1', + dialogTurns: [], + status: 'idle', + config: {}, + sessionKind: 'normal', + ...overrides, + } as Session; +} + +function state(sessions: Session[], activeSessionId: string | null = null): FlowChatState { + return { sessions: new Map(sessions.map(item => [item.sessionId, item])), activeSessionId } as FlowChatState; +} + +describe('createSessionsNavSelector', () => { + it('reuses the revision when only non-navigation session data changes', () => { + const select = createSessionsNavSelector(); + const first = session(); + + expect(select(state([first]))).toBe(1); + expect(select(state([{ ...first, dialogTurns: [{ id: 'turn-1' }] as Session['dialogTurns'] }]))).toBe(1); + }); + + it('increments for navigation fields and session ordering changes', () => { + const select = createSessionsNavSelector(); + const first = session(); + const second = session({ sessionId: 'session-2' }); + + expect(select(state([first, second]))).toBe(1); + expect(select(state([{ ...first, title: 'Renamed' }, second]))).toBe(2); + expect(select(state([{ ...first, title: 'Renamed', needsUserAttention: 'ask_user' }, second]))).toBe(3); + expect(select(state([second, { ...first, title: 'Renamed', needsUserAttention: 'ask_user' }]))).toBe(4); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts new file mode 100644 index 0000000000..8e715d5f2f --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts @@ -0,0 +1,130 @@ +import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat'; + +type DispatchTargetKind = 'local' | 'ssh' | 'device'; + +interface SessionNavSignature { + sessionId: string; + isTransient: boolean | undefined; + sessionKind: Session['sessionKind']; + parentSessionId: string | undefined; + parentToolCallId: string | undefined; + subagentType: string | undefined; + workspacePath: string | undefined; + mode: string | undefined; + needsUserAttention: Session['needsUserAttention']; + hasUnreadCompletion: Session['hasUnreadCompletion']; + latestTurnStatus: string | undefined; + title: string | undefined; + dispatchTargetKind: DispatchTargetKind; + dispatchConnectionId: string | undefined; + dispatchDeviceId: string | undefined; + dispatchWorkspacePath: string | undefined; + dispatchDisplayName: string | undefined; + dispatchJobState: Session['config']['dispatchJobState']; +} + +function readSessionNavSignature(session: Session): SessionNavSignature { + const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + const dispatchTarget = session.config.dispatchTarget; + + return { + sessionId: session.sessionId, + isTransient: session.isTransient, + sessionKind: session.sessionKind, + parentSessionId: session.parentSessionId, + parentToolCallId: session.parentToolCallId, + subagentType: session.subagentType, + workspacePath: session.workspacePath, + mode: session.mode, + needsUserAttention: session.needsUserAttention, + hasUnreadCompletion: session.hasUnreadCompletion, + latestTurnStatus: latestTurn?.status, + title: session.title, + dispatchTargetKind: dispatchTarget?.kind ?? 'local', + dispatchConnectionId: dispatchTarget?.kind === 'ssh' ? dispatchTarget.connectionId : undefined, + dispatchDeviceId: dispatchTarget?.kind === 'device' ? dispatchTarget.deviceId : undefined, + dispatchWorkspacePath: dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.workspacePath, + dispatchDisplayName: dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.displayName, + dispatchJobState: session.config.dispatchJobState, + }; +} + +function sameSessionNavSignature(previous: SessionNavSignature, session: Session): boolean { + const dispatchTarget = session.config.dispatchTarget; + const dispatchTargetKind = dispatchTarget?.kind ?? 'local'; + const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + + return previous.sessionId === session.sessionId + && previous.isTransient === session.isTransient + && previous.sessionKind === session.sessionKind + && previous.parentSessionId === session.parentSessionId + && previous.parentToolCallId === session.parentToolCallId + && previous.subagentType === session.subagentType + && previous.workspacePath === session.workspacePath + && previous.mode === session.mode + && previous.needsUserAttention === session.needsUserAttention + && previous.hasUnreadCompletion === session.hasUnreadCompletion + && previous.latestTurnStatus === latestTurn?.status + && previous.title === session.title + && previous.dispatchTargetKind === dispatchTargetKind + && previous.dispatchConnectionId === (dispatchTarget?.kind === 'ssh' ? dispatchTarget.connectionId : undefined) + && previous.dispatchDeviceId === (dispatchTarget?.kind === 'device' ? dispatchTarget.deviceId : undefined) + && previous.dispatchWorkspacePath === (dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.workspacePath) + && previous.dispatchDisplayName === (dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.displayName) + && previous.dispatchJobState === session.config.dispatchJobState; +} + +/** + * Selects only the fields rendered by the sessions navigation. + * + * The selector is called for every FlowChatStore notification. It keeps a + * primitive revision as its result, so stream updates do not rebuild a large + * joined string merely to discover that the navigation did not change. + */ +export function createSessionsNavSelector(): (state: FlowChatState) => number { + let activeSessionId: string | null | undefined; + let orderedSessionIds: string[] = []; + const signatures = new Map(); + let revision = 0; + + return (state: FlowChatState): number => { + let changed = activeSessionId !== state.activeSessionId + || orderedSessionIds.length !== state.sessions.size; + let orderChanged = orderedSessionIds.length !== state.sessions.size; + let index = 0; + + for (const session of state.sessions.values()) { + if (orderedSessionIds[index] !== session.sessionId) { + changed = true; + orderChanged = true; + } + + const previous = signatures.get(session.sessionId); + if (!previous || !sameSessionNavSignature(previous, session)) { + signatures.set(session.sessionId, readSessionNavSignature(session)); + changed = true; + } + index += 1; + } + + if (index !== orderedSessionIds.length) { + changed = true; + orderChanged = true; + } + + if (changed) { + revision += 1; + activeSessionId = state.activeSessionId; + if (orderChanged) { + orderedSessionIds = Array.from(state.sessions.keys()); + for (const sessionId of signatures.keys()) { + if (!state.sessions.has(sessionId)) { + signatures.delete(sessionId); + } + } + } + } + + return revision; + }; +} From e22f2fc849179969fa932dc4be2ab240ca9756f5 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:11:56 +0800 Subject: [PATCH 7/9] perf(flowchat): cache streaming follow geometry Reuse the latest content-end geometry during streaming RAFs and refresh it from explicit follow scheduling or viewport changes. This avoids forcing a scrollHeight read on every frame while preserving opening, navigation, rollback, and settling behavior. --- .../modern/useFlowChatFollowOutput.test.tsx | 64 ++++++++++++++++--- .../modern/useFlowChatFollowOutput.ts | 48 +++++++++----- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx index b6904c0848..7784570f41 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx @@ -290,6 +290,54 @@ describe('useFlowChatFollowOutput', () => { expect(controller?.isFollowingOutput).toBe(true); }); + it('reuses cached geometry in continuous streaming frames', () => { + let scrollHeightPx = 1500 + TAIL_SPACER; + let scrollHeightReads = 0; + Object.defineProperties(scroller, { + scrollHeight: { + configurable: true, + get: () => { + scrollHeightReads += 1; + return scrollHeightPx; + }, + }, + clientHeight: { configurable: true, value: VIEWPORT }, + scrollTop: { configurable: true, writable: true, value: 0 }, + }); + const scrollToContentEnd = vi.fn(() => { + scroller.scrollTop = 1000; + }); + + act(() => { + root.render( + { controller = next; }} + />, + ); + }); + const readsAfterInitialPlacement = scrollHeightReads; + + // Production streaming frames reuse the target cached by the last + // content-change refresh. + scrollHeightPx = 1800 + TAIL_SPACER; + runNextFrame(); + expect(scrollHeightReads).toBe(readsAfterInitialPlacement); + expect(scroller.scrollTop).toBe(1000); + + // The resize/content-change signal is the intentionally retained low-rate + // geometry refresh and updates the cached target once. + const beforeRefresh = scrollHeightReads; + act(() => controller?.scheduleFollowToLatest()); + expect(scrollHeightReads).toBeGreaterThan(beforeRefresh); + expect(scroller.scrollTop).toBe(1300); + const afterRefresh = scrollHeightReads; + runNextFrame(); + expect(scrollHeightReads).toBe(afterRefresh); + }); + it('tracks the content end exactly while the transcript is still opening', () => { // The virtualizer compensates a history prepend by writing scrollTop before the // prepended heights reach the DOM. While opening, the transcript is hidden @@ -551,6 +599,7 @@ describe('useFlowChatFollowOutput', () => { clientHeight: VIEWPORT, scrollTop: 1000, }); + act(() => controller?.scheduleFollowToLatest()); runNextFrame(); expect(scroller.scrollTop).toBe(1300); @@ -581,6 +630,7 @@ describe('useFlowChatFollowOutput', () => { clientHeight: VIEWPORT, scrollTop: 1000, }); + act(() => controller?.scheduleFollowToLatest()); runNextFrame(); expect(scroller.scrollTop).toBe(1000 - (300 - MAX_GAP)); @@ -608,6 +658,7 @@ describe('useFlowChatFollowOutput', () => { clientHeight: VIEWPORT, scrollTop: 1000, }); + act(() => controller?.scheduleFollowToLatest()); runNextFrame(); expect(scroller.scrollTop).toBe(200 + MAX_GAP); @@ -666,6 +717,7 @@ describe('useFlowChatFollowOutput', () => { clientHeight: VIEWPORT, scrollTop: 1000, }); + act(() => controller?.scheduleFollowToLatest()); runNextFrame(); scrollToContentEnd.mockClear(); @@ -924,6 +976,7 @@ describe('useFlowChatFollowOutput', () => { clientHeight: VIEWPORT, scrollTop: scroller.scrollTop, }); + act(() => controller?.scheduleFollowToLatest()); } it('spends a line of growth over the frames that were empty', () => { @@ -932,8 +985,6 @@ describe('useFlowChatFollowOutput', () => { followFromContentEnd(); growContentBy(TAIL_EASE_LINE_PX); - runNextFrame(); - expect(scroller.scrollTop).toBeCloseTo(1000 + TAIL_EASE_LINE_PX * TAIL_EASE_ALPHA, 5); expect(scroller.scrollTop - 1000).toBeLessThan(TAIL_EASE_LINE_PX); }); @@ -942,7 +993,7 @@ describe('useFlowChatFollowOutput', () => { followFromContentEnd(); growContentBy(TAIL_EASE_LINE_PX); - let previousPx = 1000; + let previousPx = scroller.scrollTop; for (let frame = 0; frame < 20; frame += 1) { runNextFrame(); // Every step is under a line: that is the bar the ease has to clear to @@ -964,8 +1015,6 @@ describe('useFlowChatFollowOutput', () => { followFromContentEnd(); growContentBy(TAIL_EASE_SNAP_ABOVE_PX + 100); - runNextFrame(); - expect(scroller.scrollTop).toBe(1000 + TAIL_EASE_SNAP_ABOVE_PX + 100); }); @@ -990,8 +1039,6 @@ describe('useFlowChatFollowOutput', () => { }); growContentBy(TAIL_EASE_LINE_PX); - runNextFrame(); - expect(scroller.scrollTop).toBe(1000 + TAIL_EASE_LINE_PX); }); @@ -1004,8 +1051,7 @@ describe('useFlowChatFollowOutput', () => { clientHeight: VIEWPORT, scrollTop: 1000, }); - - runNextFrame(); + act(() => controller?.scheduleFollowToLatest()); // Past the tolerated gap, so the hold rule gives ground — in one step. expect(scroller.scrollTop).toBe(200 + MAX_GAP); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 03919d3b12..b05bd6468e 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -253,6 +253,8 @@ export function useFlowChatFollowOutput({ const smoothScrollLastMoveAtMsRef = useRef(0); /** Where the animation started, so the trace can say how far it got. */ const smoothScrollFromPxRef = useRef(0); + /** Last content-end geometry read, reused by continuous follow frames. */ + const lastScrollHeightPxRef = useRef(0); /* * Deliberately *not* mirrored from `isFollowingOutput` here. @@ -302,13 +304,15 @@ export function useFlowChatFollowOutput({ isFollowingOutputRef.current && followFrameRef.current !== null ), []); - const readContentEndScrollTop = useCallback((scroller: HTMLElement) => ( - contentEndScrollTop({ - scrollHeight: scroller.scrollHeight, + const readContentEndScrollTop = useCallback((scroller: HTMLElement) => { + const scrollHeightPx = scroller.scrollHeight; + lastScrollHeightPxRef.current = scrollHeightPx; + return contentEndScrollTop({ + scrollHeight: scrollHeightPx, clientHeight: scroller.clientHeight, tailSpacerPx: getTailSpacerPx(), - }) - ), [getTailSpacerPx]); + }); + }, [getTailSpacerPx]); /** * The state the follow rule would hold for the current geometry, ignoring any @@ -563,14 +567,23 @@ export function useFlowChatFollowOutput({ }, [viewportId]); /** Move the viewport to whatever the follow state currently owns. */ - const applyFollowTarget = useCallback(() => { + const applyFollowTarget = useCallback((options: { + refreshContentEnd?: boolean; + } = {}) => { const scroller = scrollerRef.current; if (!scroller || isViewportSuspendedRef.current()) { return; } const remembered = followStateRef.current; - const desired = readContentEndScrollTop(scroller); + const opening = isOpeningViewport(); + // Streaming frames reuse geometry refreshed by ResizeObserver and explicit + // follow scheduling. This keeps the RAF loop from forcing a synchronous + // scrollHeight/style recalculation on every frame. + const refreshContentEnd = options.refreshContentEnd !== false || opening; + const desired = refreshContentEnd + ? readContentEndScrollTop(scroller) + : remembered.target; /* * While the transcript is opening it is still hidden, so nothing is gained * by remembering an earlier offset: drop the memory and track the content @@ -581,7 +594,7 @@ export function useFlowChatFollowOutput({ * allowance: blank below the live output is acceptable only because more * output is about to fill it. */ - const previous: TailFollowState = isOpeningViewport() + const previous: TailFollowState = opening ? { target: desired } : remembered; const next = nextTailFollowState(previous, { @@ -624,11 +637,11 @@ export function useFlowChatFollowOutput({ viewportId, onTarget, phase: followPhaseRef.current, - isOpening: isOpeningViewport(), + isOpening: opening, desiredPx: roundViewportPx(desired), targetPx: roundViewportPx(next.target), scrollTopPx: roundViewportPx(scroller.scrollTop), - scrollRangePx: roundViewportPx(scroller.scrollHeight), + scrollRangePx: roundViewportPx(lastScrollHeightPxRef.current), settleFrames: settleFramesRef.current, smoothYieldActive: smoothScrollUntilMsRef.current !== 0, }), @@ -691,8 +704,8 @@ export function useFlowChatFollowOutput({ * wait for travel nobody can see — and the reveal is watching for the * viewport to reach the content end. */ - const step = !isOpeningViewport() && shouldEaseTailFollow({ - scrollHeightPx: scroller.scrollHeight, + const step = !opening && shouldEaseTailFollow({ + scrollHeightPx: lastScrollHeightPxRef.current, clientHeightPx: scroller.clientHeight, }) ? nextEasedScrollTopPx(fromPx, next.target) @@ -730,7 +743,7 @@ export function useFlowChatFollowOutput({ // Native scroll delivery can lag a frame behind this write. Publish the // DOM readback through the existing virtualizer observer channel so range // selection catches up in the same task without a synchronous flush. - if (writeGranted && isOpeningViewport() && isFollowingOutputRef.current + if (writeGranted && opening && isFollowingOutputRef.current && followPhaseRef.current === 'following-tail' && isViewportActiveRef.current && !document.hidden && viewportOwner.currentOwner() === 'follow-output') { onOpeningOffsetRef.current?.(actualOffsetPx); @@ -788,11 +801,12 @@ export function useFlowChatFollowOutput({ }); return; } - if (!isStreamingRef.current) { + const streaming = isStreamingRef.current; + if (!streaming) { settleFramesRef.current -= 1; } - applyFollowTarget(); + applyFollowTarget({ refreshContentEnd: !streaming }); followFrameRef.current = requestAnimationFrame(runFollowFrame); }, [applyFollowTarget, isOpeningViewport, scrollerRef, viewportId]); @@ -1137,7 +1151,9 @@ export function useFlowChatFollowOutput({ return; } settleFramesRef.current = SETTLE_FRAMES; - applyFollowTarget(); + applyFollowTarget({ + refreshContentEnd: true, + }); startFollowFrame(); }, [applyFollowTarget, sampleTailWatch, startFollowFrame]); From e1456ca7261e27f585ff258487ed09ce67076085 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 27 Sep 2026 21:13:38 +0800 Subject: [PATCH 8/9] fix(flowchat): keep projected thinking cards collapsed Prevent primary-session expansion state from forcing thinking cards open inside embedded subagent panels. The panel can still expand a card explicitly through its own control. --- .../src/flow_chat/components/modern/ModelRoundItem.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 418616086a..08cfebf057 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -853,7 +853,9 @@ const FlowItemRenderer: React.FC = ({ onFileViewRequest, onTabOpen, sessionId, + activeSessionOverride, } = useFlowChatContext(); + const isSubagentSurface = activeSessionOverride?.sessionKind === 'subagent'; switch (item.type) { case 'text': @@ -879,7 +881,9 @@ const FlowItemRenderer: React.FC = ({ ); From 214a216cf77ebaa234fb800e5fbe17712089ab5e Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 28 Sep 2026 00:37:12 +0800 Subject: [PATCH 9/9] fix(flowchat): tolerate missing i18n metadata RuntimeStatusSlot only needs translated hint metadata when an unlabeled status is visible. Use a stable fallback cache owner and default language when lightweight test translation mocks omit the i18n object, preventing renderer crashes from cascading across FlowChat suites.\n\nUpdate the visibility contract test to expect projected subagent thinking cards to remain collapsed by default.\n\nVerified with the complete Web UI test suite: 900 files and 9965 tests passed. --- .../components/modern/FlowItemVisibility.test.tsx | 2 +- .../src/flow_chat/components/modern/RuntimeStatusSlot.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FlowItemVisibility.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowItemVisibility.test.tsx index 90e93025c4..c5c8bacf0e 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowItemVisibility.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowItemVisibility.test.tsx @@ -154,7 +154,7 @@ describe('visible FlowChat item composition', () => { it.each(['round', 'explore', 'subagent'])('%s uses the last visible item for reasoning disclosure', host => { act(() => root.render(renderHost(host, [thinking, blank, hidden]))); const panel = container.querySelector('[data-testid="chat-thinking-panel"]'); - expect(panel?.getAttribute('data-expanded')).toBe(host === 'explore' ? 'false' : 'true'); + expect(panel?.getAttribute('data-expanded')).toBe(host === 'round' ? 'true' : 'false'); expect(panel?.hasAttribute('data-thinking-attachment')).toBe(false); }); diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx index 04b8614ccc..aad3c262bd 100644 --- a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx @@ -6,6 +6,7 @@ import { submittedMessageStatusDelay } from '../../services/submittedMessagePres import './RuntimeStatusSlot.scss'; const EMPTY_HINTS: readonly string[] = []; +const FALLBACK_I18N_CACHE_OWNER = {}; const translatedHintsCache = new WeakMap>(); // Status labels supplied by the runtime do not need the generated hint list; @@ -56,12 +57,13 @@ export const RuntimeStatusSlot: React.FC = ({ )); const { t, i18n, ready } = useTranslation('flow-chat/processing-hints'); const needsGeneratedHint = Boolean(status && !status.label); - const language = i18n.resolvedLanguage ?? i18n.language ?? 'default'; + const language = i18n?.resolvedLanguage ?? i18n?.language ?? 'default'; + const cacheOwner = i18n ?? FALLBACK_I18N_CACHE_OWNER; const hints = React.useMemo( () => needsGeneratedHint - ? getTranslatedHints(i18n, t, language, ready) + ? getTranslatedHints(cacheOwner, t, language, ready) : EMPTY_HINTS, - [i18n, language, needsGeneratedHint, ready, t], + [cacheOwner, language, needsGeneratedHint, ready, t], ); const hint = status ? status.label