From 99c2d22a63025c510fd8ecae1f7393a98fa2ee35 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 10 Sep 2026 14:29:25 +0800 Subject: [PATCH 1/2] perf(flow-chat): reduce subagent session panel rendering overhead - Virtualize child-session transcripts using the shared virtualizer - Scope subscriptions to the child session and required parent fields - Unmount hidden panel content while preserving reading and expand state - Restore scroll anchors and tail-follow behavior when reopening tabs - Avoid reloading persisted review state on every tab switch - Add regression tests for windowing, subscriptions, and panel lifecycle --- .../components/panels/base/FlexiblePanel.tsx | 1 + .../editor-area/EditorGroup.tsx | 4 +- .../src/app/scenes/session/SessionScene.tsx | 2 +- .../src/flow_chat/components/btw/AGENTS.md | 26 +++ .../BtwSessionPanel.review-action.test.tsx | 67 ++++++- .../components/btw/BtwSessionPanel.tsx | 180 ++++++++++------- .../btw/BtwVirtualSessionList.test.tsx | 153 ++++++++++++++ .../components/btw/BtwVirtualSessionList.tsx | 98 +++++++++ .../components/btw/btwPanelViewState.ts | 12 ++ .../btw/useBtwPanelViewport.test.tsx | 121 +++++++++++ .../components/btw/useBtwPanelViewport.ts | 88 ++++++++ .../btw/useBtwSessionState.test.tsx | 188 ++++++++++++++++++ .../components/btw/useBtwSessionState.ts | 70 +++++++ .../modern/FLOWCHAT_VIRTUALIZATION.md | 10 + .../components/modern/useExploreGroupState.ts | 3 +- 15 files changed, 951 insertions(+), 72 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/btw/AGENTS.md create mode 100644 src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx create mode 100644 src/web-ui/src/flow_chat/components/btw/btwPanelViewState.ts create mode 100644 src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.ts create mode 100644 src/web-ui/src/flow_chat/components/btw/useBtwSessionState.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/btw/useBtwSessionState.ts diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index e4872c0c94..f30a8e9937 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -821,6 +821,7 @@ const FlexiblePanel: React.FC = memo(({ return ( {t('flexiblePanel.loading.taskDetail')}}> = ({ // Tabs to render (active + cached). Hidden terminal tabs stay mounted so // reopening a terminal reuses the xterm buffer instead of replaying history. + // Child-session tabs retain only their lightweight wrapper; that wrapper + // unmounts its transcript while inactive and owns reading state until close. const tabsToRender = useMemo(() => { const result = group.tabs.filter(t => - (!t.isHidden && (t.id === group.activeTabId || cachedTabsRef.current.has(t.id))) || + (!t.isHidden && (t.content.type === 'btw-session' || t.id === group.activeTabId || cachedTabsRef.current.has(t.id))) || (t.isHidden && isKeepAliveTerminalTab(t)) ); return result; diff --git a/src/web-ui/src/app/scenes/session/SessionScene.tsx b/src/web-ui/src/app/scenes/session/SessionScene.tsx index 93a93dfa54..816ac0f43a 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.tsx +++ b/src/web-ui/src/app/scenes/session/SessionScene.tsx @@ -576,7 +576,7 @@ const SessionScene: React.FC = ({ diff --git a/src/web-ui/src/flow_chat/components/btw/AGENTS.md b/src/web-ui/src/flow_chat/components/btw/AGENTS.md new file mode 100644 index 0000000000..c8a124ede6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/AGENTS.md @@ -0,0 +1,26 @@ +# Embedded session panels + +The transcript uses `BtwVirtualSessionList` and the shared FlowChat virtualizer. +Keep primary-session selection, paging, and input-footer policy out of this panel. +`useBtwSessionState` selects the child Session, parent title/workspace/connection +metadata, and only for `review-check`, the linked Task's derived outcome. Ordinary +panes must not read the parent's transcript. Keep snapshots stable for unrelated +store updates and parent streaming, and selection synchronous on ID/view changes. + +Inactive tabs retain only the `BtwSessionPanel` wrapper and its lightweight view +state. The content, session subscriptions, observers, and scroll callbacks unmount +immediately, including when the auxiliary pane collapses. Keep background task +execution and permission mailboxes outside this lifetime. Preserve exploration +expansion and stable-item reading anchors; following readers return to the latest +output. A successfully restored review location must not reload stale persisted +UI state on each tab switch. Closing a tab releases its wrapper and view state. + +For transcript windowing, scrolling, or review integration changes, run: + +```bash +pnpm --dir src/web-ui run test:run src/flow_chat/components/btw/useBtwSessionState.test.tsx src/flow_chat/components/btw/useBtwPanelViewport.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts +``` + +Native WebView2 checks remain manual: long subagent transcripts, streaming tail +follow, upward scrolling, tool expansion, sidebar resizing, and cached-tab return. +Report memory measurements and remote scenarios as unverified until exercised. diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx index 6f05c6da05..c59418e2f1 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx @@ -25,6 +25,7 @@ const panelMocks = vi.hoisted(() => ({ respondPermissionBatch: vi.fn(() => Promise.resolve()), virtualItems: [] as unknown[], flowChatSubscriber: null as ((state: FlowChatState) => void) | null, + flowChatSelectorSubscribers: new Set<(state: FlowChatState) => void>(), })); let flowChatState: FlowChatState; @@ -56,6 +57,17 @@ vi.mock('../modern/VirtualItemRenderer', async () => { }; }); +// Action-bar tests exercise context and review behavior independently of DOM +// viewport geometry. The real windowing contract has its own integration test. +vi.mock('./BtwVirtualSessionList', async () => { + const { VirtualItemRenderer } = await import('../modern/VirtualItemRenderer'); + return { + BtwVirtualSessionList: ({ items }: { items: import('../../store/modernFlowChatStore').VirtualItem[] }) => ( + <>{items.map((item, index) => )} + ), + }; +}); + vi.mock('../modern/useExploreGroupState', () => ({ useExploreGroupState: () => ({ exploreGroupStates: {}, @@ -185,7 +197,20 @@ vi.mock('../../store/FlowChatStore', () => ({ }, flowChatStore: { getState: () => flowChatState, - subscribeSelector: () => () => {}, + subscribeSelector: (select: (state: FlowChatState) => T, notify: (selected: T) => void) => { + let previous = select(flowChatState); + const listener = (state: FlowChatState) => { + const next = select(state); + if (Object.is(previous, next)) return; + previous = next; + notify(next); + }; + panelMocks.flowChatSelectorSubscribers.add(listener); + panelMocks.flowChatSubscriber = state => { + panelMocks.flowChatSelectorSubscribers.forEach(subscriber => subscriber(state)); + }; + return () => { panelMocks.flowChatSelectorSubscribers.delete(listener); }; + }, subscribe: (listener: (state: FlowChatState) => void) => { panelMocks.flowChatSubscriber = listener; return () => { @@ -580,6 +605,46 @@ describe('BtwSessionPanel review action bar integration', () => { vi.useRealTimers(); }); + it('unmounts hidden content and subscriptions without cancelling the task', async () => { + const render = async (isActive: boolean) => { + await act(async () => { + root.render(); + }); + }; + await render(true); + expect(container.querySelector('.btw-session-panel')).not.toBeNull(); + expect(panelMocks.flowChatSelectorSubscribers.size).toBeGreaterThan(0); + await render(false); + expect(container.childElementCount).toBe(0); + expect(panelMocks.flowChatSelectorSubscribers.size).toBe(0); + const child = flowChatState.sessions.get('deep-review-child')!; + flowChatState = { + ...flowChatState, + sessions: new Map(flowChatState.sessions).set(child.sessionId, { ...child, title: 'Updated while hidden' }), + }; + await render(true); + expect(container.textContent).toContain('Updated while hidden'); + expect(panelMocks.flowChatSelectorSubscribers.size).toBeGreaterThan(0); + expect(panelMocks.cancelSession).not.toHaveBeenCalled(); + expect(panelMocks.cancelSessionTask).not.toHaveBeenCalled(); + }); + + it('does not restore stale persisted review state again after tab switching', async () => { + vi.mocked(loadPersistedReviewState).mockClear(); + vi.mocked(loadPersistedReviewState).mockResolvedValue(null); + await act(async () => { + root.render(); + }); + expect(loadPersistedReviewState).toHaveBeenCalledTimes(1); + await act(async () => { + root.render(); + }); + await act(async () => { + root.render(); + }); + expect(loadPersistedReviewState).toHaveBeenCalledTimes(1); + }); + it('cancels a running side question with Escape inside its panel', async () => { flowChatState = { ...flowChatState, diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index f238a745fa..ebaeabd45d 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -1,10 +1,14 @@ import { OverflowText, Button, IconButton } from '@openbitfun/ui'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; +import {getActiveSurfaceId, onSurfaceActivated} from '@/infrastructure/peer-device/deviceSurface'; +import {createBtwPanelViewState, type BtwPanelViewState} from './btwPanelViewState'; import {useTranslation} from 'react-i18next'; import path from 'path-browserify'; import { CornerUpLeft, Loader2, Square } from 'lucide-react'; import {FlowChatContext, FlowChatVolatileContext} from '../modern/FlowChatContext'; -import {VirtualItemRenderer} from '../modern/VirtualItemRenderer'; +import {BtwVirtualSessionList} from './BtwVirtualSessionList'; +import {useBtwSessionState} from './useBtwSessionState'; +import {useFlowChatViewportOwner} from '../modern/useFlowChatViewportOwner'; import {RuntimeStatusSlot} from '../modern/RuntimeStatusSlot'; import {pendingPermissionToolCallIdsForSession} from '../modern/permissionRequestRouting'; import {usePermissionRequests} from '../modern/usePermissionRequests'; @@ -12,7 +16,7 @@ import {useExploreGroupState} from '../modern/useExploreGroupState'; import {ChatInputApprovalBand} from '../ChatInputApprovalBand'; import {ScrollToBottomButton} from '@/flow_chat'; import {flowChatStore} from '../../store/FlowChatStore'; -import type {DialogTurn, FlowChatState, Session} from '../../types/flow-chat'; +import type {DialogTurn, Session} from '../../types/flow-chat'; import {sessionToVirtualItems} from '../../store/modernFlowChatStore'; import {FLOWCHAT_FOCUS_ITEM_EVENT, type FlowChatFocusItemRequest} from '../../events/flowchatNavigation'; import {fileTabManager} from '@/shared/services/FileTabManager'; @@ -31,7 +35,6 @@ import { type ReviewDetailContentState, type ReviewDetailExecutionState, } from '../../utils/reviewDetailState'; -import {findReviewTaskOutcome} from '../../utils/reviewTaskOutcome'; import { loadBtwSessionHistory, type BtwSessionViewKind, @@ -90,6 +93,7 @@ function findReviewChildByRequestId( import './BtwSessionPanel.scss'; export interface BtwSessionPanelProps { + isActive?: boolean; childSessionId?: string; parentSessionId?: string; workspacePath?: string; @@ -97,7 +101,7 @@ export interface BtwSessionPanelProps { displayTitle?: string; } -const resolveSessionTitle = (session?: Session | null, fallback = 'Side thread') => +const resolveSessionTitle = (session?: Pick | null, fallback = 'Side thread') => session?.title?.trim() || fallback; const log = createLogger('BtwSessionPanel'); const REVIEW_ACTION_BOTTOM_BLANK_SPACE_PX = 96; @@ -139,22 +143,42 @@ const isSameReviewResult = (left: unknown, right: unknown): boolean => { } }; -export const BtwSessionPanel: React.FC = ({ +export const BtwSessionPanel: React.FC = (props) => { + const surfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + const viewState = useMemo(createBtwPanelViewState, [surfaceId, props.childSessionId, props.viewKind]); + return props.isActive === false ? null : ( + + ); +}; + +const BtwSessionPanelContent: React.FC = ({ childSessionId, parentSessionId, workspacePath, viewKind, displayTitle, + viewState, }) => { const { t } = useTranslation('flow-chat'); - const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + const { childSession, parentMetadata, reviewTaskOutcome } = useBtwSessionState( + childSessionId, parentSessionId, viewKind === 'review-check', + ); const [stoppingReview, setStoppingReview] = useState(false); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [showScrollToBottom, setShowScrollToBottom] = useState(() => { + viewState.restoring = !viewState.followTail && viewState.anchor !== null; + return !viewState.followTail; + }); const scrollContainerRef = useRef(null); + const listHeaderRef = useRef(null); + const viewportOwner = useFlowChatViewportOwner(scrollContainerRef); useSessionCompletionReceipt(childSessionId ?? null, scrollContainerRef); const actionBarRef = useRef(null); const [actionBarHeight, setActionBarHeight] = useState(0); - const shouldAutoScrollRef = useRef(true); + const shouldAutoScrollRef = useRef(viewState.followTail); // Embedded child sessions live outside the primary composer. Give direct // child requests an actionable surface here, while delegated requests stay @@ -171,12 +195,6 @@ export const BtwSessionPanel: React.FC = ({ [permissionRequests, childSessionId], ); - useEffect(() => { - return flowChatStore.subscribe(setFlowChatState); - }, []); - - const childSession = childSessionId ? flowChatState.sessions.get(childSessionId) : undefined; - const parentSession = parentSessionId ? flowChatState.sessions.get(parentSessionId) : undefined; const childSessionRef = useRef(childSession); childSessionRef.current = childSession; const childRelationship = resolveSessionRelationship(childSession); @@ -225,11 +243,11 @@ export const BtwSessionPanel: React.FC = ({ onExpandGroup, onExpandAllInTurn, onCollapseGroup, - } = useExploreGroupState(virtualItems); + } = useExploreGroupState(virtualItems, viewState.exploreGroupStates); + useEffect(() => { + viewState.exploreGroupStates = exploreGroupStates; + }, [exploreGroupStates, viewState]); const isReviewDetail = viewKind === 'review-check' || childKind === 'review' || childKind === 'deep_review'; - const reviewTaskOutcome = viewKind === 'review-check' - ? findReviewTaskOutcome(parentSession, childSession?.parentToolCallId) - : null; const reviewDetailProjection = isReviewDetail ? deriveReviewDetailProjection(childSession, virtualItems.length > 0, reviewTaskOutcome) : null; @@ -259,7 +277,7 @@ export const BtwSessionPanel: React.FC = ({ const loadChildHistory = useCallback(async () => { if (!childSessionId || !childSession) return; - const path = workspacePath ?? childSession.workspacePath ?? parentSession?.workspacePath; + const path = workspacePath ?? childSession.workspacePath ?? parentMetadata?.workspacePath; if (!path) return; await loadBtwSessionHistory({ @@ -267,12 +285,12 @@ export const BtwSessionPanel: React.FC = ({ ...(!childSession.workspacePath ? { workspacePath: path, - remoteConnectionId: childSession.remoteConnectionId || parentSession?.remoteConnectionId, - remoteSshHost: childSession.remoteSshHost || parentSession?.remoteSshHost, + remoteConnectionId: childSession.remoteConnectionId || parentMetadata?.remoteConnectionId, + remoteSshHost: childSession.remoteSshHost || parentMetadata?.remoteSshHost, } : {}), }); - }, [childSessionId, childSession, parentSession, workspacePath]); + }, [childSessionId, childSession, parentMetadata, workspacePath]); useEffect(() => { if (!childSession?.isHistorical || childSession.historyState !== 'metadata-only') return; @@ -284,10 +302,11 @@ export const BtwSessionPanel: React.FC = ({ if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; setShowScrollToBottom(distanceFromBottom > 120); - if (distanceFromBottom < 80) { + if (distanceFromBottom < 80 && !viewState.restoring) { shouldAutoScrollRef.current = true; } - }, []); + viewState.followTail = shouldAutoScrollRef.current; + }, [viewState]); useEffect(() => { const container = scrollContainerRef.current; @@ -295,6 +314,7 @@ export const BtwSessionPanel: React.FC = ({ const handleWheel = (e: WheelEvent) => { if (e.deltaY < 0) { shouldAutoScrollRef.current = false; + viewState.followTail = false; } else if (e.deltaY > 0) { const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; @@ -311,27 +331,32 @@ export const BtwSessionPanel: React.FC = ({ container.removeEventListener('wheel', handleWheel); container.removeEventListener('scroll', updateScrollAffordance); }; - }, [updateScrollAffordance]); + }, [updateScrollAffordance, viewState]); useEffect(() => { const container = scrollContainerRef.current; if (!container || !shouldAutoScrollRef.current) return; - requestAnimationFrame(() => { - container.scrollTop = container.scrollHeight; + const frame = requestAnimationFrame(() => { + if (!shouldAutoScrollRef.current) return; + viewportOwner.write({ owner: 'follow-output', topPx: container.scrollHeight, holdForMs: 0 }); setShowScrollToBottom(false); }); - }, [virtualItems]); + return () => cancelAnimationFrame(frame); + }, [virtualItems, viewportOwner]); const handleScrollToBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; shouldAutoScrollRef.current = true; - container.scrollTo({ - top: container.scrollHeight, + viewState.followTail = true; + viewState.restoring = false; + viewportOwner.write({ + owner: 'one-shot-navigation', + topPx: container.scrollHeight, behavior: getMotionAwareScrollBehavior('smooth'), }); setShowScrollToBottom(false); - }, []); + }, [viewportOwner, viewState]); const handleFileViewRequest = useCallback(( filePath: string, @@ -464,7 +489,7 @@ export const BtwSessionPanel: React.FC = ({ : showMinimizedIndicator ? REVIEW_ACTION_BOTTOM_BLANK_SPACE_PX : 0; - const parentLabel = resolveSessionTitle(parentSession, t('btw.parent')); + const parentLabel = resolveSessionTitle(parentMetadata, t('btw.parent')); const backTooltip = btwOrigin?.parentTurnIndex ? t('flowChatHeader.btwBackTooltipWithTurn', { title: parentLabel, @@ -783,6 +808,11 @@ export const BtwSessionPanel: React.FC = ({ // Restore persisted review action state once for each stable session location. useEffect(() => { if (!isReviewSession || !childSessionId || !persistedReviewWorkspacePath) return; + const locationKey = JSON.stringify([ + childSessionId, persistedReviewWorkspacePath, + persistedReviewRemoteConnectionId, persistedReviewRemoteSshHost, + ]); + if (viewState.restoredReviewLocation === locationKey) return; const store = useReviewActionBarStore.getState(); const currentActionState = store.getSessionState(childSessionId); @@ -804,12 +834,14 @@ export const BtwSessionPanel: React.FC = ({ persistedReviewRemoteSshHost, ).then((persisted: ReviewActionPersistedState | null) => { const latestChildSession = childSessionRef.current; - if (cancelled || !persisted || !latestChildSession) return; + if (cancelled || !latestChildSession) return; if ( sessionProjectWorkspacePath(latestChildSession) !== persistedReviewWorkspacePath || latestChildSession.remoteConnectionId !== persistedReviewRemoteConnectionId || latestChildSession.remoteSshHost !== persistedReviewRemoteSshHost ) return; + viewState.restoredReviewLocation = locationKey; + if (!persisted) return; const latestReviewData = findLatestCodeReviewResult(latestChildSession) as DeepReviewActionData | null; const reviewMode: ReviewActionMode = isDeepReview ? 'deep' : 'standard'; @@ -923,6 +955,7 @@ export const BtwSessionPanel: React.FC = ({ persistedReviewWorkspacePath, persistedReviewRemoteConnectionId, persistedReviewRemoteSshHost, + viewState, ]); // Observe action bar height to adjust body padding dynamically @@ -1080,7 +1113,7 @@ export const BtwSessionPanel: React.FC = ({
{childOriginLabel} - {resolveSessionTitle(parentSession, t('btw.parent'))} + {resolveSessionTitle(parentMetadata, t('btw.parent'))}
)} {(viewKind === 'review-check' || childKind === 'review' || childKind === 'deep_review') && ( @@ -1141,45 +1174,56 @@ export const BtwSessionPanel: React.FC = ({ className="btw-session-panel__body" data-openbitfun-component="btw-session-panel" data-openbitfun-part="body" - style={reviewActionBottomPadding > 0 ? { paddingBottom: `${reviewActionBottomPadding}px` } : undefined} + style={{ + paddingTop: 0, + overflowAnchor: 'none', + ...(reviewActionBottomPadding > 0 ? { paddingBottom: reviewActionBottomPadding } : {}), + }} > - {isReviewDetail && reviewDetailNotices.length > 0 && ( -
0 ? ' btw-session-panel__empty-state--with-content' : ''}`} - data-openbitfun-component="btw-session-panel" - data-openbitfun-part="empty" - role={reviewDetailNotices.some(({ state }) => - state === 'load-failed' || state === 'failed' || state === 'timed-out') - ? 'alert' - : 'status'} - aria-live="polite" - > - {reviewDetailNotices.map(({ state, key }) => ( - {t(key, { label: childBadgeLabel })} - ))} - {canRetryReviewDetailLoad && ( - - )} -
- )} + {/* Include the top inset and notices in the virtualizer's measured offset. */} +
+ {isReviewDetail && reviewDetailNotices.length > 0 && ( +
0 ? ' btw-session-panel__empty-state--with-content' : ''}`} + data-openbitfun-component="btw-session-panel" + data-openbitfun-part="empty" + role={reviewDetailNotices.some(({ state }) => + state === 'load-failed' || state === 'failed' || state === 'timed-out') + ? 'alert' + : 'status'} + aria-live="polite" + > + {reviewDetailNotices.map(({ state, key }) => ( + {t(key, { label: childBadgeLabel })} + ))} + {canRetryReviewDetailLoad && ( + + )} +
+ )} +
{virtualItems.length === 0 ? ( !isReviewDetail || reviewDetailNotices.length === 0 ? (
{t('session.empty')}
) : null ) : ( - virtualItems.map((item, index) => ( - - )) + )} ({ + VirtualItemRenderer: ({ index, measureRef }: { index: number; measureRef: React.Ref }) => ( +
{index}
+ ), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('BtwVirtualSessionList', () => { + let host: HTMLDivElement; + let scroller: HTMLDivElement; + let header: HTMLDivElement; + let root: Root; + let frames: Map; + let nextFrame: number; + let followRef: { current: boolean }; + let owner: FlowChatViewportOwnerApi; + let resizeObservers: Array<{ targets: Set; callback: ResizeObserverCallback }>; + const items = Array.from({ length: 1000 }, (_, index) => ({ + type: 'image-analyzing', turnId: `turn-${index}`, + } as VirtualItem)); + + function render() { + act(() => root.render( + , + )); + } + + beforeEach(() => { + frames = new Map(); + resizeObservers = []; + nextFrame = 0; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.stubGlobal('ResizeObserver', class { + targets = new Set(); + constructor(callback: ResizeObserverCallback) { + resizeObservers.push({ targets: this.targets, callback }); + } + observe(target: Element) { this.targets.add(target); } + unobserve(target: Element) { this.targets.delete(target); } + disconnect() { this.targets.clear(); } + }); + host = document.createElement('div'); + scroller = document.createElement('div'); + header = document.createElement('div'); + document.body.appendChild(scroller); + scroller.append(header, host); + for (const [key, value] of Object.entries({ + offsetHeight: 500, offsetWidth: 500, clientHeight: 500, clientWidth: 500, scrollHeight: 100000, + })) Object.defineProperty(scroller, key, { configurable: true, value }); + // Real row measurement must be positive in jsdom, just as in WebView2. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(100); + root = createRoot(host); + followRef = { current: false }; + owner = { + write: vi.fn(() => true), shift: vi.fn(() => true), + claim: vi.fn(() => true), release: vi.fn(), canShift: () => true, currentOwner: () => null, + }; + }); + + afterEach(() => { + act(() => root.unmount()); + scroller.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('mounts a bounded window and reaches distant history by scrolling', () => { + render(); + expect(host.querySelectorAll('[data-virtual-index]').length).toBeGreaterThan(0); + expect(host.querySelectorAll('[data-virtual-index]').length).toBeLessThan(40); + const initialIndexes = [...host.querySelectorAll('[data-virtual-index]')] + .map(node => Number(node.getAttribute('data-virtual-index'))); + expect(host.querySelector('[data-virtual-index="500"]')).toBeNull(); + act(() => { + scroller.scrollTop = 20000; + scroller.dispatchEvent(new Event('scroll')); + }); + const indexes = [...host.querySelectorAll('[data-virtual-index]')] + .map(node => Number(node.getAttribute('data-virtual-index'))); + expect(Math.min(...indexes)).toBeGreaterThan(Math.max(...initialIndexes)); + expect(indexes.length).toBeLessThan(40); + }); + + it('rechecks follow intent before a queued frame and cancels on unmount', () => { + followRef.current = true; + render(); + vi.mocked(owner.write).mockClear(); + followRef.current = false; + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach(callback => callback(16)); + }); + expect(owner.write).not.toHaveBeenCalled(); + followRef.current = true; + render(); + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach(callback => callback(32)); + }); + expect(owner.write).toHaveBeenCalledWith({ owner: 'follow-output', topPx: 100000, holdForMs: 0 }); + render(); + act(() => root.render(null)); + expect(frames.size).toBe(0); + }); + + it('follows the measured scroll range when mounted content changes height', () => { + followRef.current = true; + render(); + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach(callback => callback(16)); + }); + vi.mocked(owner.write).mockClear(); + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 120000 }); + act(() => { + for (const observer of resizeObservers) { + if (observer.targets.has(host.firstElementChild!)) { + observer.callback([], {} as ResizeObserver); + } + } + const pending = [...frames.values()]; + frames.clear(); + pending.forEach(callback => callback(32)); + }); + expect(owner.write).toHaveBeenCalledWith({ + owner: 'follow-output', topPx: 120000, holdForMs: 0, + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx new file mode 100644 index 0000000000..274c4de4ef --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { VirtualItemRenderer } from '../modern/VirtualItemRenderer'; +import { useFlowChatVirtualizer } from '../modern/useFlowChatVirtualizer'; +import type { FlowChatViewportOwnerApi } from '../modern/useFlowChatViewportOwner'; +import { getVirtualItemStableKey } from '../modern/virtualItemIdentity'; +import { estimateVirtualMessageItemHeightWithContext } from '../modern/virtualMessageListLayout'; +import type { BtwPanelViewState } from './btwPanelViewState'; +import { useBtwPanelViewport } from './useBtwPanelViewport'; + +interface BtwVirtualSessionListProps { + items: VirtualItem[]; + scrollerRef: RefObject; + headerRef: RefObject; + followRef: RefObject; + viewportOwner: FlowChatViewportOwnerApi; + exploreGroupStates: Map; + isHistorical: boolean; + viewState?: BtwPanelViewState; +} + +/** The embedded transcript shares row placement, not the primary session shell. */ +export function BtwVirtualSessionList({ + items, scrollerRef, headerRef, followRef, viewportOwner, + exploreGroupStates, isHistorical, viewState, +}: BtwVirtualSessionListProps) { + const windowRef = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const scroller = scrollerRef.current; + if (!scroller) return; + const updateWidth = () => setWidth(scroller.clientWidth); + const observer = new ResizeObserver(updateWidth); + observer.observe(scroller); + updateWidth(); + return () => observer.disconnect(); + }, [scrollerRef]); + const virtualizer = useFlowChatVirtualizer({ + items, + scrollerRef, + headerRef, + getItemKey: getVirtualItemStableKey, + estimateItemHeightPx: estimateVirtualMessageItemHeightWithContext, + estimateContext: { + availableWidthPx: width || undefined, + exploreGroupStates, + isHistorical, + }, + estimateContextRevision: `${width}|${isHistorical}|${[...exploreGroupStates] + .map(([id, expanded]) => `${id}:${expanded}`).join(',')}`, + scrollPaddingStartPx: 0, + writeViewport: viewportOwner.write, + shiftViewport: viewportOwner.shift, + }); + useBtwPanelViewport(viewState, items, scrollerRef, windowRef, virtualizer, viewportOwner); + + // Estimated offscreen rows change the scroll range as they mount. Follow + // those measurements as well as streamed data, but recheck user intent in + // the frame itself so a queued update cannot undo an upward gesture. + useEffect(() => { + const scroller = scrollerRef.current; + const window = windowRef.current; + if (!scroller || !window) return; + let frame: number | undefined; + const follow = () => { + if (frame !== undefined) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = undefined; + if (!followRef.current || scroller.clientHeight === 0) return; + viewportOwner.write({ owner: 'follow-output', topPx: scroller.scrollHeight, holdForMs: 0 }); + }); + }; + const observer = new ResizeObserver(follow); + observer.observe(window); + observer.observe(scroller); + follow(); + return () => { + observer.disconnect(); + if (frame !== undefined) cancelAnimationFrame(frame); + }; + }, [items, virtualizer.rows, scrollerRef, followRef, viewportOwner]); + + return ( +
+ {virtualizer.rows.map(row => ( + + ))} +
+ ); +} diff --git a/src/web-ui/src/flow_chat/components/btw/btwPanelViewState.ts b/src/web-ui/src/flow_chat/components/btw/btwPanelViewState.ts new file mode 100644 index 0000000000..b42ecc862a --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/btwPanelViewState.ts @@ -0,0 +1,12 @@ +/** Tab-owned UI state only: never retain sessions, virtual items, or DOM nodes. */ +export interface BtwPanelViewState { + followTail: boolean; + anchor: { key: string; offsetPx: number } | null; + exploreGroupStates: Map; + restoredReviewLocation?: string; + restoring: boolean; +} + +export function createBtwPanelViewState(): BtwPanelViewState { + return { followTail: true, anchor: null, exploreGroupStates: new Map(), restoring: false }; +} diff --git a/src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.test.tsx b/src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.test.tsx new file mode 100644 index 0000000000..70eddb5699 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import { act, useEffect, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useBtwPanelViewport } from './useBtwPanelViewport'; +import { createBtwPanelViewState } from './btwPanelViewState'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import type { FlowChatVirtualizer } from '../modern/useFlowChatVirtualizer'; +import type { FlowChatViewportOwnerApi } from '../modern/useFlowChatViewportOwner'; +import { useExploreGroupState } from '../modern/useExploreGroupState'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('embedded panel reading position', () => { + let root: Root; + let host: HTMLDivElement; + let scroller: HTMLDivElement; + let window: HTMLDivElement; + let state: ReturnType; + let owner: FlowChatViewportOwnerApi; + let virtualizer: FlowChatVirtualizer; + const item = (id: string) => ({ type: 'image-analyzing', turnId: id } as VirtualItem); + function Harness({ items = [item('saved')] }: { items?: VirtualItem[] }) { + useBtwPanelViewport(state, items, useRef(scroller), useRef(window), virtualizer, owner); + return null; + } + function row() { + const element = document.createElement('div'); + element.setAttribute('data-virtual-item-key', 'image-analyzing:saved'); + element.getBoundingClientRect = () => ({ top: 100, bottom: 400, height: 300 } as DOMRect); + window.appendChild(element); + return element; + } + beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + host = document.createElement('div'); + scroller = document.createElement('div'); + window = document.createElement('div'); + scroller.appendChild(window); + document.body.append(host, scroller); + Object.defineProperty(scroller, 'clientHeight', { value: 500 }); + scroller.getBoundingClientRect = () => ({ top: 150 } as DOMRect); + root = createRoot(host); + state = createBtwPanelViewState(); + state.followTail = false; + owner = { write: vi.fn(() => true) } as unknown as FlowChatViewportOwnerApi; + virtualizer = { getItemBounds: vi.fn(() => ({ startPx: 600, endPx: 900 })) } as unknown as FlowChatVirtualizer; + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); scroller.remove(); + vi.unstubAllGlobals(); + }); + + it('captures a stable item and intra-row offset, then restores after history grows', () => { + row(); + act(() => root.render()); + act(() => scroller.dispatchEvent(new Event('scroll'))); + expect(state.anchor).toEqual({ key: 'image-analyzing:saved', offsetPx: 50 }); + act(() => root.render(null)); + window.replaceChildren(); + state.restoring = true; + act(() => root.render()); + expect(virtualizer.getItemBounds).toHaveBeenLastCalledWith(1); + expect(owner.write).toHaveBeenLastCalledWith({ owner: 'one-shot-navigation', topPx: 650, holdForMs: 0 }); + scroller.scrollTop = 650; + row(); + act(() => root.render()); + expect(owner.write).toHaveBeenLastCalledWith({ owner: 'one-shot-navigation', topPx: 650, holdForMs: 0 }); + expect(state.restoring).toBe(false); + }); + + it('does not restore an old anchor when following the tail, and cleans up on unmount', () => { + state.followTail = true; + state.anchor = { key: 'image-analyzing:saved', offsetPx: 50 }; + row(); + act(() => root.render()); + expect(owner.write).not.toHaveBeenCalled(); + act(() => scroller.dispatchEvent(new Event('scroll'))); + expect(state.anchor).toBeNull(); + act(() => root.render(null)); + state.followTail = false; + act(() => scroller.dispatchEvent(new Event('scroll'))); + expect(state.anchor).toBeNull(); + }); + + it('abandons restoration when the user takes over or the saved item is absent', () => { + state.anchor = { key: 'image-analyzing:saved', offsetPx: 50 }; + state.restoring = true; + act(() => root.render()); + act(() => scroller.dispatchEvent(new Event('wheel'))); + vi.mocked(owner.write).mockClear(); + row(); + act(() => root.render()); + expect(owner.write).not.toHaveBeenCalled(); + act(() => root.render(null)); + state.restoring = true; + act(() => root.render()); + expect(state.restoring).toBe(false); + expect(state.anchor).toBeNull(); + }); + + it('restores exploration expansion after content remount without retaining items', () => { + let exploration: ReturnType; + function ExpansionHarness() { + exploration = useExploreGroupState([], state.exploreGroupStates); + const groups = exploration.exploreGroupStates; + useEffect(() => { state.exploreGroupStates = groups; }, [groups]); + return null; + } + act(() => root.render()); + act(() => exploration.onExpandGroup('group')); + act(() => root.render(null)); + act(() => root.render()); + expect(exploration!.exploreGroupStates.get('group')).toBe(true); + act(() => root.render(null)); + state = createBtwPanelViewState(); + act(() => root.render()); + expect(exploration!.exploreGroupStates.size).toBe(0); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.ts b/src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.ts new file mode 100644 index 0000000000..dfdba9a72d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/useBtwPanelViewport.ts @@ -0,0 +1,88 @@ +import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import type { FlowChatVirtualizer } from '../modern/useFlowChatVirtualizer'; +import type { FlowChatViewportOwnerApi } from '../modern/useFlowChatViewportOwner'; +import { getVirtualItemStableKey } from '../modern/virtualItemIdentity'; +import type { BtwPanelViewState } from './btwPanelViewState'; + +export function useBtwPanelViewport( + viewState: BtwPanelViewState | undefined, + items: VirtualItem[], + scrollerRef: RefObject, + windowRef: RefObject, + virtualizer: FlowChatVirtualizer, + viewportOwner: FlowChatViewportOwnerApi, +) { + const restoreRef = useRef(viewState && !viewState.followTail ? viewState.anchor : null); + + useLayoutEffect(() => { + const anchor = restoreRef.current; + const scroller = scrollerRef.current; + if (!anchor || !scroller || !scroller.clientHeight || !viewState) return; + if (!viewState.restoring) { + restoreRef.current = null; + return; + } + const index = items.findIndex(item => getVirtualItemStableKey(item) === anchor.key); + if (index < 0) { + restoreRef.current = null; + viewState.restoring = false; + viewState.anchor = null; + return; + } + // First place the estimated row into the window. Once mounted, use its + // actual position, including the saved offset inside a tall model round. + const row = [...(windowRef.current?.children ?? [])].find(element => + element.getAttribute('data-virtual-item-key') === anchor.key, + ); + const bounds = virtualizer.getItemBounds(index); + if (!bounds) return; + const offset = row + ? Math.min(anchor.offsetPx, Math.max(0, row.getBoundingClientRect().height - 1)) + : anchor.offsetPx; + const topPx = row + ? scroller.scrollTop + row.getBoundingClientRect().top + - scroller.getBoundingClientRect().top - scroller.clientTop + offset + : bounds.startPx + offset; + if (viewportOwner.write({ owner: 'one-shot-navigation', topPx, holdForMs: 0 }) && row) { + restoreRef.current = null; + viewState.restoring = false; + } + }, [items, scrollerRef, windowRef, virtualizer, viewportOwner, viewState]); + + useEffect(() => { + const scroller = scrollerRef.current; + const window = windowRef.current; + if (!scroller || !window || !viewState) return; + const capture = () => { + if (viewState.restoring || !scroller.clientHeight) return; + if (viewState.followTail) { + viewState.anchor = null; + return; + } + const top = scroller.getBoundingClientRect().top + scroller.clientTop; + const row = [...window.children].find(element => element.getBoundingClientRect().bottom > top); + const key = row?.getAttribute('data-virtual-item-key'); + if (row && key) viewState.anchor = { key, offsetPx: top - row.getBoundingClientRect().top }; + }; + const interrupt = () => { + restoreRef.current = null; + viewState.restoring = false; + }; + scroller.addEventListener('scroll', capture); + scroller.addEventListener('wheel', interrupt, { passive: true }); + scroller.addEventListener('touchstart', interrupt, { passive: true }); + scroller.addEventListener('pointerdown', interrupt); + scroller.addEventListener('keydown', interrupt); + const observer = new ResizeObserver(capture); + observer.observe(window); + return () => { + observer.disconnect(); + scroller.removeEventListener('scroll', capture); + scroller.removeEventListener('wheel', interrupt); + scroller.removeEventListener('touchstart', interrupt); + scroller.removeEventListener('pointerdown', interrupt); + scroller.removeEventListener('keydown', interrupt); + }; + }, [scrollerRef, windowRef, viewState]); +} diff --git a/src/web-ui/src/flow_chat/components/btw/useBtwSessionState.test.tsx b/src/web-ui/src/flow_chat/components/btw/useBtwSessionState.test.tsx new file mode 100644 index 0000000000..6abe704742 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/useBtwSessionState.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowChatState, Session } from '../../types/flow-chat'; +import { useBtwSessionState } from './useBtwSessionState'; + +const store = vi.hoisted(() => ({ + state: { sessions: new Map() } as FlowChatState, + listeners: new Set<() => void>(), + beforeSubscribe: undefined as (() => void) | undefined, +})); + +vi.mock('../../store/FlowChatStore', () => ({ + flowChatStore: { + getState: () => store.state, + subscribeSelector: (select: (state: FlowChatState) => T, notify: (value: T) => void) => { + store.beforeSubscribe?.(); + // Match the store's lazy first notification, including unchanged data. + let hasPrevious = false; + let previous: T; + const listener = () => { + const next = select(store.state); + if (hasPrevious && Object.is(previous, next)) return; + hasPrevious = true; + previous = next; + notify(next); + }; + store.listeners.add(listener); + return () => { store.listeners.delete(listener); }; + }, + }, +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('useBtwSessionState', () => { + let root: Root; + let container: HTMLDivElement; + let renders: number; + let selected: ReturnType; + const session = (id: string) => ({ sessionId: id, title: id } as Session); + function Harness({ child = 'child', parent = 'parent', reviewCheck = false }: { child?: string; parent?: string; reviewCheck?: boolean }) { + selected = useBtwSessionState(child, parent, reviewCheck); + renders += 1; + return null; + } + function publish(sessions: Map) { + act(() => { + store.state = { ...store.state, sessions }; + store.listeners.forEach(listener => listener()); + }); + } + beforeEach(() => { + store.state = { sessions: new Map(['child', 'parent', 'other'].map(id => [id, session(id)])) } as FlowChatState; + store.listeners.clear(); + store.beforeSubscribe = undefined; + renders = 0; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + expect(store.listeners.size).toBe(0); + container.remove(); + }); + + it('does not render for unrelated subagent updates, including the first notification', () => { + act(() => root.render()); + const before = renders; + const snapshot = selected; + for (let index = 0; index < 50; index += 1) { + publish(new Map(store.state.sessions).set('other', session('other'))); + } + expect(renders).toBe(before); + expect(selected).toBe(snapshot); + }); + + it('updates when the child streams or parent metadata/review state changes', () => { + act(() => root.render()); + const before = renders; + const child = { ...store.state.sessions.get('child')!, title: 'streamed' }; + publish(new Map(store.state.sessions).set('child', child)); + expect(selected.childSession).toBe(child); + expect(renders).toBe(before + 1); + const parent = { ...store.state.sessions.get('parent')!, title: 'renamed' }; + publish(new Map(store.state.sessions).set('parent', parent)); + expect(selected.parentMetadata?.title).toBe(parent.title); + expect(selected.parentMetadata).not.toHaveProperty('dialogTurns'); + expect(renders).toBe(before + 2); + }); + + it('selects new IDs immediately and handles hydration, removal and surface replacement', () => { + act(() => root.render()); + act(() => root.render()); + expect(selected.childSession).toBeUndefined(); + expect(selected.parentMetadata?.title).toBe('other'); + expect(store.listeners.size).toBe(1); + const hydrated = session('missing'); + publish(new Map(store.state.sessions).set('missing', hydrated)); + expect(selected.childSession).toBe(hydrated); + const replacement = session('missing'); + publish(new Map([['missing', replacement]])); + expect(selected.childSession).toBe(replacement); + expect(selected.parentMetadata).toBeUndefined(); + publish(new Map()); + expect(selected.childSession).toBeUndefined(); + }); + + it('does not miss an update between render and subscription', () => { + const child = session('child'); + store.beforeSubscribe = () => { + store.state = { ...store.state, sessions: new Map(store.state.sessions).set('child', child) }; + }; + act(() => root.render()); + expect(selected.childSession).toBe(child); + }); + + it('ignores parent streaming without scanning its transcript in ordinary panes', () => { + act(() => root.render()); + const before = renders; + const snapshot = selected; + for (let index = 0; index < 50; index += 1) { + const parent = { + ...session('parent'), lastActiveAt: index, + get dialogTurns(): Session['dialogTurns'] { + throw new Error('Ordinary panes must not read parent turns'); + }, + }; + publish(new Map(store.state.sessions).set('parent', parent)); + } + expect(renders).toBe(before); + expect(selected).toBe(snapshot); + }); + + it.each(['title', 'workspacePath', 'remoteConnectionId', 'remoteSshHost'] as const)( + 'updates parent %s and handles its removal', field => { + act(() => root.render()); + const before = renders; + publish(new Map(store.state.sessions).set('parent', { + ...session('parent'), [field]: 'updated', + })); + expect(selected.parentMetadata?.[field]).toBe('updated'); + expect(renders).toBe(before + 1); + const parent = { ...store.state.sessions.get('parent')! }; + Reflect.deleteProperty(parent, field); + publish(new Map(store.state.sessions).set('parent', parent)); + expect(selected.parentMetadata?.[field]).toBeUndefined(); + expect(renders).toBe(before + 2); + }, + ); + + it('publishes only the linked review outcome and resets it when the view changes', () => { + const parentWithResult = (status: string, toolId = 'linked-task') => ({ + ...session('parent'), + dialogTurns: [{ + id: 'turn', modelRounds: [{ + id: 'round', items: [{ + type: 'tool', id: toolId, status: 'completed', + toolCall: { id: toolId }, toolResult: { result: { status } }, + }], + }], + }], + } as unknown as Session); + store.state.sessions.set('child', { ...session('child'), parentToolCallId: 'linked-task' }); + store.state.sessions.set('parent', parentWithResult('completed')); + act(() => root.render()); + const before = renders; + publish(new Map(store.state.sessions).set('parent', parentWithResult('timed_out', 'unrelated-task'))); + expect(renders).toBe(before); + publish(new Map(store.state.sessions).set('parent', parentWithResult('partial_timeout'))); + expect(selected.reviewTaskOutcome).toBe('partial-timeout'); + expect(renders).toBe(before + 1); + publish(new Map(store.state.sessions).set('parent', parentWithResult('partial_timeout'))); + expect(renders).toBe(before + 1); + publish(new Map(store.state.sessions).set('child', { ...session('child'), parentToolCallId: 'other-task' })); + expect(selected.reviewTaskOutcome).toBeNull(); + publish(new Map(store.state.sessions).set('parent', parentWithResult('timed_out', 'other-task'))); + expect(selected.reviewTaskOutcome).toBe('timed-out'); + act(() => root.render()); + expect(selected.reviewTaskOutcome).toBeNull(); + act(() => root.render()); + expect(selected.reviewTaskOutcome).toBe('timed-out'); + publish(new Map([['child', store.state.sessions.get('child')!]])); + expect(selected.reviewTaskOutcome).toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/btw/useBtwSessionState.ts b/src/web-ui/src/flow_chat/components/btw/useBtwSessionState.ts new file mode 100644 index 0000000000..034b6486dd --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/useBtwSessionState.ts @@ -0,0 +1,70 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { flowChatStore } from '../../store/FlowChatStore'; +import type { FlowChatState, Session } from '../../types/flow-chat'; +import { findReviewTaskOutcome, type ReviewTaskOutcome } from '../../utils/reviewTaskOutcome'; + +type ParentMetadata = Pick; + +interface BtwSessionState { + childSession: Session | undefined; + parentMetadata: ParentMetadata | undefined; + reviewTaskOutcome: ReviewTaskOutcome | null; +} + +/** Subscribe to this pane's sessions without retaining the global store snapshot. */ +export function useBtwSessionState( + childSessionId?: string, + parentSessionId?: string, + reviewCheck = false, +): BtwSessionState { + const source = useMemo(() => { + let snapshot: BtwSessionState = { + childSession: undefined, parentMetadata: undefined, reviewTaskOutcome: null, + }; + let reviewTurns: Session['dialogTurns'] | undefined; + let reviewToolCallId: string | undefined; + let reviewTaskOutcome: ReviewTaskOutcome | null = null; + const select = (state: FlowChatState) => { + const childSession = childSessionId ? state.sessions.get(childSessionId) : undefined; + const parentSession = parentSessionId ? state.sessions.get(parentSessionId) : undefined; + let parentMetadata = snapshot.parentMetadata; + if ( + Boolean(parentMetadata) !== Boolean(parentSession) || + parentMetadata?.title !== parentSession?.title || + parentMetadata?.workspacePath !== parentSession?.workspacePath || + parentMetadata?.remoteConnectionId !== parentSession?.remoteConnectionId || + parentMetadata?.remoteSshHost !== parentSession?.remoteSshHost + ) { + parentMetadata = parentSession ? { + title: parentSession.title, + workspacePath: parentSession.workspacePath, + remoteConnectionId: parentSession.remoteConnectionId, + remoteSshHost: parentSession.remoteSshHost, + } : undefined; + } + // Ordinary child panes never scan or retain the parent's transcript. + // Review panes publish only the linked Task's semantic outcome. + if (reviewCheck && ( + reviewTurns !== parentSession?.dialogTurns || + reviewToolCallId !== childSession?.parentToolCallId + )) { + reviewTurns = parentSession?.dialogTurns; + reviewToolCallId = childSession?.parentToolCallId; + reviewTaskOutcome = findReviewTaskOutcome(parentSession, reviewToolCallId); + } + if (snapshot.childSession !== childSession || snapshot.parentMetadata !== parentMetadata || + snapshot.reviewTaskOutcome !== reviewTaskOutcome) { + snapshot = { childSession, parentMetadata, reviewTaskOutcome }; + } + return snapshot; + }; + return { + getSnapshot: () => select(flowChatStore.getState()), + subscribe: (notify: () => void) => flowChatStore.subscribeSelector(select, notify), + }; + }, [childSessionId, parentSessionId, reviewCheck]); + + // React rechecks after subscribing, covering updates between render and commit + // and immediately selecting the new pair when a pane changes sessions. + return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot); +} 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 b07d3502e5..46b2bc5b68 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 @@ -1,5 +1,15 @@ # FlowChat Virtualization +## Embedded session lifetime + +`BtwSessionPanel` keeps a lightweight tab-owned wrapper while its content is +inactive. Its transcript and observers unmount immediately. `BtwVirtualSessionList` +shares the virtualizer and stable row keys; `useBtwPanelViewport` saves a visible +row key and intra-row offset while reading, then restores against estimated and +mounted geometry. Readers following output return to the live tail. The shared +`useExploreGroupState` accepts initial expansion state for this remount boundary; +the primary transcript retains its existing default and session lifetime. + ## Result visibility and read receipts `useSessionCompletionReceipt` reads the final projected non-user item for the diff --git a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts index 0f05845301..f89459509c 100644 --- a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts +++ b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts @@ -21,8 +21,9 @@ interface UseExploreGroupStateResult { export function useExploreGroupState( virtualItems: VirtualItem[], + initialStates?: Map, ): UseExploreGroupStateResult { - const [exploreGroupStates, setExploreGroupStates] = useState>(new Map()); + const [exploreGroupStates, setExploreGroupStates] = useState>(() => initialStates ?? new Map()); const virtualItemsRef = useRef(virtualItems); virtualItemsRef.current = virtualItems; From eafc9c433e07b7347b870e1edb49bdd192dfc309 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 10 Sep 2026 15:07:24 +0800 Subject: [PATCH 2/2] fix(web-ui): resolve lint errors and warnings - Separate shared helpers and contexts from component exports to preserve React Fast Refresh boundaries - Update consumers and tests to use the extracted modules - Define BtwSessionPanelContent before its wrapper - Remove an unused ESLint directive and unnecessary export --- src/web-ui/src/app/App.tsx | 2 +- .../NavPanel/components/DeviceArtwork.tsx | 14 +-- .../components/DeviceStatusControl.test.tsx | 2 +- .../NavPanel/components/deviceArtworkKind.ts | 15 +++ .../components/SceneTopBar/SceneChrome.tsx | 16 +-- .../src/app/components/SceneTopBar/index.ts | 2 +- .../SceneTopBar/sceneChromeContext.ts | 17 +++ .../app/scenes/browser/HtmlPreviewPanel.tsx | 1 - .../flow_chat/components/ModelSelector.tsx | 7 +- .../components/ReasoningPresetSelector.tsx | 110 +----------------- .../components/btw/BtwSessionPanel.tsx | 24 ++-- .../components/modern/FlowChatHeader.tsx | 6 +- .../modern/MessageReferenceCapsule.tsx | 37 +----- .../modern/UserMessagePresentationContent.tsx | 3 +- .../modern/messageReferenceIcons.tsx | 36 ++++++ .../components/reasoningPresetPresentation.ts | 106 +++++++++++++++++ .../voice/RealtimeVoiceCallContext.ts | 18 +++ .../voice/RealtimeVoiceCallContext.tsx | 35 ------ .../voice/RealtimeVoiceCallProvider.tsx | 17 +++ .../tool-cards/AgentWaitToolCard.test.tsx | 3 +- .../tool-cards/AgentWaitToolCard.tsx | 21 +--- .../tool-cards/agentWaitSteeringHint.ts | 19 +++ .../components/ReleaseLetterModal.tsx | 2 +- 23 files changed, 260 insertions(+), 253 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts create mode 100644 src/web-ui/src/app/components/SceneTopBar/sceneChromeContext.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/messageReferenceIcons.tsx create mode 100644 src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts create mode 100644 src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.ts delete mode 100644 src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.tsx create mode 100644 src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallProvider.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/agentWaitSteeringHint.ts diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 0b250bf543..7e66c18f43 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -29,7 +29,7 @@ import { retryStartupAfterModuleLoadFailure, } from './startup/startupModuleRecovery'; import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/ToolbarModeProvider'; -import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallContext'; +import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallProvider'; import type { AgentCompanionPetCommand } from './services/agentCompanionPetCommands'; import AskUserAnnouncer from './components/NavPanel/AskUserAnnouncer'; import { handleBrowserShortcut } from './browserShortcutPolicy'; diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceArtwork.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceArtwork.tsx index fde0e75f64..446bce4f13 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceArtwork.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceArtwork.tsx @@ -1,19 +1,7 @@ import type { DeviceOverviewDevice } from '../deviceInterconnectionOverview'; import macbookAir from '../assets/macbook-air.png'; -type DeviceArtworkKind = 'device' | 'server' | 'macbook-air'; - -export function getDeviceArtworkKind( - device: Pick, -): DeviceArtworkKind { - if (device.kind === 'execution-host') return 'server'; - // Device names can identify a model, but the controller's OS cannot identify - // a remote machine. Unrecognized names deliberately use neutral artwork. - if (device.kind === 'desktop' && /\bmacbook[\s._-]*air\b/i.test(device.name)) { - return 'macbook-air'; - } - return 'device'; -} +import { getDeviceArtworkKind } from './deviceArtworkKind'; export function DeviceArtwork({ device }: { device: DeviceOverviewDevice }) { const artwork = getDeviceArtworkKind(device); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index 0c4474fe49..8aa7d205bd 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -8,7 +8,7 @@ import { type DeviceInterconnectionOverview, type DeviceInterconnectionOverviewInput, } from '../deviceInterconnectionOverview'; -import { getDeviceArtworkKind } from './DeviceArtwork'; +import { getDeviceArtworkKind } from './deviceArtworkKind'; import DeviceStatusControl from './DeviceStatusControl'; const state = vi.hoisted(() => ({ diff --git a/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts b/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts new file mode 100644 index 0000000000..095ab19a19 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/deviceArtworkKind.ts @@ -0,0 +1,15 @@ +import type { DeviceOverviewDevice } from '../deviceInterconnectionOverview'; + +type DeviceArtworkKind = 'device' | 'server' | 'macbook-air'; + +export function getDeviceArtworkKind( + device: Pick, +): DeviceArtworkKind { + if (device.kind === 'execution-host') return 'server'; + // Device names can identify a model, but the controller's OS cannot identify + // a remote machine. Unrecognized names deliberately use neutral artwork. + if (device.kind === 'desktop' && /\bmacbook[\s._-]*air\b/i.test(device.name)) { + return 'macbook-air'; + } + return 'device'; +} diff --git a/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx b/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx index 7ec83e7347..51c22b4de2 100644 --- a/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx +++ b/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx @@ -10,23 +10,13 @@ import React, { type ReactNode, } from 'react'; import type { SceneTabId } from '../SceneBar/types'; +import { SceneChromeContext, type SceneChromeContextValue } from './sceneChromeContext'; interface SceneChromeContributionRecord { owner: symbol; content: ReactNode; } -interface SceneChromeContextValue { - activeSceneId: SceneTabId | null; - setContribution: ( - sceneId: SceneTabId, - owner: symbol, - content: ReactNode, - ) => void; - removeContribution: (sceneId: SceneTabId, owner: symbol) => void; -} - -const SceneChromeContext = createContext(null); const SceneChromeContentContext = createContext(null); interface SceneChromeProviderProps { @@ -117,7 +107,3 @@ export const SceneChromeContribution: React.FC = ( return null; }; - -export function useSceneChromeContext(): SceneChromeContextValue | null { - return useContext(SceneChromeContext); -} diff --git a/src/web-ui/src/app/components/SceneTopBar/index.ts b/src/web-ui/src/app/components/SceneTopBar/index.ts index 22fa0c38e3..819a5f5894 100644 --- a/src/web-ui/src/app/components/SceneTopBar/index.ts +++ b/src/web-ui/src/app/components/SceneTopBar/index.ts @@ -3,5 +3,5 @@ export { SceneChromeContribution, SceneChromeHost, SceneChromeProvider, - useSceneChromeContext, } from './SceneChrome'; +export { useSceneChromeContext } from './sceneChromeContext'; diff --git a/src/web-ui/src/app/components/SceneTopBar/sceneChromeContext.ts b/src/web-ui/src/app/components/SceneTopBar/sceneChromeContext.ts new file mode 100644 index 0000000000..c7e5cd8c07 --- /dev/null +++ b/src/web-ui/src/app/components/SceneTopBar/sceneChromeContext.ts @@ -0,0 +1,17 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { SceneTabId } from '../SceneBar/types'; + +export interface SceneChromeContextValue { + activeSceneId: SceneTabId | null; + setContribution: ( + sceneId: SceneTabId, + owner: symbol, + content: ReactNode, + ) => void; + removeContribution: (sceneId: SceneTabId, owner: symbol) => void; +} + +export const SceneChromeContext = createContext(null); +export function useSceneChromeContext(): SceneChromeContextValue | null { + return useContext(SceneChromeContext); +} diff --git a/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx b/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx index 0a2f613310..9ae7daa5c6 100644 --- a/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx +++ b/src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx @@ -54,7 +54,6 @@ const HtmlPreviewPanel: React.FC = ({ } }; // The session is intentionally recreated only when the opened file scope changes. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [filePath, workspacePath, remoteConnectionId]); if (error) { diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 4771cb9742..230ee3b149 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -49,11 +49,8 @@ import { import { createLogger } from '@/shared/utils/logger'; import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition'; import { AcpModeSelector } from './AcpModeSelector'; -import { - ReasoningIntensityMark, - presetDisplayLabel, - reasoningIntensityLevel, -} from './ReasoningPresetSelector'; +import { ReasoningIntensityMark } from './ReasoningPresetSelector'; +import { presetDisplayLabel, reasoningIntensityLevel } from './reasoningPresetPresentation'; import { getRecentReasoningPreset, setRecentReasoningPreset, diff --git a/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx b/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx index c2d5042d4b..df217407ec 100644 --- a/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx @@ -9,11 +9,9 @@ import { OverflowText, Menu, MenuItem } from '@openbitfun/ui'; import { Tooltip } from '@openbitfun/ui'; import { RetainedMountBoundary } from '@/shared/presence'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; -import type { - ReasoningCatalogProjection, - ReasoningPresetDescriptor, -} from '@/infrastructure/config/types'; +import type { ReasoningCatalogProjection } from '@/infrastructure/config/types'; import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition'; +import { presetLabel, presetDisplayLabel, reasoningIntensityLevel, type ReasoningIntensityLevel } from './reasoningPresetPresentation'; import './ReasoningPresetSelector.scss'; interface ReasoningPresetSelectorProps { @@ -26,110 +24,6 @@ interface ReasoningPresetSelectorProps { onSelect: (presetId: string | null) => void | Promise; } -function presetLabel( - preset: ReasoningPresetDescriptor, - t: ReturnType['t'], -): string { - return t(`reasoningEffort.${preset.id}`, { defaultValue: preset.label || preset.id }); -} - -export function presetDisplayLabel( - preset: ReasoningPresetDescriptor, - t: ReturnType['t'], -): string { - const fallback = presetLabel(preset, t); - const semanticKey = presetSemanticKey(preset); - return semanticKey - ? t(`reasoningSelector.levels.${semanticKey}`, { defaultValue: fallback }) - : fallback; -} - -type ReasoningIntensityLevel = 0 | 1 | 2 | 3 | 4; - -type ReasoningPresetSemanticKey = - | 'off' - | 'on' - | 'minimal' - | 'low' - | 'medium' - | 'high' - | 'xhigh' - | 'max'; - -const REASONING_PRESET_SEMANTIC_KEYS = new Set([ - 'off', - 'on', - 'minimal', - 'low', - 'medium', - 'high', - 'xhigh', - 'max', -]); - -function asReasoningPresetSemanticKey(value: string): ReasoningPresetSemanticKey | undefined { - const normalized = value.trim().toLowerCase(); - if (normalized === 'none') return 'off'; - return REASONING_PRESET_SEMANTIC_KEYS.has(normalized as ReasoningPresetSemanticKey) - ? normalized as ReasoningPresetSemanticKey - : undefined; -} - -function presetSemanticKey( - preset: ReasoningPresetDescriptor, -): ReasoningPresetSemanticKey | undefined { - const idKey = asReasoningPresetSemanticKey(preset.id); - if (idKey) return idKey; - - // Generated presets can prefix or replace their semantic id (for example - // effort-off and budget-max). Their action is the stable meaning to localize. - // Custom presets keep their authored label instead of being renamed by an - // implementation detail in their request actions. - if (preset.source === 'model_config') return undefined; - - for (const action of preset.actions) { - if (action.type === 'toggle') return action.enabled ? 'on' : 'off'; - if (action.type === 'effort') { - const effortKey = asReasoningPresetSemanticKey(action.value); - if (effortKey) return effortKey; - } - if (action.type === 'budget_tokens') { - if (preset.id.toLowerCase().includes('max')) return 'max'; - if (preset.id.toLowerCase().includes('high')) return 'high'; - } - } - - return undefined; -} - -function presetDisablesReasoning(preset: ReasoningPresetDescriptor): boolean { - if (presetSemanticKey(preset) === 'off') return true; - return preset.actions.some(action => ( - action.type === 'toggle' && !action.enabled - )); -} - -export function reasoningIntensityLevel( - preset: ReasoningPresetDescriptor | undefined, - orderedPresets: ReasoningPresetDescriptor[], -): ReasoningIntensityLevel { - if (!preset) return 0; - if (presetDisablesReasoning(preset)) return 0; - - const activePresets = orderedPresets.filter(item => !presetDisablesReasoning(item)); - const activeIndex = activePresets.findIndex(item => item.id === preset.id); - if (activeIndex < 0) return 0; - if (activePresets.length === 1) return 1; - - // The catalog may merge toggle, effort and token-budget presets. Ranking by - // the catalog order keeps the visual series monotonic even when ids come from - // different action families (off, on, low, high, max). - return Math.min( - 4, - Math.max(1, Math.round((activeIndex / (activePresets.length - 1)) * 3) + 1), - ) as 1 | 2 | 3 | 4; -} - interface ReasoningIntensityMarkProps { level: ReasoningIntensityLevel; compact?: boolean; diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index ebaeabd45d..ffc43a2a3d 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -143,18 +143,6 @@ const isSameReviewResult = (left: unknown, right: unknown): boolean => { } }; -export const BtwSessionPanel: React.FC = (props) => { - const surfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); - const viewState = useMemo(createBtwPanelViewState, [surfaceId, props.childSessionId, props.viewKind]); - return props.isActive === false ? null : ( - - ); -}; - const BtwSessionPanelContent: React.FC = ({ childSessionId, parentSessionId, @@ -1285,4 +1273,16 @@ const BtwSessionPanelContent: React.FC = (props) => { + const surfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + const viewState = useMemo(createBtwPanelViewState, [surfaceId, props.childSessionId, props.viewKind]); + return props.isActive === false ? null : ( + + ); +}; + BtwSessionPanel.displayName = 'BtwSessionPanel'; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 6ba81d0498..2ba074311b 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -8,10 +8,8 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallba import { createPortal } from 'react-dom'; import { Keyboard, Square } from 'lucide-react'; import { OverflowText, Icon, IconButton, Menu, MenuItem, SearchField, Tooltip } from '@openbitfun/ui'; -import { - SceneChromeContribution, - useSceneChromeContext, -} from '@/app/components/SceneTopBar/SceneChrome'; +import { SceneChromeContribution } from '@/app/components/SceneTopBar/SceneChrome'; +import { useSceneChromeContext } from '@/app/components/SceneTopBar/sceneChromeContext'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; import { SessionTreePopover, type SessionTreeSelection } from './SessionTreePopover'; diff --git a/src/web-ui/src/flow_chat/components/modern/MessageReferenceCapsule.tsx b/src/web-ui/src/flow_chat/components/modern/MessageReferenceCapsule.tsx index a26a2f0554..1a4dea5488 100644 --- a/src/web-ui/src/flow_chat/components/modern/MessageReferenceCapsule.tsx +++ b/src/web-ui/src/flow_chat/components/modern/MessageReferenceCapsule.tsx @@ -1,41 +1,8 @@ import React from 'react'; -import { OverflowText, Icon } from '@openbitfun/ui'; -import { AtSign, Code2, File, MessageCircle } from 'lucide-react'; -import type { ContextType } from '@/shared/types/context'; +import { OverflowText } from '@openbitfun/ui'; +import { messageContextIcon, messageInlineTokenIcon } from './messageReferenceIcons'; import './MessageReferenceCapsule.scss'; -export function messageContextIcon(type: ContextType | string): React.ReactNode { - switch (type) { - case 'session-reference': return ; - case 'file': - case 'image': return ; - case 'directory': return ; - case 'code-snippet': - case 'mermaid-node': - case 'mermaid-diagram': return ; - case 'pull-request': - case 'git-ref': return ; - case 'terminal-command': return ; - case 'url': return ; - default: return ; - } -} - -/** - * Returns the icon used for composer inline tokens. Keeping this projection - * shared means persisted Turn Rail previews render the same visual language as - * the already-sent user message. - */ -export function messageInlineTokenIcon(tokenType: string): React.ReactNode { - switch (tokenType) { - case 'skill': - return ; - case 'widget': - default: - return ; - } -} - export const MessageReferenceCapsule: React.FC<{ className?: string; type: string; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessagePresentationContent.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessagePresentationContent.tsx index 991337c729..4e7c557c09 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessagePresentationContent.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessagePresentationContent.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { ComposerPresentation } from '../../utils/composerPresentation'; -import { MessageReferenceCapsule, messageInlineTokenIcon } from './MessageReferenceCapsule'; +import { MessageReferenceCapsule } from './MessageReferenceCapsule'; +import { messageInlineTokenIcon } from './messageReferenceIcons'; export const UserMessagePresentationContent: React.FC<{ presentation: ComposerPresentation; diff --git a/src/web-ui/src/flow_chat/components/modern/messageReferenceIcons.tsx b/src/web-ui/src/flow_chat/components/modern/messageReferenceIcons.tsx new file mode 100644 index 0000000000..1981d4eee4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/messageReferenceIcons.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Icon } from '@openbitfun/ui'; +import { AtSign, Code2, File, MessageCircle } from 'lucide-react'; +import type { ContextType } from '@/shared/types/context'; + +export function messageContextIcon(type: ContextType | string): React.ReactNode { + switch (type) { + case 'session-reference': return ; + case 'file': + case 'image': return ; + case 'directory': return ; + case 'code-snippet': + case 'mermaid-node': + case 'mermaid-diagram': return ; + case 'pull-request': + case 'git-ref': return ; + case 'terminal-command': return ; + case 'url': return ; + default: return ; + } +} + +/** + * Returns the icon used for composer inline tokens. Keeping this projection + * shared means persisted Turn Rail previews render the same visual language as + * the already-sent user message. + */ +export function messageInlineTokenIcon(tokenType: string): React.ReactNode { + switch (tokenType) { + case 'skill': + return ; + case 'widget': + default: + return ; + } +} diff --git a/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts new file mode 100644 index 0000000000..58fe4f2bfc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/reasoningPresetPresentation.ts @@ -0,0 +1,106 @@ +import type { useTranslation } from 'react-i18next'; +import type { ReasoningPresetDescriptor } from '@/infrastructure/config/types'; + +export function presetLabel( + preset: ReasoningPresetDescriptor, + t: ReturnType['t'], +): string { + return t(`reasoningEffort.${preset.id}`, { defaultValue: preset.label || preset.id }); +} + +export function presetDisplayLabel( + preset: ReasoningPresetDescriptor, + t: ReturnType['t'], +): string { + const fallback = presetLabel(preset, t); + const semanticKey = presetSemanticKey(preset); + return semanticKey + ? t(`reasoningSelector.levels.${semanticKey}`, { defaultValue: fallback }) + : fallback; +} + +export type ReasoningIntensityLevel = 0 | 1 | 2 | 3 | 4; + +type ReasoningPresetSemanticKey = + | 'off' + | 'on' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max'; + +const REASONING_PRESET_SEMANTIC_KEYS = new Set([ + 'off', + 'on', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +]); + +function asReasoningPresetSemanticKey(value: string): ReasoningPresetSemanticKey | undefined { + const normalized = value.trim().toLowerCase(); + if (normalized === 'none') return 'off'; + return REASONING_PRESET_SEMANTIC_KEYS.has(normalized as ReasoningPresetSemanticKey) + ? normalized as ReasoningPresetSemanticKey + : undefined; +} + +function presetSemanticKey( + preset: ReasoningPresetDescriptor, +): ReasoningPresetSemanticKey | undefined { + const idKey = asReasoningPresetSemanticKey(preset.id); + if (idKey) return idKey; + + // Generated presets can prefix or replace their semantic id (for example + // effort-off and budget-max). Their action is the stable meaning to localize. + // Custom presets keep their authored label instead of being renamed by an + // implementation detail in their request actions. + if (preset.source === 'model_config') return undefined; + + for (const action of preset.actions) { + if (action.type === 'toggle') return action.enabled ? 'on' : 'off'; + if (action.type === 'effort') { + const effortKey = asReasoningPresetSemanticKey(action.value); + if (effortKey) return effortKey; + } + if (action.type === 'budget_tokens') { + if (preset.id.toLowerCase().includes('max')) return 'max'; + if (preset.id.toLowerCase().includes('high')) return 'high'; + } + } + + return undefined; +} + +function presetDisablesReasoning(preset: ReasoningPresetDescriptor): boolean { + if (presetSemanticKey(preset) === 'off') return true; + return preset.actions.some(action => ( + action.type === 'toggle' && !action.enabled + )); +} + +export function reasoningIntensityLevel( + preset: ReasoningPresetDescriptor | undefined, + orderedPresets: ReasoningPresetDescriptor[], +): ReasoningIntensityLevel { + if (!preset) return 0; + if (presetDisablesReasoning(preset)) return 0; + + const activePresets = orderedPresets.filter(item => !presetDisablesReasoning(item)); + const activeIndex = activePresets.findIndex(item => item.id === preset.id); + if (activeIndex < 0) return 0; + if (activePresets.length === 1) return 1; + + // The catalog may merge toggle, effort and token-budget presets. Ranking by + // the catalog order keeps the visual series monotonic even when ids come from + // different action families (off, on, low, high, max). + return Math.min( + 4, + Math.max(1, Math.round((activeIndex / (activePresets.length - 1)) * 3) + 1), + ) as 1 | 2 | 3 | 4; +} diff --git a/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.ts b/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.ts new file mode 100644 index 0000000000..bf1cc2264a --- /dev/null +++ b/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; +import type { RealtimeVoiceCallController } from './useRealtimeVoiceCall'; + +export const RealtimeVoiceCallContext = createContext(null); + +export function useRealtimeVoiceCall(): RealtimeVoiceCallController { + const controller = useContext(RealtimeVoiceCallContext); + if (!controller) { + throw new Error('useRealtimeVoiceCall must be used inside RealtimeVoiceCallProvider'); + } + return controller; +} + +/** Tests and isolated composer previews may render without the client shell. */ +export function useRealtimeVoiceCallActive(): boolean { + const controller = useContext(RealtimeVoiceCallContext); + return Boolean(controller && controller.phase !== 'idle'); +} diff --git a/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.tsx b/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.tsx deleted file mode 100644 index ce60b43999..0000000000 --- a/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallContext.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { createContext, useContext, type PropsWithChildren } from 'react'; -import { - useRealtimeVoiceCallController, - type RealtimeVoiceCallController, -} from './useRealtimeVoiceCall'; - -const RealtimeVoiceCallContext = createContext(null); - -/** - * Owns one realtime voice call for the whole client. Keeping this above the - * scene/workspace views lets a call and its Agent task survive navigation and - * workspace switches. - */ -export function RealtimeVoiceCallProvider({ children }: PropsWithChildren) { - const controller = useRealtimeVoiceCallController(); - return ( - - {children} - - ); -} - -export function useRealtimeVoiceCall(): RealtimeVoiceCallController { - const controller = useContext(RealtimeVoiceCallContext); - if (!controller) { - throw new Error('useRealtimeVoiceCall must be used inside RealtimeVoiceCallProvider'); - } - return controller; -} - -/** Tests and isolated composer previews may render without the client shell. */ -export function useRealtimeVoiceCallActive(): boolean { - const controller = useContext(RealtimeVoiceCallContext); - return Boolean(controller && controller.phase !== 'idle'); -} diff --git a/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallProvider.tsx b/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallProvider.tsx new file mode 100644 index 0000000000..1c4fe73dd7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/voice/RealtimeVoiceCallProvider.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from 'react'; +import { useRealtimeVoiceCallController } from './useRealtimeVoiceCall'; +import { RealtimeVoiceCallContext } from './RealtimeVoiceCallContext'; + +/** + * Owns one realtime voice call for the whole client. Keeping this above the + * scene/workspace views lets a call and its Agent task survive navigation and + * workspace switches. + */ +export function RealtimeVoiceCallProvider({ children }: PropsWithChildren) { + const controller = useRealtimeVoiceCallController(); + return ( + + {children} + + ); +} diff --git a/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.test.tsx index 9541758b60..4d1a972b76 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.test.tsx @@ -3,7 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import type { FlowToolItem, Session, ToolCardConfig } from '../types/flow-chat'; -import { AgentWaitToolCard, shouldShowAgentWaitSteeringHint } from './AgentWaitToolCard'; +import { AgentWaitToolCard } from './AgentWaitToolCard'; +import { shouldShowAgentWaitSteeringHint } from './agentWaitSteeringHint'; const { getState } = vi.hoisted(() => ({ getState: vi.fn() })); diff --git a/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.tsx index 38c7f9f568..e3b3918466 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AgentWaitToolCard.tsx @@ -2,33 +2,16 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { flowChatStore } from '../store/FlowChatStore'; -import type { Session, ToolCardProps, ToolCardDisplayContext } from '../types/flow-chat'; -import { isAcpFlowSession } from '../utils/acpSession'; +import type { ToolCardProps } from '../types/flow-chat'; +import { RUNNING_STATUSES, shouldShowAgentWaitSteeringHint } from './agentWaitSteeringHint'; import { AgentWaitToolCard as AgentWaitToolCardView } from '@openbitfun/ui/flow-chat'; -const RUNNING_STATUSES = new Set(['pending', 'preparing', 'running', 'streaming', 'receiving']); - interface AgentWaitResult { status?: string; results?: unknown[]; pending_bg_task_ids?: string[]; } -export function shouldShowAgentWaitSteeringHint( - status: ToolCardProps['toolItem']['status'], - displayContext: ToolCardDisplayContext | undefined, - session: Session | null | undefined, -): boolean { - if (!RUNNING_STATUSES.has(status) || displayContext === 'subagent-projection' || !session) { - return false; - } - - return session.sessionKind !== 'subagent' - && !session.parentToolCallId - && !session.isHistorical - && !isAcpFlowSession(session); -} - export const AgentWaitToolCard: React.FC = ({ toolItem, sessionId, diff --git a/src/web-ui/src/flow_chat/tool-cards/agentWaitSteeringHint.ts b/src/web-ui/src/flow_chat/tool-cards/agentWaitSteeringHint.ts new file mode 100644 index 0000000000..8073da658a --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/agentWaitSteeringHint.ts @@ -0,0 +1,19 @@ +import type { Session, ToolCardProps, ToolCardDisplayContext } from '../types/flow-chat'; +import { isAcpFlowSession } from '../utils/acpSession'; + +export const RUNNING_STATUSES = new Set(['pending', 'preparing', 'running', 'streaming', 'receiving']); + +export function shouldShowAgentWaitSteeringHint( + status: ToolCardProps['toolItem']['status'], + displayContext: ToolCardDisplayContext | undefined, + session: Session | null | undefined, +): boolean { + if (!RUNNING_STATUSES.has(status) || displayContext === 'subagent-projection' || !session) { + return false; + } + + return session.sessionKind !== 'subagent' + && !session.parentToolCallId + && !session.isHistorical + && !isAcpFlowSession(session); +} diff --git a/src/web-ui/src/shared/announcement-system/components/ReleaseLetterModal.tsx b/src/web-ui/src/shared/announcement-system/components/ReleaseLetterModal.tsx index c45baef657..735c81fa1b 100644 --- a/src/web-ui/src/shared/announcement-system/components/ReleaseLetterModal.tsx +++ b/src/web-ui/src/shared/announcement-system/components/ReleaseLetterModal.tsx @@ -19,7 +19,7 @@ interface ReleaseLetterParagraph { text: string; } -export function parseReleaseLetterBody(body: string): ReleaseLetterParagraph[] { +function parseReleaseLetterBody(body: string): ReleaseLetterParagraph[] { return body .split(/\r?\n\s*\r?\n/) .map(paragraph => paragraph.trim())