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/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; + }; +} 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 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/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 = ({ ); 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..aad3c262bd 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,36 @@ import { useRuntimeStatusStore } from '../../store/runtimeStatusStore'; import { submittedMessageStatusDelay } from '../../services/submittedMessagePresentation'; 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; +// 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 +55,16 @@ 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 cacheOwner = i18n ?? FALLBACK_I18N_CACHE_OWNER; + const hints = React.useMemo( + () => needsGeneratedHint + ? getTranslatedHints(cacheOwner, t, language, ready) + : EMPTY_HINTS, + [cacheOwner, language, needsGeneratedHint, ready, t], + ); const hint = status ? status.label || hints[stableHintIndex(`${status.turnId}:${status.roundId}`, hints.length)] 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]); 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; 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; 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(); }