From 2587462ff28278a2e5c4394aaa90d675638e9fae Mon Sep 17 00:00:00 2001 From: GCWing Date: Mon, 6 Jul 2026 23:19:24 +0800 Subject: [PATCH] refactor(web-ui): stabilize flow chat scroll with viewport state machine Replace legacy Virtuoso scroll adapters and geometry helpers with a unified FlowViewport controller, and wire session/navigation header context through the workspace shell. --- .../UnifiedTopBar/UnifiedTopBar.tsx | 3 +- .../app/components/WorkDock/NewWorkDialog.tsx | 3 + src/web-ui/src/app/globalOverlayEscape.ts | 7 +- .../src/app/hooks/useCurrentSessionTitle.ts | 4 +- .../src/app/hooks/useSessionHeaderContext.ts | 52 + src/web-ui/src/app/layout/AppLayout.tsx | 18 +- .../app/navigation/navigationController.ts | 281 +++++ .../src/app/navigation/workspaceNavigation.ts | 136 +-- .../navigation/workspaceSurfaceStore.test.ts | 39 +- .../app/navigation/workspaceSurfaceStore.ts | 101 +- .../app/navigation/workspaceSurfaceTypes.ts | 10 +- .../src/app/scenes/session/SessionScene.tsx | 17 - .../SessionProfileProvider.tsx | 28 +- .../src/app/surfaces/AgenticOSWorkspace.tsx | 9 +- .../src/app/surfaces/SurfaceRenderer.tsx | 9 +- .../components/CurrentSessionTitle.tsx | 4 +- .../components/ScrollToBottomButton.scss | 106 -- .../components/ScrollToBottomButton.tsx | 46 - .../src/flow_chat/components/WelcomePanel.css | 43 +- .../src/flow_chat/components/WelcomePanel.tsx | 175 ++- .../hooks/useComposerSessionTarget.ts | 6 +- src/web-ui/src/flow_chat/components/index.ts | 1 - .../modern/AgenticOSFlowChatContainer.tsx | 10 +- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 389 +++---- .../modern/FlowChatSelectionAddButton.tsx | 4 +- .../modern/StandardFlowChatContainer.tsx | 2 + .../components/modern/VirtualMessageList.tsx | 495 ++------ .../components/modern/useFlowChatCore.ts | 45 - .../modern/useFlowChatNavigation.ts | 4 +- src/web-ui/src/flow_chat/hooks/index.ts | 1 - .../flow_chat/hooks/useActiveSessionState.ts | 4 +- .../flow_chat/hooks/useAgenticOsTimeline.ts | 4 +- .../src/flow_chat/hooks/useAutoScroll.ts | 97 -- src/web-ui/src/flow_chat/index.ts | 1 - .../scroll/FlowScrollGeometry.test.ts | 76 -- .../flow_chat/scroll/FlowScrollGeometry.ts | 142 --- .../src/flow_chat/scroll/FlowScrollPolicy.ts | 7 +- src/web-ui/src/flow_chat/scroll/README.md | 113 +- .../adapters/useVirtuosoFlowFollowOutput.ts | 449 -------- .../useVirtuosoFlowGeometryController.ts | 341 ------ .../useVirtuosoFlowLayoutMutationBridge.ts | 138 --- .../useVirtuosoFlowLayoutObservers.ts | 170 --- .../useVirtuosoFlowNavigationController.ts | 656 ----------- .../useVirtuosoFlowUserIntentBridge.ts | 215 ---- .../adapters/useVirtuosoVisibleTurnTracker.ts | 2 +- .../viewport/FlowViewportGeometry.test.ts | 123 ++ .../scroll/viewport/FlowViewportGeometry.ts | 325 ++++++ .../viewport/FlowViewportMachine.test.ts | 159 +++ .../scroll/viewport/FlowViewportMachine.ts | 223 ++++ .../scroll/viewport/FlowViewportScheduler.ts | 1002 +++++++++++++++++ .../viewport/useFlowViewportController.ts | 435 +++++++ .../src/flow_chat/services/FlowChatManager.ts | 47 +- .../flow_chat/services/childSessionPanels.ts | 21 +- .../flow-chat-manager/EventHandlerModule.ts | 4 +- .../flow-chat-manager/MessageModule.ts | 5 +- .../flow-chat-manager/SessionModule.test.ts | 104 +- .../flow-chat-manager/SessionModule.ts | 133 ++- .../flow-chat-manager/ToolEventModule.ts | 4 +- .../services/flow-chat-manager/index.ts | 2 + .../services/openAgenticOsSession.ts | 6 +- .../src/flow_chat/services/storeSync.ts | 4 +- .../src/flow_chat/store/FlowChatStore.ts | 60 + .../flow_chat/store/modernFlowChatStore.ts | 15 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 2 + .../self-control/SelfControlService.test.ts | 2 +- .../src/locales/en-US/flow-chat/welcome.json | 3 + .../src/locales/zh-CN/flow-chat/welcome.json | 3 + .../services/sceneOpenTargetResolver.test.ts | 11 +- .../generative-widget/widgetInteraction.ts | 4 +- 69 files changed, 3570 insertions(+), 3590 deletions(-) create mode 100644 src/web-ui/src/app/hooks/useSessionHeaderContext.ts create mode 100644 src/web-ui/src/app/navigation/navigationController.ts delete mode 100644 src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss delete mode 100644 src/web-ui/src/flow_chat/components/ScrollToBottomButton.tsx delete mode 100644 src/web-ui/src/flow_chat/hooks/useAutoScroll.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.test.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowFollowOutput.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowGeometryController.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutMutationBridge.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutObservers.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowNavigationController.ts delete mode 100644 src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowUserIntentBridge.ts create mode 100644 src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.test.ts create mode 100644 src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.ts create mode 100644 src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.test.ts create mode 100644 src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.ts create mode 100644 src/web-ui/src/flow_chat/scroll/viewport/FlowViewportScheduler.ts create mode 100644 src/web-ui/src/flow_chat/scroll/viewport/useFlowViewportController.ts diff --git a/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx b/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx index 2e534386..aebad268 100644 --- a/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx +++ b/src/web-ui/src/app/components/UnifiedTopBar/UnifiedTopBar.tsx @@ -44,6 +44,7 @@ import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { useSessionProfile } from '../../session-profiles'; import { getWorkspaceSceneDef } from '../../navigation/workspaceSceneRegistry'; import { resolveWorkContextForSurface } from '../../navigation/workspaceTopBarContext'; +import { useSessionHeaderContext } from '../../hooks/useSessionHeaderContext'; import { useWorkspaceSurfaceStore, type WorkspaceSceneHistoryEntry, @@ -106,7 +107,7 @@ const UnifiedTopBar: React.FC = ({ const { themes, themeId, setTheme, loading: themeLoading } = useTheme(); const { hasWorkspace } = useLastUsedWorkspace(); const { warning } = useNotification(); - const sessionContext = useHeaderStore((s) => s.sessionContext); + const sessionContext = useSessionHeaderContext(); const contextNavOverrides = useHeaderStore((s) => s.contextNavOverrides); const requestOpenWorkDock = useWorkDockStore((s) => s.requestOpenWorkDock); const works = useWorkStore((s) => s.works); diff --git a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx index 947538bb..9abd6448 100644 --- a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx +++ b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx @@ -633,6 +633,9 @@ export const NewWorkDialog: React.FC = ({ try { if (startMode === 'agentic-os') { const agenticOsSessionId = await openAgenticOsSession(); + if (!agenticOsSessionId) { + throw new Error('Failed to open Agentic OS session'); + } await flowChatManager.sendMessage( trimmedObjective, agenticOsSessionId, diff --git a/src/web-ui/src/app/globalOverlayEscape.ts b/src/web-ui/src/app/globalOverlayEscape.ts index 660da025..de4f81b2 100644 --- a/src/web-ui/src/app/globalOverlayEscape.ts +++ b/src/web-ui/src/app/globalOverlayEscape.ts @@ -9,7 +9,7 @@ * and when nested UI should consume Esc first. */ -import { useWorkspaceSurfaceStore } from './navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from './navigation/workspaceSurfaceStore'; import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; import { ALL_SHORTCUTS } from '@/shared/constants/shortcuts'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; @@ -52,7 +52,7 @@ function isTargetInsideChatShortcutScope(target: EventTarget | null): boolean { } function isActiveSessionProcessing(): boolean { - const sid = useWorkspaceSurfaceStore.getState().focusedSessionId; + const sid = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); if (!sid) return false; return stateMachineManager.getCurrentState(sid) === SessionExecutionState.PROCESSING; } @@ -76,7 +76,8 @@ export function installGlobalSurfaceEscapeToHome(): void { if (!eventMatchesEscapeToAgenticBinding(event)) return; if (shouldDeferForNestedEscapeUi(event)) return; - const { activeSurface, focusedSessionId } = useWorkspaceSurfaceStore.getState(); + const { activeSurface } = useWorkspaceSurfaceStore.getState(); + const focusedSessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); if (activeSurface.kind !== 'agentic-os-home') { event.preventDefault(); event.stopImmediatePropagation(); diff --git a/src/web-ui/src/app/hooks/useCurrentSessionTitle.ts b/src/web-ui/src/app/hooks/useCurrentSessionTitle.ts index d094bfaf..1ecafd9e 100644 --- a/src/web-ui/src/app/hooks/useCurrentSessionTitle.ts +++ b/src/web-ui/src/app/hooks/useCurrentSessionTitle.ts @@ -3,11 +3,11 @@ * Subscribes to flowChatStore so the value updates reactively. */ -import { useWorkspaceSurfaceStore } from '../navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '../navigation/workspaceSurfaceStore'; import { useFlowChatStoreSelector } from '../../flow_chat/hooks/useFlowChatStoreSelector'; export function useCurrentSessionTitle(): string { - const focusedSessionId = useWorkspaceSurfaceStore(state => state.focusedSessionId); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); return useFlowChatStoreSelector( state => { const session = focusedSessionId ? state.sessions.get(focusedSessionId) : undefined; diff --git a/src/web-ui/src/app/hooks/useSessionHeaderContext.ts b/src/web-ui/src/app/hooks/useSessionHeaderContext.ts new file mode 100644 index 00000000..ffe5c299 --- /dev/null +++ b/src/web-ui/src/app/hooks/useSessionHeaderContext.ts @@ -0,0 +1,52 @@ +/** + * Derived session header context for UnifiedTopBar. + * Single source: focused session from surface store + FlowChatStore session data. + */ + +import { useMemo } from 'react'; +import { + getWorkspaceDisplayName, + useWorkspaceContext, +} from '@/infrastructure/contexts/WorkspaceContext'; +import { + fallbackWorkspaceFolderLabel, + resolveWorkspaceForSession, +} from '@/flow_chat/utils/sessionOrdering'; +import { useFlowChatStoreSelector } from '@/flow_chat/hooks/useFlowChatStoreSelector'; +import { + selectFocusedSessionId, + useWorkspaceSurfaceStore, +} from '../navigation/workspaceSurfaceStore'; +import type { SessionHeaderContext } from '../stores/headerStore'; + +export function useSessionHeaderContext(): SessionHeaderContext | null { + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); + const { openedWorkspacesList } = useWorkspaceContext(); + + const session = useFlowChatStoreSelector((state) => ( + focusedSessionId ? state.sessions.get(focusedSessionId) : undefined + )); + + return useMemo((): SessionHeaderContext | null => { + if (!session?.sessionId || !session.descriptor) { + return null; + } + + let workspaceDisplayName = ''; + if (session.workspacePath?.trim()) { + const ws = resolveWorkspaceForSession(session, openedWorkspacesList); + if (ws) { + workspaceDisplayName = getWorkspaceDisplayName(ws).trim(); + } + if (!workspaceDisplayName) { + workspaceDisplayName = fallbackWorkspaceFolderLabel(session.workspacePath); + } + } + + return { + descriptor: session.descriptor, + workspacePath: session.workspacePath, + workspaceDisplayName, + }; + }, [openedWorkspacesList, session]); +} diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index e61a73e4..c87de4c0 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -30,7 +30,8 @@ import { useI18n } from '@/infrastructure/i18n'; import { consumeDeferredNewSessionWorkspace } from '../utils/deferredWorkspaceSession'; import { appRuntime, runtimePolicy } from '@/infrastructure/app-runtime'; import { descriptorFromAgentType, getDefaultSessionDescriptor } from '@/flow_chat/domain/sessionDescriptor'; -import { useWorkspaceSurfaceStore } from '../navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '../navigation/workspaceSurfaceStore'; +import { commitStartupHome } from '../navigation/navigationController'; import './AppLayout.scss'; const log = createLogger('AppLayout'); @@ -177,20 +178,21 @@ const AppLayout: React.FC = ({ className = '' }) => { const initializationPreferredDescriptor = explicitPreferredMode ? descriptorFromAgentType(explicitPreferredMode) : undefined; - const suppressAutoSessionSelection = consumeDeferredNewSessionWorkspace( - lastUsedWorkspace.rootPath - ); + consumeDeferredNewSessionWorkspace(lastUsedWorkspace.rootPath); + + commitStartupHome(); const flowChatManager = FlowChatManager.getInstance(); const initialization = await flowChatManager.initializeWorkspaceSessionState( lastUsedWorkspace.rootPath, { preferredDescriptor: initializationPreferredDescriptor, - skipAutoSelectSession: suppressAutoSessionSelection, + skipAutoSelectSession: true, createDefaultSession: true, defaultSessionConfig: { workspaceId: lastUsedWorkspace.id, workspacePath: lastUsedWorkspace.rootPath, + navigate: false, }, defaultSessionDescriptor: initializationPreferredDescriptor ?? getDefaultSessionDescriptor(), } @@ -199,8 +201,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const surfaceState = useWorkspaceSurfaceStore.getState(); const workspaceScopedActiveId = initialization.createdSessionId || - surfaceState.composerTargetSessionId || - surfaceState.focusedSessionId || + selectFocusedSessionId(surfaceState) || initialization.focusedSessionId; const pendingDescription = sessionStorage.getItem('pendingProjectDescription'); @@ -213,8 +214,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const latestSurfaceState = useWorkspaceSurfaceStore.getState(); const targetSessionId = pendingTargetSessionId || - latestSurfaceState.composerTargetSessionId || - latestSurfaceState.focusedSessionId; + selectFocusedSessionId(latestSurfaceState); if (!targetSessionId) { log.error('Cannot find active session ID'); diff --git a/src/web-ui/src/app/navigation/navigationController.ts b/src/web-ui/src/app/navigation/navigationController.ts new file mode 100644 index 00000000..957b4d8d --- /dev/null +++ b/src/web-ui/src/app/navigation/navigationController.ts @@ -0,0 +1,281 @@ +import type { WorkspaceSceneId } from './workspaceSceneTypes'; +import { + selectFocusedSessionId, + useWorkspaceSurfaceStore, + type WorkspaceSurfaceHistoryMode, +} from './workspaceSurfaceStore'; +import type { WorkspaceSurfaceContext } from './workspaceSurfaceTypes'; +import { createAgenticOsHomeSurface } from './workspaceSurfaceTypes'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { syncSessionToModernStore } from '@/flow_chat/services/storeSync'; +import { + getAgenticOsSessionDescriptor, + isSystemAgenticOsSession, +} from '@/flow_chat/domain/sessionDescriptor'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { resolveSessionTypeDefinitionForDescriptor } from '@/app/session-profiles'; +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { + projectRuntimeScopeFromWorkspacePath, + runtimeScopeFromAppScope, + systemRuntimeScope, + type RuntimeScope, +} from '@/shared/types/runtime-scope'; +import type { AppScope } from '@/shared/types/app-scope'; +import type { ProductAppRuntimeContext } from '@/shared/types/product-app-runtime'; +import type { SessionStorageScope } from '@/shared/types/session-history'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('NavigationController'); + +let navEpoch = 0; + +export function getNavigationEpoch(): number { + return navEpoch; +} + +export interface OpenWorkspaceSceneOptions { + scope?: RuntimeScope | null; + workspacePath?: string | null; + appScope?: AppScope | null; + context?: WorkspaceSurfaceContext | null; + runtimeContext?: ProductAppRuntimeContext | null; + historyMode?: WorkspaceSurfaceHistoryMode; +} + +export interface OpenWorkspaceSessionOptions { + context?: WorkspaceSurfaceContext | null; +} + +function resolveSceneScope(options: OpenWorkspaceSceneOptions): RuntimeScope { + if (options.scope) { + return options.scope; + } + if (options.appScope) { + return runtimeScopeFromAppScope(options.appScope); + } + if (options.workspacePath === null) { + return systemRuntimeScope(); + } + return projectRuntimeScopeFromWorkspacePath(options.workspacePath) ?? systemRuntimeScope(); +} + +function findLatestAgenticOsSessionId(): string | null { + return Array.from(flowChatStore.getState().sessions.values()) + .filter((session) => isSystemAgenticOsSession(session.descriptor)) + .sort( + (a, b) => + (b.lastActiveAt ?? b.createdAt ?? 0) - (a.lastActiveAt ?? a.createdAt ?? 0) + )[0]?.sessionId ?? null; +} + +async function loadSessionMetadataAttempt( + sessionId: string, + workspacePath: string | undefined, + storageScope: SessionStorageScope +): Promise { + try { + const metadata = await sessionAPI.loadSessionMetadata(sessionId, workspacePath, storageScope); + if (!metadata) { + return null; + } + await flowChatStore.hydrateWorkspaceSessionsMetadata( + [metadata], + metadata.workspacePath || workspacePath || '', + metadata.storageScope || storageScope, + ); + return flowChatStore.getState().sessions.get(sessionId) ?? null; + } catch (error) { + log.debug('Session metadata load attempt failed', { sessionId, workspacePath, storageScope, error }); + return null; + } +} + +async function ensureSessionInStore(sessionId: string): Promise { + const existing = flowChatStore.getState().sessions.get(sessionId); + if (existing) { + return existing; + } + + const agenticOsSession = await loadSessionMetadataAttempt(sessionId, undefined, 'agentic_os'); + if (agenticOsSession) { + return agenticOsSession; + } + + const openedWorkspaces = Array.from(workspaceManager.getState().openedWorkspaces.values()); + for (const workspace of openedWorkspaces) { + const workspaceSession = await loadSessionMetadataAttempt( + sessionId, + workspace.rootPath, + 'workspace', + ); + if (workspaceSession) { + return workspaceSession; + } + } + + log.warn('Session not found in store or on disk', { sessionId }); + return null; +} + +function commitSessionSurface( + session: Session, + options: OpenWorkspaceSessionOptions = {} +): void { + const sessionType = resolveSessionTypeDefinitionForDescriptor(session.descriptor); + const surfacePolicy = sessionType.lifecycle.defaultSurface; + + if (surfacePolicy === 'agentic-os-home') { + useWorkspaceSurfaceStore.getState().openSurface(createAgenticOsHomeSurface(), { + context: options.context, + currentOsSessionId: session.sessionId, + }); + return; + } + + if (surfacePolicy === 'session') { + useWorkspaceSurfaceStore.getState().openSurface( + { kind: 'session', sessionId: session.sessionId }, + { context: options.context }, + ); + return; + } + + log.warn('Session surface policy not handled for navigation', { + sessionId: session.sessionId, + surfacePolicy, + }); +} + +async function settleSessionActivation(sessionId: string): Promise { + flowChatStore.switchSession(sessionId); + syncSessionToModernStore(sessionId); + const { flowChatManager } = await import('@/flow_chat/services/FlowChatManager'); + await flowChatManager.activateSessionData(sessionId); +} + +export function openScene( + sceneId: WorkspaceSceneId, + options: OpenWorkspaceSceneOptions = {} +): void { + ++navEpoch; + useWorkspaceSurfaceStore.getState().openSurface({ + kind: 'scene', + sceneId, + scope: resolveSceneScope(options), + appScope: options.appScope, + runtimeContext: options.runtimeContext, + }, { + context: options.context, + historyMode: options.historyMode, + }); +} + +export function goBackScene(): boolean { + ++navEpoch; + return useWorkspaceSurfaceStore.getState().goBackScene(); +} + +export function openSceneHistoryEntry(index: number): boolean { + ++navEpoch; + return useWorkspaceSurfaceStore.getState().openSceneHistoryEntry(index); +} + +export async function openSession( + sessionId: string, + options: OpenWorkspaceSessionOptions = {} +): Promise { + const trimmedSessionId = sessionId.trim(); + if (!trimmedSessionId) { + return; + } + + const epoch = ++navEpoch; + const currentFocusedId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); + + const session = await ensureSessionInStore(trimmedSessionId); + if (epoch !== navEpoch) { + return; + } + if (!session) { + return; + } + + if (currentFocusedId === trimmedSessionId) { + syncSessionToModernStore(trimmedSessionId); + void import('@/flow_chat/services/FlowChatManager').then(({ flowChatManager }) => + flowChatManager.activateSessionData(trimmedSessionId) + ); + return; + } + + commitSessionSurface(session, options); + if (epoch !== navEpoch) { + return; + } + + await settleSessionActivation(trimmedSessionId); +} + +export async function openHome(options?: { + context?: WorkspaceSurfaceContext | null; + currentOsSessionId?: string | null; +}): Promise { + const epoch = ++navEpoch; + const state = useWorkspaceSurfaceStore.getState(); + + if (state.activeSurface.kind === 'agentic-os-home') { + const existingOsSessionId = options?.currentOsSessionId ?? state.currentOsSessionId; + if (existingOsSessionId) { + await openSession(existingOsSessionId, { context: options?.context }); + return existingOsSessionId; + } + } + + const resolvedOsSessionId = + options?.currentOsSessionId ?? + state.currentOsSessionId ?? + findLatestAgenticOsSessionId(); + + if (resolvedOsSessionId) { + if (epoch !== navEpoch) { + return null; + } + await openSession(resolvedOsSessionId, { context: options?.context }); + return resolvedOsSessionId; + } + + if (epoch !== navEpoch) { + return null; + } + + const newSessionId = await (async () => { + const { flowChatManager } = await import('@/flow_chat/services/FlowChatManager'); + return flowChatManager.createChatSession( + { storageScope: 'agentic_os', navigate: false }, + getAgenticOsSessionDescriptor(), + ); + })(); + + if (epoch !== navEpoch) { + return null; + } + + useWorkspaceSurfaceStore.getState().openSurface(createAgenticOsHomeSurface(), { + context: options?.context, + currentOsSessionId: newSessionId, + }); + await settleSessionActivation(newSessionId); + return newSessionId; +} + +export function getActiveWorkspaceSurface() { + return useWorkspaceSurfaceStore.getState().activeSurface; +} + +/** Synchronous home commit for startup — shows home chrome before async session resolve. */ +export function commitStartupHome(): void { + ++navEpoch; + useWorkspaceSurfaceStore.getState().openSurface(createAgenticOsHomeSurface()); +} diff --git a/src/web-ui/src/app/navigation/workspaceNavigation.ts b/src/web-ui/src/app/navigation/workspaceNavigation.ts index bcdb2ebf..97d4fcb2 100644 --- a/src/web-ui/src/app/navigation/workspaceNavigation.ts +++ b/src/web-ui/src/app/navigation/workspaceNavigation.ts @@ -1,134 +1,48 @@ +/** + * Workspace navigation — public API backed by navigationController. + * + * All session/scene switching should go through this module or navigationController + * so surface state, focus, and hydration stay in sync. + */ + import type { WorkspaceSceneId } from './workspaceSceneTypes'; -import { useWorkspaceSurfaceStore } from './workspaceSurfaceStore'; -import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; -import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { syncSessionToModernStore } from '@/flow_chat/services/storeSync'; -import { - getAgenticOsSessionDescriptor, - isSystemAgenticOsSession, -} from '@/flow_chat/domain/sessionDescriptor'; -import type { WorkspaceSurfaceContext } from './workspaceSurfaceTypes'; -import type { WorkspaceSurfaceHistoryMode } from './workspaceSurfaceStore'; -import type { AppScope } from '@/shared/types/app-scope'; -import type { ProductAppRuntimeContext } from '@/shared/types/product-app-runtime'; import { - projectRuntimeScopeFromWorkspacePath, - runtimeScopeFromAppScope, - systemRuntimeScope, - type RuntimeScope, -} from '@/shared/types/runtime-scope'; - -function isAgenticOsSession(sessionId: string): boolean { - const session = flowChatStore.getState().sessions.get(sessionId); - return !!session && isSystemAgenticOsSession(session.descriptor); -} - -function findLatestAgenticOsSessionId(): string | null { - return Array.from(flowChatStore.getState().sessions.values()) - .filter((session) => isSystemAgenticOsSession(session.descriptor)) - .sort( - (a, b) => - (b.lastActiveAt ?? b.createdAt ?? 0) - (a.lastActiveAt ?? a.createdAt ?? 0) - )[0]?.sessionId ?? null; -} - -export interface OpenWorkspaceSceneOptions { - scope?: RuntimeScope | null; - workspacePath?: string | null; - appScope?: AppScope | null; - context?: WorkspaceSurfaceContext | null; - runtimeContext?: ProductAppRuntimeContext | null; - historyMode?: WorkspaceSurfaceHistoryMode; -} - -export interface OpenWorkspaceSessionOptions { - context?: WorkspaceSurfaceContext | null; -} - -function resolveSceneScope(options: OpenWorkspaceSceneOptions): RuntimeScope { - if (options.scope) { - return options.scope; - } - if (options.appScope) { - return runtimeScopeFromAppScope(options.appScope); - } - if (options.workspacePath === null) { - return systemRuntimeScope(); - } - return projectRuntimeScopeFromWorkspacePath(options.workspacePath) ?? systemRuntimeScope(); -} + getActiveWorkspaceSurface, + goBackScene as goBackSceneController, + openHome, + openScene as openSceneController, + openSceneHistoryEntry as openSceneHistoryEntryController, + openSession as openSessionController, + type OpenWorkspaceSceneOptions, + type OpenWorkspaceSessionOptions, +} from './navigationController'; + +export type { OpenWorkspaceSceneOptions, OpenWorkspaceSessionOptions }; export function openWorkspaceScene( sceneId: WorkspaceSceneId, options: OpenWorkspaceSceneOptions = {} ): void { - useWorkspaceSurfaceStore.getState().openSurface({ - kind: 'scene', - sceneId, - scope: resolveSceneScope(options), - appScope: options.appScope, - runtimeContext: options.runtimeContext, - }, { - context: options.context, - historyMode: options.historyMode, - }); + openSceneController(sceneId, options); } export function goBackWorkspaceScene(): boolean { - return useWorkspaceSurfaceStore.getState().goBackScene(); + return goBackSceneController(); } export function openWorkspaceSceneHistoryEntry(index: number): boolean { - return useWorkspaceSurfaceStore.getState().openSceneHistoryEntry(index); + return openSceneHistoryEntryController(index); } export async function openWorkspaceSession( sessionId: string, options: OpenWorkspaceSessionOptions = {} ): Promise { - if (useWorkspaceSurfaceStore.getState().focusedSessionId === sessionId) { - syncSessionToModernStore(sessionId); - } else { - await flowChatManager.switchChatSession(sessionId); - syncSessionToModernStore(sessionId); - } - - if (isAgenticOsSession(sessionId)) { - useWorkspaceSurfaceStore.getState().openSurface({ - kind: 'agentic-os-home', - agenticOsSessionId: sessionId, - scope: systemRuntimeScope(), - }, { - context: options.context, - }); - return; - } - - useWorkspaceSurfaceStore.getState().openSurface( - { kind: 'session', sessionId }, - { context: options.context } - ); + await openSessionController(sessionId, options); } -export async function openWorkspaceHome(): Promise { - const agenticOsSessionId = findLatestAgenticOsSessionId(); - if (agenticOsSessionId) { - await openWorkspaceSession(agenticOsSessionId); - return agenticOsSessionId; - } - - const newSessionId = await flowChatManager.createChatSession( - { storageScope: 'agentic_os' }, - getAgenticOsSessionDescriptor() - ); - useWorkspaceSurfaceStore.getState().openSurface({ - kind: 'agentic-os-home', - agenticOsSessionId: newSessionId, - scope: systemRuntimeScope(), - }); - return newSessionId; +export async function openWorkspaceHome(): Promise { + return openHome(); } -export function getActiveWorkspaceSurface() { - return useWorkspaceSurfaceStore.getState().activeSurface; -} +export { getActiveWorkspaceSurface }; diff --git a/src/web-ui/src/app/navigation/workspaceSurfaceStore.test.ts b/src/web-ui/src/app/navigation/workspaceSurfaceStore.test.ts index e91fdb9f..02cb80f6 100644 --- a/src/web-ui/src/app/navigation/workspaceSurfaceStore.test.ts +++ b/src/web-ui/src/app/navigation/workspaceSurfaceStore.test.ts @@ -1,19 +1,21 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { selectCanGoBackScene, + selectComposerTargetSessionId, + selectFocusedSessionId, useWorkspaceSurfaceStore, WORKSPACE_SCENE_HISTORY_LIMIT, } from './workspaceSurfaceStore'; import type { WorkspaceSceneId } from './workspaceSceneTypes'; -import { isSameWorkspaceSurface, type WorkspaceSurface } from './workspaceSurfaceTypes'; +import { + createAgenticOsHomeSurface, + isSameWorkspaceSurface, + type WorkspaceSurface, +} from './workspaceSurfaceTypes'; import { systemRuntimeScope } from '@/shared/types/runtime-scope'; import type { ProductAppRuntimeContext } from '@/shared/types/product-app-runtime'; -const homeSurface: WorkspaceSurface = { - kind: 'agentic-os-home', - agenticOsSessionId: null, - scope: systemRuntimeScope(), -}; +const homeSurface = createAgenticOsHomeSurface(); function sceneSurface(sceneId: WorkspaceSceneId): Extract { return { @@ -48,10 +50,9 @@ function resetStore() { useWorkspaceSurfaceStore.setState({ activeSurface: homeSurface, previousSurface: null, + currentOsSessionId: null, sceneHistory: [], surfaceContext: null, - focusedSessionId: null, - composerTargetSessionId: null, }); } @@ -67,17 +68,19 @@ describe('workspaceSurfaceStore scene history', () => { store.openSurface(sceneSurface('settings')); expect(historyKeys()).toEqual(['apps']); - store.openSurface({ kind: 'agentic-os-home', agenticOsSessionId: 'agentic-home-1', scope: systemRuntimeScope() }); + store.openSurface(homeSurface, { currentOsSessionId: 'agentic-home-1' }); expect(useWorkspaceSurfaceStore.getState().sceneHistory).toEqual([]); expect(useWorkspaceSurfaceStore.getState().goBackScene()).toBe(false); expect(selectCanGoBackScene(useWorkspaceSurfaceStore.getState())).toBe(false); + expect(useWorkspaceSurfaceStore.getState().currentOsSessionId).toBe('agentic-home-1'); }); it('keeps Agentic OS home out of scene history', () => { const store = useWorkspaceSurfaceStore.getState(); useWorkspaceSurfaceStore.setState({ - activeSurface: { kind: 'agentic-os-home', agenticOsSessionId: 'agentic-home-1', scope: systemRuntimeScope() }, + activeSurface: homeSurface, + currentOsSessionId: 'agentic-home-1', sceneHistory: [{ surface: sceneSurface('apps') as Exclude, context: null, @@ -85,7 +88,7 @@ describe('workspaceSurfaceStore scene history', () => { }], }); expect(useWorkspaceSurfaceStore.getState().goBackScene()).toBe(false); - store.openSurface({ kind: 'agentic-os-home', agenticOsSessionId: 'agentic-home-1', scope: systemRuntimeScope() }); + store.openSurface(homeSurface, { currentOsSessionId: 'agentic-home-1' }); expect(useWorkspaceSurfaceStore.getState().sceneHistory).toEqual([]); expect(selectCanGoBackScene(useWorkspaceSurfaceStore.getState())).toBe(false); }); @@ -104,8 +107,8 @@ describe('workspaceSurfaceStore scene history', () => { kind: 'session', sessionId: 'session-1', }); - expect(useWorkspaceSurfaceStore.getState().focusedSessionId).toBe('session-1'); - expect(useWorkspaceSurfaceStore.getState().composerTargetSessionId).toBe('session-1'); + expect(selectFocusedSessionId(useWorkspaceSurfaceStore.getState())).toBe('session-1'); + expect(selectComposerTargetSessionId(useWorkspaceSurfaceStore.getState())).toBe('session-1'); expect(historyKeys()).toEqual([]); }); @@ -175,4 +178,14 @@ describe('workspaceSurfaceStore scene history', () => { expect(isSameWorkspaceSurface(first, second)).toBe(false); }); + + it('treats agentic-os-home as the same surface regardless of current OS session', () => { + const first = createAgenticOsHomeSurface(); + const second = createAgenticOsHomeSurface(); + expect(isSameWorkspaceSurface(first, second)).toBe(true); + + useWorkspaceSurfaceStore.getState().openSurface(first, { currentOsSessionId: 'session-a' }); + useWorkspaceSurfaceStore.getState().openSurface(second, { currentOsSessionId: 'session-b' }); + expect(useWorkspaceSurfaceStore.getState().currentOsSessionId).toBe('session-b'); + }); }); diff --git a/src/web-ui/src/app/navigation/workspaceSurfaceStore.ts b/src/web-ui/src/app/navigation/workspaceSurfaceStore.ts index f3912039..7b6f73b2 100644 --- a/src/web-ui/src/app/navigation/workspaceSurfaceStore.ts +++ b/src/web-ui/src/app/navigation/workspaceSurfaceStore.ts @@ -3,6 +3,7 @@ import { getSceneNav } from '../scenes/nav-registry'; import { useNavSceneStore } from '../stores/navSceneStore'; import type { WorkspaceSceneId } from './workspaceSceneTypes'; import { + createAgenticOsHomeSurface, isSameWorkspaceSurface, type WorkspaceSurfaceContext, type WorkspaceSurface, @@ -21,27 +22,26 @@ export interface WorkspaceSceneHistoryEntry { export type WorkspaceSurfaceHistoryMode = 'push' | 'restore'; -interface OpenSurfaceOptions { +export interface OpenSurfaceOptions { context?: WorkspaceSurfaceContext | null; historyMode?: WorkspaceSurfaceHistoryMode; + /** When opening agentic-os-home, sets which OS session is shown on the home surface. */ + currentOsSessionId?: string | null; } interface WorkspaceSurfaceState { activeSurface: WorkspaceSurface; previousSurface: WorkspaceSurface | null; + currentOsSessionId: string | null; sceneHistory: WorkspaceSceneHistoryEntry[]; surfaceContext: WorkspaceSurfaceContext | null; - focusedSessionId: string | null; - composerTargetSessionId: string | null; openSurface: (surface: WorkspaceSurface, options?: OpenSurfaceOptions) => void; goBackScene: () => boolean; openSceneHistoryEntry: (index: number) => boolean; clearSceneHistory: () => void; - focusSession: (sessionId: string | null) => void; - setComposerTargetSession: (sessionId: string | null) => void; clearSurfaceContext: () => void; forgetSessions: (sessionIds: readonly string[]) => void; - returnHome: (agenticOsSessionId?: string | null) => void; + returnHome: (currentOsSessionId?: string | null) => void; } function resolveNavSceneId(id: WorkspaceSceneId): WorkspaceSceneId | null { @@ -98,33 +98,41 @@ function pushSceneHistory( ].slice(0, WORKSPACE_SCENE_HISTORY_LIMIT); } -function getFocusedSessionId(surface: WorkspaceSurface): string | null { - return surface.kind === 'session' - ? surface.sessionId - : surface.kind === 'agentic-os-home' - ? surface.agenticOsSessionId - : null; +export function selectFocusedSessionId(state: WorkspaceSurfaceState): string | null { + if (state.activeSurface.kind === 'session') { + return state.activeSurface.sessionId; + } + if (state.activeSurface.kind === 'agentic-os-home') { + return state.currentOsSessionId; + } + return null; } +export const selectComposerTargetSessionId = selectFocusedSessionId; + export const useWorkspaceSurfaceStore = create((set, get) => ({ - activeSurface: { kind: 'agentic-os-home', agenticOsSessionId: null, scope: systemRuntimeScope() }, + activeSurface: createAgenticOsHomeSurface(), previousSurface: null, + currentOsSessionId: null, sceneHistory: [], surfaceContext: null, - focusedSessionId: null, - composerTargetSessionId: null, openSurface: (surface, options = {}) => { const state = get(); const current = state.activeSurface; const nextSurfaceContext = options.context ?? null; - const nextFocusedSessionId = getFocusedSessionId(surface); + const nextCurrentOsSessionId = + surface.kind === 'agentic-os-home' + ? (options.currentOsSessionId !== undefined + ? options.currentOsSessionId + : state.currentOsSessionId) + : state.currentOsSessionId; + if (isSameWorkspaceSurface(current, surface)) { set({ sceneHistory: surface.kind === 'agentic-os-home' ? [] : state.sceneHistory, surfaceContext: nextSurfaceContext, - focusedSessionId: nextFocusedSessionId, - composerTargetSessionId: nextFocusedSessionId, + currentOsSessionId: nextCurrentOsSessionId, }); syncSceneNav(surface); return; @@ -142,8 +150,7 @@ export const useWorkspaceSurfaceStore = create((set, get) previousSurface: current, sceneHistory: nextSceneHistory, surfaceContext: nextSurfaceContext, - focusedSessionId: nextFocusedSessionId, - composerTargetSessionId: nextFocusedSessionId, + currentOsSessionId: nextCurrentOsSessionId, }); syncSceneNav(surface); }, @@ -163,14 +170,12 @@ export const useWorkspaceSurfaceStore = create((set, get) if (!entry) return false; const nextHistory = state.sceneHistory.filter((_, i) => i !== index); - const nextFocusedSessionId = getFocusedSessionId(entry.surface); set({ activeSurface: entry.surface, previousSurface: state.activeSurface, sceneHistory: nextHistory, surfaceContext: entry.context, - focusedSessionId: nextFocusedSessionId, - composerTargetSessionId: nextFocusedSessionId, + currentOsSessionId: state.currentOsSessionId, }); syncSceneNav(entry.surface); return true; @@ -180,17 +185,6 @@ export const useWorkspaceSurfaceStore = create((set, get) set({ sceneHistory: [] }); }, - focusSession: (sessionId) => { - set({ - focusedSessionId: sessionId, - composerTargetSessionId: sessionId, - }); - }, - - setComposerTargetSession: (sessionId) => { - set({ composerTargetSessionId: sessionId }); - }, - clearSurfaceContext: () => { set({ surfaceContext: null }); }, @@ -202,12 +196,13 @@ export const useWorkspaceSurfaceStore = create((set, get) set((state) => { const activeSurface = state.activeSurface.kind === 'session' && removedSessionIds.has(state.activeSurface.sessionId) - ? { kind: 'agentic-os-home', agenticOsSessionId: null, scope: systemRuntimeScope() } as WorkspaceSurface - : state.activeSurface.kind === 'agentic-os-home' && - state.activeSurface.agenticOsSessionId && - removedSessionIds.has(state.activeSurface.agenticOsSessionId) - ? { kind: 'agentic-os-home', agenticOsSessionId: null, scope: systemRuntimeScope() } as WorkspaceSurface - : state.activeSurface; + ? createAgenticOsHomeSurface() + : state.activeSurface; + + const nextCurrentOsSessionId = + state.currentOsSessionId && removedSessionIds.has(state.currentOsSessionId) + ? null + : state.currentOsSessionId; const nextSceneHistory = state.sceneHistory.filter((entry) => ( entry.surface.kind !== 'session' || !removedSessionIds.has(entry.surface.sessionId) @@ -217,21 +212,16 @@ export const useWorkspaceSurfaceStore = create((set, get) activeSurface, surfaceContext: activeSurface === state.activeSurface ? state.surfaceContext : null, sceneHistory: activeSurface.kind === 'agentic-os-home' ? [] : nextSceneHistory, - focusedSessionId: - state.focusedSessionId && removedSessionIds.has(state.focusedSessionId) - ? null - : state.focusedSessionId, - composerTargetSessionId: - state.composerTargetSessionId && removedSessionIds.has(state.composerTargetSessionId) - ? null - : state.composerTargetSessionId, + currentOsSessionId: nextCurrentOsSessionId, }; }); syncSceneNav(get().activeSurface); }, - returnHome: (agenticOsSessionId = null) => { - get().openSurface({ kind: 'agentic-os-home', agenticOsSessionId, scope: systemRuntimeScope() }); + returnHome: (currentOsSessionId) => { + get().openSurface(createAgenticOsHomeSurface(), { + currentOsSessionId: currentOsSessionId !== undefined ? currentOsSessionId : get().currentOsSessionId, + }); }, })); @@ -246,3 +236,14 @@ export function selectIsHomeSurface(state: WorkspaceSurfaceState): boolean { export function selectCanGoBackScene(state: WorkspaceSurfaceState): boolean { return state.activeSurface.kind !== 'agentic-os-home' && state.sceneHistory.length > 0; } + +/** @deprecated Use selectFocusedSessionId instead */ +export function getFocusedSessionIdFromSurface(state: WorkspaceSurfaceState): string | null { + return selectFocusedSessionId(state); +} + +export function homeSurfaceWithScope(): WorkspaceSurface { + return createAgenticOsHomeSurface(); +} + +export { systemRuntimeScope }; diff --git a/src/web-ui/src/app/navigation/workspaceSurfaceTypes.ts b/src/web-ui/src/app/navigation/workspaceSurfaceTypes.ts index bc7fd856..57f2f87f 100644 --- a/src/web-ui/src/app/navigation/workspaceSurfaceTypes.ts +++ b/src/web-ui/src/app/navigation/workspaceSurfaceTypes.ts @@ -1,14 +1,14 @@ import type { WorkspaceSceneId } from './workspaceSceneTypes'; import { appScopeIdentity } from '@/shared/types/app-scope'; import type { AppScope } from '@/shared/types/app-scope'; -import { runtimeScopeIdentity, type RuntimeScope } from '@/shared/types/runtime-scope'; +import { runtimeScopeIdentity, systemRuntimeScope, type RuntimeScope } from '@/shared/types/runtime-scope'; import type { ProductAppRuntimeContext } from '@/shared/types/product-app-runtime'; export type WorkspaceSurfaceContext = | { kind: 'work'; workId: string }; export type WorkspaceSurface = - | { kind: 'agentic-os-home'; agenticOsSessionId: string | null; scope: RuntimeScope } + | { kind: 'agentic-os-home'; scope: RuntimeScope } | { kind: 'scene'; sceneId: WorkspaceSceneId; @@ -27,8 +27,6 @@ export function isSameWorkspaceSurface(a: WorkspaceSurface, b: WorkspaceSurface) switch (a.kind) { case 'agentic-os-home': return ( - a.agenticOsSessionId === - (b as Extract).agenticOsSessionId && runtimeScopeIdentity(a.scope) === runtimeScopeIdentity((b as Extract).scope) ); @@ -46,3 +44,7 @@ export function isSameWorkspaceSurface(a: WorkspaceSurface, b: WorkspaceSurface) return a.sessionId === (b as Extract).sessionId; } } + +export function createAgenticOsHomeSurface(): Extract { + return { kind: 'agentic-os-home', scope: systemRuntimeScope() }; +} diff --git a/src/web-ui/src/app/scenes/session/SessionScene.tsx b/src/web-ui/src/app/scenes/session/SessionScene.tsx index 5f20072c..6e166c74 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.tsx +++ b/src/web-ui/src/app/scenes/session/SessionScene.tsx @@ -14,10 +14,7 @@ import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next'; import { useApp } from '../../hooks/useApp'; import { useSessionProfile } from '../../session-profiles'; -import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { useSessionGoalSnapshot } from '@/flow_chat/store/sessionGoalStore'; -import { useWorkspaceSurfaceStore } from '../../navigation/workspaceSurfaceStore'; import ChatPane from './ChatPane'; import AuxPane, { type AuxPaneRef } from './AuxPane'; @@ -73,20 +70,6 @@ const SessionScene: React.FC = ({ const currentRightWidth = state.layout.rightPanelWidth || RIGHT_PANEL_CONFIG.COMFORTABLE_DEFAULT; - useEffect(() => { - const targetSessionId = surfaceSessionId?.trim(); - if (!targetSessionId) return; - - useWorkspaceSurfaceStore.getState().focusSession(targetSessionId); - - const flowState = flowChatStore.getState(); - if (!flowState.sessions.has(targetSessionId)) { - return; - } - - void flowChatManager.switchChatSession(targetSessionId); - }, [surfaceSessionId]); - const rightPanelMode: PanelDisplayMode = useMemo(() => { if (state.layout.rightPanelCollapsed) return 'collapsed'; return getPanelDisplayMode(currentRightWidth, RIGHT_PANEL_CONFIG); diff --git a/src/web-ui/src/app/session-profiles/SessionProfileProvider.tsx b/src/web-ui/src/app/session-profiles/SessionProfileProvider.tsx index 006e5e1d..48d363ed 100644 --- a/src/web-ui/src/app/session-profiles/SessionProfileProvider.tsx +++ b/src/web-ui/src/app/session-profiles/SessionProfileProvider.tsx @@ -1,33 +1,23 @@ /** * Session profile provider — wires React Context for the active session's profile. * - * SessionProfileProvider reads the active session descriptor from the FlowChat - * store, resolves the matching SessionProfile, and makes it available to the - * entire component tree via useSessionProfile(). - * - * The resolved profile object is a module-level constant, so the Context value - * reference only changes when the session type actually switches — no spurious - * re-renders for consumers. + * Profile resolution follows navigation focus from workspaceSurfaceStore only. */ import React, { useMemo } from 'react'; import { resolveProfile } from './SessionProfileRegistry'; import { SessionProfileContext, type SessionProfileContextValue } from './SessionProfileReactContext'; import { useFlowChatStoreSelector } from '@/flow_chat/hooks/useFlowChatStoreSelector'; -import { useWorkspaceSurfaceStore } from '../navigation/workspaceSurfaceStore'; +import { + selectFocusedSessionId, + useWorkspaceSurfaceStore, +} from '../navigation/workspaceSurfaceStore'; export const SessionProfileProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const activeSurface = useWorkspaceSurfaceStore((state) => state.activeSurface); - const focusedSessionId = useWorkspaceSurfaceStore((state) => state.focusedSessionId); - const profileId = useFlowChatStoreSelector((state) => { - const sessionId = - activeSurface.kind === 'agentic-os-home' - ? activeSurface.agenticOsSessionId ?? focusedSessionId - : activeSurface.kind === 'session' - ? activeSurface.sessionId - : focusedSessionId; - return sessionId ? state.sessions.get(sessionId)?.descriptor?.profileId : undefined; - }); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); + const profileId = useFlowChatStoreSelector((state) => ( + focusedSessionId ? state.sessions.get(focusedSessionId)?.descriptor?.profileId : undefined + )); const value = useMemo( () => ({ profile: resolveProfile(profileId) }), diff --git a/src/web-ui/src/app/surfaces/AgenticOSWorkspace.tsx b/src/web-ui/src/app/surfaces/AgenticOSWorkspace.tsx index 2ee3e9ba..8fcacd95 100644 --- a/src/web-ui/src/app/surfaces/AgenticOSWorkspace.tsx +++ b/src/web-ui/src/app/surfaces/AgenticOSWorkspace.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { appRuntime } from '@/infrastructure/app-runtime'; import { useDialogCompletionNotify } from '../hooks/useDialogCompletionNotify'; import { useSessionProfile } from '../session-profiles'; -import { useWorkspaceSurfaceStore } from '../navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '../navigation/workspaceSurfaceStore'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { projectWorkspacePathFromRuntimeScope, @@ -23,7 +23,8 @@ const AgenticOSWorkspace: React.FC = ({ isEntering = false, }) => { const activeSurface = useWorkspaceSurfaceStore((s) => s.activeSurface); - const focusedSessionId = useWorkspaceSurfaceStore((s) => s.focusedSessionId); + const currentOsSessionId = useWorkspaceSurfaceStore((s) => s.currentOsSessionId); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); const { profile } = useSessionProfile(); useDialogCompletionNotify(); @@ -34,7 +35,7 @@ const AgenticOSWorkspace: React.FC = ({ activeSurface.kind === 'session' ? activeSurface.sessionId : activeSurface.kind === 'agentic-os-home' - ? activeSurface.agenticOsSessionId ?? focusedSessionId ?? undefined + ? currentOsSessionId ?? focusedSessionId ?? undefined : focusedSessionId ?? undefined; const sessionScope = activeSessionId ? runtimeScopeFromSession(flowChatStore.getState().sessions.get(activeSessionId)) @@ -50,7 +51,7 @@ const AgenticOSWorkspace: React.FC = ({ activeSessionId: activeSessionId ?? undefined, }; }); - }, [activeSurface, focusedSessionId]); + }, [activeSurface, currentOsSessionId, focusedSessionId]); const workspaceClassName = [ 'agentic-os-workspace', diff --git a/src/web-ui/src/app/surfaces/SurfaceRenderer.tsx b/src/web-ui/src/app/surfaces/SurfaceRenderer.tsx index 7f80478d..d21aeb40 100644 --- a/src/web-ui/src/app/surfaces/SurfaceRenderer.tsx +++ b/src/web-ui/src/app/surfaces/SurfaceRenderer.tsx @@ -17,6 +17,7 @@ import { type RuntimeScope, } from '@/shared/types/runtime-scope'; import { useFlowChatStoreSelector } from '@/flow_chat/hooks/useFlowChatStoreSelector'; +import { useWorkspaceSurfaceStore } from '../navigation/workspaceSurfaceStore'; import SessionScene from '../scenes/session/SessionScene'; import SettingsScene from '../scenes/settings/SettingsScene'; import AppsScene from '../scenes/apps/AppsScene'; @@ -43,6 +44,7 @@ const SurfaceRenderer: React.FC = ({ isEntering = false, }) => { const { t } = useI18n('common'); + const currentOsSessionId = useWorkspaceSurfaceStore(state => state.currentOsSessionId); const sessionScope = useFlowChatStoreSelector( (state) => { if (surface.kind === 'agentic-os-home') { @@ -71,7 +73,7 @@ const SurfaceRenderer: React.FC = ({ } > - {renderSurface(surface, sessionScope, isEntering)} + {renderSurface(surface, sessionScope, isEntering, currentOsSessionId)} @@ -81,14 +83,15 @@ const SurfaceRenderer: React.FC = ({ function renderSurface( surface: WorkspaceSurface, sessionScope: RuntimeScope | null, - isEntering: boolean + isEntering: boolean, + currentOsSessionId: string | null, ): React.ReactNode { switch (surface.kind) { case 'agentic-os-home': return ( diff --git a/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx b/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx index ff26ca7e..b369a446 100644 --- a/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx +++ b/src/web-ui/src/flow_chat/components/CurrentSessionTitle.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Plus } from 'lucide-react'; import { IconButton, Tooltip } from '@/design-system'; import { useFlowChatStoreSelector } from '../hooks/useFlowChatStoreSelector'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; import './CurrentSessionTitle.scss'; interface CurrentSessionTitleProps { @@ -16,7 +16,7 @@ interface CurrentSessionTitleProps { */ const CurrentSessionTitle: React.FC = ({ onCreateSession }) => { const { t } = useTranslation('flow-chat'); - const focusedSessionId = useWorkspaceSurfaceStore(state => state.focusedSessionId); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); const titleValue = useFlowChatStoreSelector((state) => { const activeSession = focusedSessionId ? state.sessions.get(focusedSessionId) diff --git a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss b/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss deleted file mode 100644 index 97b91557..00000000 --- a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.scss +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Scroll-to-bottom button styles - minimal. - */ - -.scroll-to-bottom-button { - position: absolute; - bottom: 65px; /* Closer to the bottom */ - right: 20px; - - display: flex; - align-items: center; - justify-content: center; - - width: 40px; - height: 40px; - padding: 0; - - // Theme-aware surface so the control separates from the transcript; icon uses primary text (SVG stroke) - background: color-mix(in srgb, var(--ds-color-bg-elevated) 92%, transparent); - color: var(--ds-chat-text-primary); - - border: 2px solid var(--ds-chat-text-primary); - border-radius: 10px; - - backdrop-filter: blur(14px) saturate(1.15); - -webkit-backdrop-filter: blur(14px) saturate(1.15); - - box-shadow: - 0 4px 18px color-mix(in srgb, var(--ds-color-bg-app) 38%, transparent), - 0 0 0 1px color-mix(in srgb, var(--ds-chat-text-primary) 28%, transparent), - inset 0 1px 0 color-mix(in srgb, var(--ds-chat-text-primary) 12%, transparent); - - cursor: pointer; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - - z-index: var(--ds-z-sticky); - - &:hover { - background: color-mix(in srgb, var(--ds-color-bg-elevated) 98%, transparent); - color: var(--ds-chat-text-primary); - border-color: var(--ds-chat-text-primary); - transform: translateY(-2px); - - box-shadow: - 0 8px 22px color-mix(in srgb, var(--ds-color-bg-app) 42%, transparent), - 0 0 0 1px color-mix(in srgb, var(--ds-chat-text-primary) 40%, transparent), - inset 0 1px 0 color-mix(in srgb, var(--ds-chat-text-primary) 16%, transparent); - } - - &:active { - transform: translateY(0); - transition: all 0.1s ease; - } - - &:focus-visible { - outline: 3px solid var(--ds-chat-text-primary); - outline-offset: 3px; - } - - .scroll-icon { - flex-shrink: 0; - } - - .unread-badge { - position: absolute; - top: -6px; - right: -6px; - - min-width: 16px; - height: 16px; - padding: 0 4px; - - display: flex; - align-items: center; - justify-content: center; - - // Glass-like badge - background: color-mix(in srgb, var(--ds-status-surface-info-fg) 90%, transparent); - color: var(--ds-color-text-inverse); - border-radius: 8px; - border: 1px solid color-mix(in srgb, var(--ds-status-surface-info-fg) 30%, transparent); - - // Frosted effect - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - - font-size: 9px; - font-weight: 600; - line-height: 1; - - box-shadow: - 0 2px 8px color-mix(in srgb, var(--ds-status-surface-info-fg) 40%, transparent), - inset 0 1px 0 color-mix(in srgb, var(--ds-chat-text-primary) 20%, transparent); - } -} - -// Responsive tweaks -@media (max-width: 768px) { - .scroll-to-bottom-button { - bottom: 60px; /* Closer to the bottom on mobile */ - right: 16px; - width: 40px; - height: 40px; - } -} - diff --git a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.tsx b/src/web-ui/src/flow_chat/components/ScrollToBottomButton.tsx deleted file mode 100644 index f8305e6a..00000000 --- a/src/web-ui/src/flow_chat/components/ScrollToBottomButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Scroll-to-bottom button. - * Shows when the user scrolls up; click to return to latest messages. - */ - -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { IconButton, Tooltip } from '@/design-system'; -import './ScrollToBottomButton.scss'; - -interface ScrollToBottomButtonProps { - visible: boolean; - onClick: () => void; - unreadCount?: number; // Optional: show unread message count. - className?: string; -} - -export const ScrollToBottomButton: React.FC = ({ - visible, - onClick, - unreadCount, - className = '' -}) => { - const { t } = useTranslation('flow-chat'); - - if (!visible) return null; - - return ( - - - - {unreadCount !== undefined && unreadCount > 0 && ( - {unreadCount > 99 ? '99+' : unreadCount} - )} - - - ); -}; - diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.css b/src/web-ui/src/flow_chat/components/WelcomePanel.css index 84a2565c..c5645eb9 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.css +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.css @@ -219,18 +219,24 @@ animation: wp-dropdownIn 0.15s ease-out; } -.welcome-panel__dropdown-current { +.welcome-panel__dropdown-header { display: flex; align-items: center; gap: 8px; - padding: 10px 14px; + min-width: 0; + padding: 8px; font-size: 13px; font-weight: 600; color: var(--ds-chat-text-primary); } -.welcome-panel__dropdown-current svg:first-child { - color: var(--ds-chat-accent); +.welcome-panel__dropdown-search { + flex: 1; + min-width: 0; +} + +.welcome-panel__dropdown-open-workspace { + color: var(--ds-chat-text-secondary); } .welcome-panel__dropdown-sep { @@ -239,6 +245,14 @@ background: var(--ds-chat-border); } +.welcome-panel__dropdown-scroll { + max-height: min(280px, calc(100vh - 148px)); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + .welcome-panel__dropdown-option { display: flex; align-items: center; @@ -262,6 +276,11 @@ transition: background 0.12s, color 0.12s; } +.welcome-panel__dropdown-option--selected { + background: var(--ds-color-element-soft); + color: var(--ds-chat-text-primary); +} + .welcome-panel__dropdown-option:hover { background: var(--ds-color-element-soft); color: var(--ds-chat-text-primary); @@ -281,6 +300,15 @@ color: var(--ds-color-accent-600); } +.welcome-panel__dropdown-check { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 12px; + color: var(--ds-chat-accent); +} + .welcome-panel__dropdown-name { flex: 1; min-width: 0; @@ -289,6 +317,13 @@ white-space: nowrap; } +.welcome-panel__dropdown-empty { + padding: 12px 14px; + color: var(--ds-chat-text-muted); + font-size: 13px; + line-height: 1.4; +} + /* ── Cowork ── */ .welcome-panel__cowork { diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index e0e572ae..1523b929 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { useTranslation } from 'react-i18next'; import { FolderOpen, + FolderPlus, ChevronDown, Check, Orbit, @@ -18,14 +19,16 @@ import { } from 'lucide-react'; import { AppBuilderGlyph } from '@/app/scenes/apps/app-builder/AppBuilderGlyph'; import { createLogger } from '@/shared/utils/logger'; +import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; import { getWorkspaceDisplayName, useWorkspaceContext, } from '@/infrastructure/contexts/WorkspaceContext'; import type { WorkspaceInfo } from '@/shared/types'; +import type { SessionDescriptor } from '@/flow_chat/domain/sessionDescriptor'; import { isSamePath } from '@/shared/utils/pathUtils'; import { resolveSessionTypeDefinition, useSessionProfile } from '@/app/session-profiles'; -import { Button } from '@/design-system'; +import { Button, IconButton, Search } from '@/design-system'; import { fallbackWorkspaceFolderLabel, resolveWorkspaceForSession, @@ -52,7 +55,9 @@ const APP_BUILDER_PROMPTS: AppBuilderPrompt[] = [ interface WelcomePanelProps { onQuickAction?: (command: string) => void; className?: string; + sessionId?: string; workspacePath?: string; + preferredDescriptor?: SessionDescriptor; } interface WelcomeWorkspaceTarget { @@ -64,11 +69,14 @@ interface WelcomeWorkspaceTarget { export const WelcomePanel: React.FC = ({ onQuickAction, className = '', + sessionId, workspacePath, + preferredDescriptor, }) => { const { t } = useTranslation('flow-chat'); const [workspaceDropdownOpen, setWorkspaceDropdownOpen] = useState(false); const [isSelectingWorkspace, setIsSelectingWorkspace] = useState(false); + const [workspaceSearchQuery, setWorkspaceSearchQuery] = useState(''); const workspaceDropdownRef = useRef(null); const { profile } = useSessionProfile(); @@ -81,6 +89,10 @@ export const WelcomePanel: React.FC = ({ const sessionType = useMemo(() => resolveSessionTypeDefinition(profile.id), [profile.id]); const welcome = sessionType.welcome; + const workspaceSwitchDescriptor = useMemo( + () => preferredDescriptor ?? sessionType.descriptorDefaults, + [preferredDescriptor, sessionType.descriptorDefaults], + ); const sessionWorkspaceTarget = useMemo(() => { const scopedPath = workspacePath?.trim(); @@ -105,13 +117,15 @@ export const WelcomePanel: React.FC = ({ const welcomeWorkspace = useMemo(() => { if (sessionWorkspaceTarget) return sessionWorkspaceTarget; - if (!lastUsedWorkspace) return null; - const displayName = getWorkspaceDisplayName(lastUsedWorkspace).trim(); - return { - id: lastUsedWorkspace.id, - name: displayName || fallbackWorkspaceFolderLabel(lastUsedWorkspace.rootPath) || lastUsedWorkspace.rootPath, - rootPath: lastUsedWorkspace.rootPath, - }; + if (lastUsedWorkspace) { + const displayName = getWorkspaceDisplayName(lastUsedWorkspace).trim(); + return { + id: lastUsedWorkspace.id, + name: displayName || fallbackWorkspaceFolderLabel(lastUsedWorkspace.rootPath) || lastUsedWorkspace.rootPath, + rootPath: lastUsedWorkspace.rootPath, + }; + } + return null; }, [lastUsedWorkspace, sessionWorkspaceTarget]); const hasWelcomeWorkspace = Boolean(welcomeWorkspace); @@ -128,15 +142,28 @@ export const WelcomePanel: React.FC = ({ const tagline = greeting.subtitle; const aiPartnerKey = welcome.aiPartnerKey; - const otherWorkspaces = useMemo( - () => openedWorkspacesList.filter((ws) => { - if (!welcomeWorkspace) return true; - if (welcomeWorkspace.id && ws.id === welcomeWorkspace.id) return false; - if (welcomeWorkspace.rootPath && isSamePath(ws.rootPath, welcomeWorkspace.rootPath)) return false; + const selectedWorkspaceTarget = useMemo(() => { + return welcomeWorkspace; + }, [welcomeWorkspace]); + + const filteredWorkspaces = useMemo(() => { + const query = workspaceSearchQuery.trim().toLocaleLowerCase(); + if (!query) return openedWorkspacesList; + return openedWorkspacesList.filter((ws) => { + const name = getWorkspaceDisplayName(ws).toLocaleLowerCase(); + const rootPath = ws.rootPath.toLocaleLowerCase(); + return name.includes(query) || rootPath.includes(query); + }); + }, [openedWorkspacesList, workspaceSearchQuery]); + + const isSelectedWorkspace = useCallback((ws: WorkspaceInfo) => { + if (!selectedWorkspaceTarget) return false; + if (selectedWorkspaceTarget.id && ws.id === selectedWorkspaceTarget.id) return true; + if (selectedWorkspaceTarget.rootPath && isSamePath(ws.rootPath, selectedWorkspaceTarget.rootPath)) { return true; - }), - [openedWorkspacesList, welcomeWorkspace], - ); + } + return false; + }, [selectedWorkspaceTarget]); useEffect(() => { if (!workspaceDropdownOpen) return; @@ -149,10 +176,24 @@ export const WelcomePanel: React.FC = ({ return () => document.removeEventListener('mousedown', handler); }, [workspaceDropdownOpen]); + useEffect(() => { + if (!workspaceDropdownOpen) { + setWorkspaceSearchQuery(''); + } + }, [workspaceDropdownOpen]); + const handleSwitchWorkspace = useCallback(async (ws: WorkspaceInfo) => { - try { setWorkspaceDropdownOpen(false); await switchWorkspace(ws); } - catch (err) { log.warn('Failed to switch workspace', err); } - }, [switchWorkspace]); + try { + setWorkspaceDropdownOpen(false); + const workspace = await switchWorkspace(ws); + if (sessionId) { + await FlowChatManager.getInstance().retargetEmptySessionWorkspace(sessionId, workspace, { + preferredDescriptor: workspaceSwitchDescriptor, + }); + } + } + catch (err) { log.warn('Failed to switch workspace', { error: err }); } + }, [sessionId, switchWorkspace, workspaceSwitchDescriptor]); const handleOpenOtherFolder = useCallback(async () => { try { @@ -160,13 +201,20 @@ export const WelcomePanel: React.FC = ({ setIsSelectingWorkspace(true); const { open } = await import('@tauri-apps/plugin-dialog'); const selected = await open({ directory: true, multiple: false }); - if (selected && typeof selected === 'string') await openWorkspace(selected); + if (selected && typeof selected === 'string') { + const workspace = await openWorkspace(selected); + if (sessionId) { + await FlowChatManager.getInstance().retargetEmptySessionWorkspace(sessionId, workspace, { + preferredDescriptor: workspaceSwitchDescriptor, + }); + } + } } catch (err) { - log.warn('Failed to open workspace folder', err); + log.warn('Failed to open workspace folder', { error: err }); } finally { setIsSelectingWorkspace(false); } - }, [openWorkspace]); + }, [openWorkspace, sessionId, workspaceSwitchDescriptor]); const handleQuickActionClick = useCallback((cmd: string) => { onQuickAction?.(cmd); @@ -255,32 +303,63 @@ export const WelcomePanel: React.FC = ({ {workspaceDropdownOpen && (
- {welcomeWorkspace && ( -
- - - {welcomeWorkspace.name} -
- )} - {otherWorkspaces.length > 0 && ( - <> - {hasWelcomeWorkspace &&
} - {otherWorkspaces.map(ws => ( - - ))} - - )} +
+ + { void handleOpenOtherFolder(); }} + > + + +
+
+
+ {filteredWorkspaces.length > 0 ? ( + filteredWorkspaces.map(ws => { + const selected = isSelectedWorkspace(ws); + const displayName = getWorkspaceDisplayName(ws); + return ( + + ); + }) + ) : ( +
+ {t('welcome.workspaceSearchEmpty')} +
+ )} +
)} diff --git a/src/web-ui/src/flow_chat/components/composer/hooks/useComposerSessionTarget.ts b/src/web-ui/src/flow_chat/components/composer/hooks/useComposerSessionTarget.ts index f86f7873..2ee36eef 100644 --- a/src/web-ui/src/flow_chat/components/composer/hooks/useComposerSessionTarget.ts +++ b/src/web-ui/src/flow_chat/components/composer/hooks/useComposerSessionTarget.ts @@ -8,7 +8,7 @@ import { import { resolveSessionRelationship } from '../../../utils/sessionMetadata'; import type { ChatInputTarget } from '../model/composerState'; import { useFlowChatStoreSelector } from '../../../hooks/useFlowChatStoreSelector'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; interface UseComposerSessionTargetParams { explicitSessionId?: string | null; @@ -24,8 +24,7 @@ export function useComposerSessionTarget({ t, }: UseComposerSessionTargetParams) { const activeSessionState = useActiveSessionState(); - const composerTargetSessionId = useWorkspaceSurfaceStore(state => state.composerTargetSessionId); - const focusedSessionId = useWorkspaceSurfaceStore(state => state.focusedSessionId); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); const activeBtwSessionTab = useAgentCanvasStore( state => selectActiveSideThreadSessionTab(state) ); @@ -33,7 +32,6 @@ export function useComposerSessionTarget({ const explicitTargetSessionId = explicitSessionId?.trim() || null; const currentSessionId = explicitTargetSessionId ?? - composerTargetSessionId ?? focusedSessionId ?? activeSessionState.sessionId; const activeBtwSessionData = activeBtwSessionTab?.content.data as diff --git a/src/web-ui/src/flow_chat/components/index.ts b/src/web-ui/src/flow_chat/components/index.ts index 988e4977..1a18c0a4 100644 --- a/src/web-ui/src/flow_chat/components/index.ts +++ b/src/web-ui/src/flow_chat/components/index.ts @@ -1,5 +1,4 @@ export { ChatInput } from './ChatInput'; -export { ScrollToBottomButton } from './ScrollToBottomButton'; export { ScrollToLatestBar } from './ScrollToLatestBar'; export { CodePreview } from './CodePreview'; export { InlineDiffPreview } from './InlineDiffPreview'; diff --git a/src/web-ui/src/flow_chat/components/modern/AgenticOSFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/AgenticOSFlowChatContainer.tsx index 6cff2165..23104403 100644 --- a/src/web-ui/src/flow_chat/components/modern/AgenticOSFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/AgenticOSFlowChatContainer.tsx @@ -9,8 +9,8 @@ import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; -import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { flowChatManager } from '../../services/FlowChatManager'; +import { openSession } from '@/app/navigation/navigationController'; +import { FlowChatManager, flowChatManager } from '../../services/FlowChatManager'; import { VirtualMessageList } from './VirtualMessageList'; import { FlowChatHeader } from './FlowChatHeader'; import { AgenticOsTimelineSidebar } from './AgenticOsTimelineSidebar'; @@ -246,7 +246,7 @@ export const AgenticOSFlowChatContainer: React.FC prev + 1); - await flowChatManager.switchChatSession(sessionId); + await openSession(sessionId); window.setTimeout(() => { if ( pendingCrossSessionTargetRef.current?.sessionId === sessionId && @@ -272,7 +272,7 @@ export const AgenticOSFlowChatContainer: React.FC prev + 1); - await flowChatManager.switchChatSession(sessionId); + await openSession(sessionId); } catch (error) { log.warn('Agentic OS timeline session select failed', { sessionId, error }); } @@ -467,7 +467,9 @@ export const AgenticOSFlowChatContainer: React.FC { window.dispatchEvent( new CustomEvent('fill-chat-input', { detail: { message: command } }), diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 44f9a6db..1e92e484 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -1,271 +1,156 @@ # FlowChat Scroll Stability -This document explains the scroll-stability mechanism used by `VirtualMessageList.tsx`. +How `VirtualMessageList` keeps the viewport stable while content heights +change. Read this before touching anything under +`src/web-ui/src/flow_chat/scroll/viewport/` or the footer rendering in +`VirtualMessageList.tsx`. -Read this before changing any of the following: +## Architecture In One Paragraph -- footer height / footer rendering in `VirtualMessageList.tsx` -- scroll compensation state or refs -- anchor-lock timing -- `ResizeObserver` / `MutationObserver` / transition listeners -- `flowchat:layout-collapse-intent` -- `flowchat:layout-mutation` -- `overflow-anchor` styles in `VirtualMessageList.scss` +A pure mode machine (`FlowViewportMachine`) decides what the viewport is doing +(`reading`, `pinned-latest`, `following`, `finalizing`, `navigating`). A +single rAF pipeline (`FlowViewportScheduler`) is the only writer of +`scrollTop`: each frame it reconciles the synthetic bottom reservation model +(`FlowViewportGeometry`), computes the mode's target position, and performs at +most one write. Input events dispatch machine events; observers only wake the +pipeline. There are no timing windows and no scroll-delta intent inference. -## Problem +## The Reservation Model -FlowChat uses `react-virtuoso` for virtualization. When the user is already at or near the bottom, collapsing content near the end of the list can shrink total content height. +The Virtuoso footer renders `inputStackFooterPx + collapsePx + pinPx` of +synthetic space: -Without compensation, the browser clamps `scrollTop` downward immediately because the previous bottom position no longer exists. That causes the visible header/content above to drop. +- `collapsePx` — consumable shrink protection while reading. +- `pinPx` / `pinFloorPx` — the synthetic tail that makes "pin the latest user + message to the 61px reading offset" reachable. Only space above the floor is + consumable; the floor itself is exchanged against content growth. -If we compensate too late, the user sees a flash: - -1. browser clamps `scrollTop` -2. code restores `scrollTop` -3. header appears to drop and jump back - -If we restore without enough compensation, the final position is still wrong. - -The goal of this mechanism is: - -- keep the visible header/content vertically stable -- allow temporary invisible blank space at the bottom -- avoid the collapse flash - -## High-Level Strategy - -The fix is a two-stage approach: - -1. Pre-compensate before a known collapse starts. -2. Reconcile with the real measured height delta after layout updates. - -This prevents the "drop first, restore later" behavior while still using the actual measured shrink amount to settle on the correct final compensation. - -## Core Building Blocks - -## 1. Bottom Reservations - -The footer uses a unified bottom-reservation model. Each reservation contributes -temporary tail space, but keeps its own semantics: - -- `collapse`: shrink protection for height loss near the bottom -- `pin`: viewport positioning space for "pin turn to top" navigation - -The rendered footer height is the sum of all active reservations. - -Important details: - -- the real footer height is `MESSAGE_LIST_FOOTER_HEIGHT + totalBottomReservationPx` -- reservation space is not real content height -- reservations may define a `floorPx` -- only reservation space above the floor is consumable -- all measurements that compare old vs new content height must use: +Every growth/shrink comparison must use the effective height: ```ts -effectiveScrollHeight = scroller.scrollHeight - getTotalBottomCompensationPx() +effectiveHeight = scrollHeight - (collapsePx + pinPx) - inputStackFooterPx ``` -If you forget to subtract reservation space, future shrink/growth calculations become wrong. - -`pin` reservations use this extra metadata: - -- `targetTurnId`: which user turn the viewport should align to -- `mode: 'transient' | 'sticky-latest'` -- `floorPx`: the minimum tail space needed to keep the pinned target stable - -`sticky-latest` is used for the "latest turn should stay pinned to top" behavior. -Its floor can be reconciled from live DOM measurements as content grows or shrinks. - -## 2. Synchronous Footer DOM Apply - -React state alone is not enough here. - -`applyFooterCompensationNow()` writes footer height directly to the DOM and forces layout reads: - -- `footer.style.height` -- `footer.style.minHeight` -- `footer.offsetHeight` -- `scroller.scrollHeight` - -This is intentional. It ensures the browser uses the new footer height in the same turn, before we restore the anchor. - -If you move compensation back to "React render only", the flash can return because the DOM may still be one frame behind when `scrollTop` is restored. - -## 3. Anchor Lock - -`anchorLockRef` temporarily remembers the desired `scrollTop`. - -It exists for two reasons: - -- immediate restore right after compensation is applied -- follow-up enforcement during scroll events while the layout is still settling - -The immediate restore handles the critical path. The scroll listener is the safety net. - -## 4. Collapse Intent - -Some collapses are predictable before layout actually shrinks. - -`flowchat:layout-collapse-intent` is emitted before a known collapsible UI -shrinks. `VirtualMessageList` uses that event to: - -- capture the pre-collapse anchor `scrollTop` -- capture the bottom distance before collapse -- estimate required compensation from current card height -- apply provisional compensation immediately - -This pre-compensation is what avoids the flash. - -If the list waits until `ResizeObserver` sees the shrink, the browser may already have clamped `scrollTop`. - -## Runtime Flow - -## A. Known Tool Card Collapse +The unified bottom semantics is `getContentDistanceFromBottom` (excludes all +synthetic space). Virtuoso's `atBottomStateChange` is not used. -When a helper-backed card or region is about to collapse: +## The Equal-Exchange Invariant (pinned-latest) -1. it dispatches `flowchat:layout-collapse-intent` before the collapse state is applied -2. `VirtualMessageList` estimates the upcoming shrink using `cardHeight` -3. `VirtualMessageList` adds provisional footer compensation immediately -4. `VirtualMessageList` activates anchor lock using the current `scrollTop` -5. actual layout shrink happens -6. `ResizeObserver` / `MutationObserver` / transition listeners trigger `measureHeightChange()` -7. measured shrink reconciles the compensation to the real final value -8. anchor lock restores / enforces the final `scrollTop` +While the latest turn is pinned, everything that changes height inside the +current turn happens **below** the pinned message. The pipeline re-measures +the pin floor from the live DOM every frame: -Common examples: - -- `FileOperationToolCard` -- `ModelThinkingDisplay` -- `TerminalToolCard` -- `ExploreGroupRenderer` - -## B. Unknown or Unsignaled Shrink - -If a shrink happens without a collapse intent: - -1. `measureHeightChange()` detects the negative height delta -2. compensation falls back to `shrinkAmount - distanceFromBottom` -3. anchor lock uses the previously known scroll position - -This path is safer than doing nothing, but it is more likely to show visible movement than the pre-compensation path. - -## Why Transition Tracking Exists - -Some collapsible UI uses animated layout properties such as: - -- `grid-template-rows` -- `height` -- `max-height` - -During those transitions, the DOM may report intermediate sizes for multiple frames. - -`layoutTransitionCountRef` prevents us from consuming compensation too early while the layout is still animating. If you remove this guard, compensation can disappear mid-transition and reintroduce vertical drift. - -## C. Follow-Output Mode (continuous tail) - -When the viewport is in follow-output mode and the latest turn is still -streaming, the user's intent is "keep the tail visible", which is the -opposite of "preserve the upper anchor". To avoid the visible -"stutter then jump" behavior caused by collapse pre-compensation -freezing the viewport mid-animation, follow mode short-circuits the -protection path: - -1. `handleToolCardCollapseIntent` returns early without writing - `pendingCollapseIntent`, without adding `collapse` reservation, and - without activating anchor lock. -2. The shrink branch of `measureHeightChange` returns early without - adding fallback footer compensation. -3. A continuous RAF loop in `useVirtuosoFlowFollowOutput` runs every frame - while `isFollowing && isStreaming`, calling `performAutoFollowScroll` - to chase the bottom and `reconcileStickyPinReservation` to keep the - sticky-latest pin floor aligned with the live DOM. -4. The loop is cancelled as soon as follow exits (user upward scroll, - session change, streaming ends, or an explicit navigation). - -This branch coexists with the legacy collapse compensation path. Outside -follow mode (user reading older content), all original protections still -apply unchanged. - -## Why `overflow-anchor: none` Must Stay - -`VirtualMessageList.scss` disables native browser scroll anchoring on: - -- `[data-virtuoso-scroller]` -- `.message-list-footer` - -This is required because the browser's built-in anchoring fights the manual compensation logic. - -If you remove `overflow-anchor: none`, the browser may apply its own anchor correction on top of our compensation and produce unstable or inconsistent results. - -## Required Event Contract - -`flowchat:layout-mutation` - -- dispatch after a generic expand/collapse action that changes height -- purpose: schedule a follow-up measurement - -`flowchat:layout-collapse-intent` - -- dispatch before a collapse that can reduce list height near the bottom -- include `cardHeight` when possible -- purpose: pre-compensate before the browser clamps scroll position - -Current producer: - -- `useFlowLayoutMutationContract.ts` -- `ModelThinkingDisplay.tsx` -- `ExploreGroupRenderer.tsx` - -Most tool cards now emit these events through `useFlowLayoutMutationContract`. -Components that need more accurate collapse estimation can pass a custom -`getCardHeight` function to the helper. - -If a future collapsible component shows the same "header drops" or "flash on collapse" symptom, it should likely emit `flowchat:layout-collapse-intent` before collapsing. - -## Invariants To Preserve +``` +floor = max(0, desiredScrollTop - (scrollHeight - pinPx - clientHeight)) +``` -- Footer compensation must remain additive temporary space, not real content. -- Effective height comparisons must subtract current compensation. -- Footer DOM compensation must be applied synchronously before anchor restore. -- Anchor restore must clamp against current `maxScrollTop`. -- Pre-collapse intent must capture the anchor before the component shrinks. -- Compensation must not be consumed too early during active layout transitions. -- Session changes and empty-list resets must clear compensation and anchor state. +Content grows by Δ → floor shrinks by Δ → `scrollHeight` is constant → +`scrollTop` never moves. The user's message stays put while the answer fills +the blank. When the floor reaches zero mid-stream, the machine flips to +`following` — the two positions coincide exactly, so the handoff is seamless. + +The floor **survives stream end**: short answers stay pinned with their blank. +It is released only by a new turn, an explicit navigation away, or a session +change. Pin mode is derived from the target (latest turn ⇒ `sticky-latest`, +older turn ⇒ `transient`), so anchor-dot jumps back to the latest turn rebuild +the blank instead of destroying it. + +### Sticky floor ownership (layout contract) + +The sticky floor is **not** a mode-scoped temporary buffer. It is a layout +contract owned by the latest turn's reading position: + +- **Survives detours**: jumping to history or scrolling up only changes the + viewport *mode* (`reading`); the floor stays in geometry untouched. +- **Frozen during detours**: downward-scroll consumption and height-delta + reconciliation do not touch the floor while the user is away. Virtualization + remeasurement while scrolling history is ignored. +- **Incremental missing-tail semantics**: `resolvePinMetrics` reports + *additional* tail needed beyond the current `pinPx`. At equilibrium + (message aligned, reservation correct) it is zero — meaning "keep the + current floor", never "floor should be zero". +- **Restore trigger**: returning to the **content bottom** (not the physical + bottom that includes the synthetic tail) re-enters `pinned-latest` when a + live floor exists. `stepPinned` then re-aligns from measurement. +- **Equal exchange**: while `pinned-latest`, content growth shrinks the floor + 1:1 via `absorbPinnedContentGrowth`; measurement preserves equilibrium + when `missingTailSpacePx` is zero. + +## Height-Change Handling Matrix + +| Mode | Growth (incl. animated expand) | Shrink (incl. animated collapse) | +|---|---|---| +| `pinned-latest` | floor absorbs it 1:1; zero movement | collapse intent bumps the floor synchronously (estimate), per-frame reconcile converges to measured; `scrollHeight` never dips, no clamp | +| `following` / `finalizing` | chase the tail every frame | no compensation; the viewport rides the tail upward | +| `reading` (near bottom) | consume consumable reservations | synchronous pre-compensation + anchor lock; no early consumption during CSS transitions | +| `reading` (change far above bottom) | nothing (scrollbar only) | nothing (fallback compensation is zero) | +| `navigating` | animator re-resolves its destination every frame | same | + +`finalizing` is entered when the stream ends while following: the pipeline +keeps chasing the tail until layout is quiet (8 stable frames or 800ms), so +terminal auto-collapses and markdown upgrades are absorbed without a stall. +Consumable leftovers are cleared on settle; the sticky floor is not. + +## Synchronous Writes (the only two exceptions) + +`scrollTop` is written only inside the pipeline. Footer height has two +synchronous write paths that must land before the browser clamps or paints: + +1. **Collapse-intent pre-compensation** (`flowchat:layout-collapse-intent`): + in `reading`, add collapse compensation and arm the anchor lock; in + `pinned-latest`, bump the pin floor. Both before the component shrinks. +2. **Downward-scroll consumption**: user scrolling down eats consumable + reservation space inside the scroll event so the viewport can never enter + visible synthetic blank. + +`applyFooterNow` writes the style and forces layout reads +(`footer.offsetHeight`, `scroller.scrollHeight`) so the new height +participates in the same task. Do not move this to React state rendering. + +## Why These Stay + +- `overflow-anchor: none` on the scroller and footer + (`VirtualMessageList.scss`): native browser anchoring fights the + reservation model. +- Transition tracking (`grid-template-rows` / `height` / `max-height`): + gates early consumption in `reading` and the settle detection in + `finalizing`. Pinned/following do not depend on it — they measure the live + DOM every frame and are immune to intermediate sizes. +- Measured-first reconciliation: concurrent expand + collapse in the same + frame (new tool card appears while the explore group collapses) composes + automatically because the floor is recomputed from rects, not from + per-event bookkeeping. ## Common Ways To Break This -- Replacing `applyFooterCompensationNow()` with state-only rendering. -- Measuring raw `scrollHeight` deltas without subtracting existing compensation. -- Removing `flowchat:layout-collapse-intent` from a helper-backed collapsible component. -- Dispatching collapse intent after `setState` instead of before it. -- Removing `overflow-anchor: none`. -- Removing transition-aware delayed measurement. -- Simplifying anchor restore to a one-shot restore without the scroll listener fallback. -- Removing the follow-mode short-circuit in `handleToolCardCollapseIntent` / - `measureHeightChange`. Without it, follow-output streaming will visibly stall - during collapse animations and then snap to the latest token. -- Removing the continuous RAF follow loop. Event-driven follow alone cannot - keep up with collapse animations + dense token streams without visible jitter. - -## If You Need To Change This Logic - -Use this checklist: - -1. Verify bottom collapse at the end of a conversation. -2. Verify manual collapse of a completed `Write` / `Edit` tool card. -3. Verify auto-collapse of file tool cards after streaming finishes. -4. Verify repeated expand/collapse near the bottom. -5. Verify thinking / explore / other collapsible sections still schedule measurements correctly. -6. Verify there is no visible "drop then snap back" flash. -7. Verify the final header position remains stable after collapse. - -## Related Files - -- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx` -- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss` -- `src/web-ui/src/flow_chat/scroll/useFlowLayoutMutationContract.ts` -- `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` -- `src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx` -- `src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx` -- `src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx` +- Writing `scrollTop` anywhere outside the scheduler pipeline. +- Comparing raw `scrollHeight` deltas without subtracting reservations. +- Dispatching a collapse intent after `setState` instead of before it. +- Removing the pinned-mode synchronous floor bump (a 1-frame clamp flash + returns: pinned scrollTop equals maxScrollTop, so any un-prefunded shrink + clamps immediately). +- Clearing the pin floor on stream end (short answers will drop). +- Reintroducing time-window intent guards; upward input events must win + unconditionally. +- Making the footer height React-state-driven per frame (one frame late = + visible clamp). + +## Verification Checklist + +1. Send a message → the user message pins at the reading offset; no + scroll-to-latest bar flash; the answer fills the blank without movement. +2. Dense token stream → wheel up once → the viewport is immediately yours; no + pull-back. +3. Mid-turn explore-group auto-collapse while pinned → zero movement. +4. Simultaneous new-card expand + old-card collapse → no jitter. +5. Stream end with terminal auto-collapses → smooth settle, no leftover blank + that needs a user scroll, no bar flash. +6. Short answer → stays pinned with blank after the turn ends; wheel-down + cannot scroll into the blank. +7. Reading history near the bottom during a collapse → no drop-and-snap-back. +8. Anchor-dot jump to the latest turn → re-pins with blank intact; jump to an + older turn → blank released, no clamp jump. +9. Session switch in both directions → no residual reservations. + +Unit tests: `scroll/viewport/FlowViewportMachine.test.ts`, +`scroll/viewport/FlowViewportGeometry.test.ts`. diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatSelectionAddButton.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatSelectionAddButton.tsx index 241510b1..d8efe4b2 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatSelectionAddButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatSelectionAddButton.tsx @@ -8,7 +8,7 @@ import { createLogger } from '@/shared/utils/logger'; import { createTransientBtwSession } from '../../services/BtwThreadService'; import { openBtwSessionInAuxPane } from '../../services/childSessionPanels'; import { flowChatStore } from '../../store/FlowChatStore'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; import { resolveSessionRelationship } from '../../utils/sessionMetadata'; import './FlowChatSelectionAddButton.scss'; @@ -269,7 +269,7 @@ export const FlowChatSelectionAddButton: React.FC {virtualItems.length === 0 ? ( { window.dispatchEvent( new CustomEvent('fill-chat-input', { detail: { message: command } }), diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index ddc4d768..f394a63b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -2,57 +2,50 @@ * Virtualized message list. * Renders a flattened DialogTurn stream (user messages + model rounds). * - * Scroll policy (simplified): - * - The list preserves the current viewport by default. - * - A new turn first pins the latest user message near the top for reading. - * - Follow mode starts explicitly via "jump to latest", or automatically once - * the latest turn's streaming output grows enough to consume the sticky tail space. - * - User upward scroll intent exits follow and cancels any pending auto-follow arm. - * - "Scroll to latest" bar appears whenever the list is not at bottom. + * Scroll policy: + * - A new turn pins the latest user message near the top for reading; the + * synthetic tail below it is exchanged 1:1 against streaming growth. + * - Once the tail floor is consumed, the viewport follows the output. + * - Explicit upward user intent (wheel/touch/keys/scrollbar) always hands the + * viewport back to the user immediately. + * - When the stream ends, a short finalizing window absorbs terminal + * auto-collapses before settling into reading mode. + * + * All of this is owned by `useFlowViewportController`; this component only + * wires the controller to Virtuoso and renders overlays. + * See `src/web-ui/src/flow_chat/scroll/README.md`. */ -import React, { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { useRef, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useActiveSessionState } from '../../hooks/useActiveSessionState'; import { VirtualItemRenderer } from './VirtualItemRenderer'; import { ScrollToLatestBar } from '../ScrollToLatestBar'; import { ProcessingIndicator } from './ProcessingIndicator'; import { ScrollAnchor } from './ScrollAnchor'; -import { - useVirtuosoFlowFollowOutput, - type FollowOutputEnterReason, - type FollowOutputExitReason, -} from '../../scroll/adapters/useVirtuosoFlowFollowOutput'; -import { useVirtuosoFlowLayoutMutationBridge } from '../../scroll/adapters/useVirtuosoFlowLayoutMutationBridge'; -import { useVirtuosoFlowLayoutObservers } from '../../scroll/adapters/useVirtuosoFlowLayoutObservers'; -import { useVirtuosoFlowUserIntentBridge } from '../../scroll/adapters/useVirtuosoFlowUserIntentBridge'; +import { useFlowViewportController } from '../../scroll/viewport/useFlowViewportController'; +import type { ViewportPinMode } from '../../scroll/viewport/FlowViewportGeometry'; import { useVirtuosoVisibleTurnTracker } from '../../scroll/adapters/useVirtuosoVisibleTurnTracker'; -import { useVirtuosoFlowGeometryController } from '../../scroll/adapters/useVirtuosoFlowGeometryController'; -import { useVirtuosoFlowNavigationController } from '../../scroll/adapters/useVirtuosoFlowNavigationController'; -import type { FlowChatPinTurnToTopMode } from '../../events/flowchatNavigation'; import { useVirtualItems, useActiveSession } from '../../store/modernFlowChatStore'; import { useChatInputState } from '../../store/chatInputStateStore'; import { computeFlowChatInputStackFooterPx } from '../../utils/flowChatScrollLayout'; import { projectStreamingOutput } from '../../projections/streamingOutputProjection'; import { projectProcessingAffordance } from '../../projections/processingAffordanceProjection'; import { useStableProcessingAffordance } from './useStableProcessingAffordance'; -import { COMPENSATION_EPSILON_PX } from '../../scroll/FlowScrollGeometry'; import './VirtualMessageList.scss'; -// Read `FLOWCHAT_SCROLL_STABILITY.md` before changing collapse compensation logic. - /** * Methods exposed by VirtualMessageList. */ export interface VirtualMessageListRef { scrollToTurn: (turnIndex: number) => void; scrollToIndex: (index: number) => void; - // Clears pin reservation first, then scrolls to the physical bottom. + // Clears the pin reservation first, then scrolls to the end of content. scrollToPhysicalBottomAndClearPin: () => void; - // Preserves any existing pin reservation and behaves like an End-key scroll. + // Jump to the latest output and follow it while streaming. scrollToLatestEndPosition: () => void; - // Aligns the target turn's user message to the viewport top. - pinTurnToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => boolean; + // Aligns the target turn's user message to the viewport reading offset. + pinTurnToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: ViewportPinMode }) => boolean; } export interface VirtualMessageListProps { @@ -69,71 +62,19 @@ export const VirtualMessageList = forwardRef(null); - const scrollerElementRef = useRef(null); - const footerElementRef = useRef(null); - const layoutTransitionCountRef = useRef(0); - const followOutputControllerRef = useRef<{ - handleUserScrollIntent: () => void; - handleScroll: () => void; - scheduleFollowToLatest: (reason: string) => void; - }>({ - handleUserScrollIntent: () => {}, - handleScroll: () => {}, - scheduleFollowToLatest: () => {}, - }); - const deferredFollowReasonRef = useRef(null); - const enterFollowOutputRef = useRef<(reason: FollowOutputEnterReason) => void>(() => {}); - const exitFollowOutputRef = useRef<(reason: FollowOutputExitReason) => void>(() => {}); - // Mirror of `isFollowingOutput` for use inside listeners that are registered - // once per mount. When follow mode is active we deliberately bypass collapse - // pre-compensation and anchor lock so the continuous follow loop can keep - // tracking the bottom without fighting the layout-stability machinery. - const isFollowingOutputRef = useRef(false); - const isStreamingOutputRef = useRef(false); const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); const inputHeight = useChatInputState(state => state.inputHeight); - - const inputStackFooterPxRef = useRef(0); const inputStackFooterPx = computeFlowChatInputStackFooterPx(inputHeight, isInputActive); - inputStackFooterPxRef.current = inputStackFooterPx; const activeSessionState = useActiveSessionState(); const isProcessing = activeSessionState.isProcessing; const processingPhase = activeSessionState.processingPhase; - const { - bottomReservationState, - bottomReservationStateRef, - previousMeasuredHeightRef, - previousScrollTopRef, - anchorLockRef, - pendingCollapseIntentRef, - getFooterHeightPx, - getTotalBottomCompensationPx, - snapshotMeasuredContentHeight, - updateBottomReservationState, - resetBottomReservations, - resetTransientGeometryState, - consumeBottomCompensation, - applyFooterCompensationNow, - releaseAnchorLock, - activateAnchorLock, - scheduleHeightMeasure, - } = useVirtuosoFlowGeometryController({ - footerElementRef, - scrollerElementRef, - inputStackFooterPxRef, - layoutTransitionCountRef, - isFollowingOutputRef, - isStreamingOutputRef, - }); - - const userMessageItems = React.useMemo(() => { + const userMessageItems = useMemo(() => { return virtualItems .map((item, index) => ({ item, index })) .filter(({ item }) => item.type === 'user-message'); @@ -141,16 +82,12 @@ export const VirtualMessageList = forwardRef({ - turnId: latestTurnId, - sawPositiveFloor: false, - }); - const hasPrimedMountedStreamingTurnFollowRef = useRef(false); - const previousLatestTurnIdForFollowRef = useRef(latestTurnId); - const previousSessionIdForFollowRef = useRef(activeSession?.sessionId); + + const streamingOutputProjection = useMemo( + () => projectStreamingOutput(activeSession), + [activeSession], + ); + const isStreamingOutput = isProcessing || streamingOutputProjection.isStreamingOutput; const { scheduleVisibleTurnMeasure } = useVirtuosoVisibleTurnTracker({ activeSessionId: activeSession?.sessionId, @@ -160,309 +97,40 @@ export const VirtualMessageList = forwardRef { - if (el && el instanceof HTMLElement) { - scrollerElementRef.current = el; - setScrollerElement(el); - return; - } - - scrollerElementRef.current = null; - setScrollerElement(null); - }, []); - - const shouldSuspendAutoFollow = useCallback(() => { - const collapseIntent = pendingCollapseIntentRef.current; - return ( - layoutTransitionCountRef.current > 0 || - (collapseIntent.active && collapseIntent.expiresAtMs >= performance.now()) - ); - }, [pendingCollapseIntentRef]); - - const scheduleFollowToLatestWithViewportState = useCallback((reason: string) => { - const collapseIntentActive = shouldSuspendAutoFollow(); - if (collapseIntentActive) { - deferredFollowReasonRef.current = reason; - return; - } - deferredFollowReasonRef.current = null; - followOutputControllerRef.current.scheduleFollowToLatest(reason); - }, [shouldSuspendAutoFollow]); - - const scheduleDeferredFollowToLatest = useCallback((reason: string) => { - followOutputControllerRef.current.scheduleFollowToLatest(reason); - }, []); + const handleVisibleTurnMeasure = useCallback(() => { + scheduleVisibleTurnMeasure(); + }, [scheduleVisibleTurnMeasure]); const { - pendingTurnPin, - performAutoFollowSync, - requestTurnPinToTop, - reconcileStickyPinReservation, - schedulePinReservationReconcile, - scrollToLatestEndPositionInternal, - scrollToTurn, - scrollToIndex, - pinTurnToTop, - scrollToPhysicalBottomAndClearPin, - scrollToLatestEndPosition, - } = useVirtuosoFlowNavigationController({ - activeSessionId: activeSession?.sessionId, - virtuosoRef, - scrollerElement, - scrollerElementRef, - virtualItemCount: virtualItems.length, - userMessageItems, - latestTurnId, - bottomReservationStateRef, - previousMeasuredHeightRef, - previousScrollTopRef, - pendingCollapseIntentRef, - layoutTransitionCountRef, - getTotalBottomCompensationPx, - snapshotMeasuredContentHeight, - updateBottomReservationState, - resetBottomReservations, - resetTransientGeometryState, - applyFooterCompensationNow, - releaseAnchorLock, - scheduleVisibleTurnMeasure, - enterFollowOutputRef, - exitFollowOutputRef, - }); - - useVirtuosoFlowLayoutMutationBridge({ - scrollerElement, - isFollowingOutputRef, - isStreamingOutputRef, - bottomReservationStateRef, - pendingCollapseIntentRef, - scheduleHeightMeasure, - scheduleVisibleTurnMeasure, - schedulePinReservationReconcile, - getTotalBottomCompensationPx, - updateBottomReservationState, - applyFooterCompensationNow, - activateAnchorLock, - }); - - useVirtuosoFlowLayoutObservers({ - scrollerElement, - isProcessing, - layoutTransitionCountRef, - pendingCollapseIntentRef, - deferredFollowReasonRef, - previousMeasuredHeightRef, - previousScrollTopRef, - snapshotMeasuredContentHeight, - scheduleHeightMeasure, - scheduleVisibleTurnMeasure, - schedulePinReservationReconcile, - scheduleFollowToLatestWithViewportState, - shouldSuspendAutoFollow, - scheduleFollowToLatest: scheduleDeferredFollowToLatest, - }); - - useVirtuosoFlowUserIntentBridge({ - scrollerElement, - anchorLockRef, - layoutTransitionCountRef, - previousScrollTopRef, - previousMeasuredHeightRef, - followOutputControllerRef, - releaseAnchorLock, - getTotalBottomCompensationPx, - consumeBottomCompensation, - applyFooterCompensationNow, - snapshotMeasuredContentHeight, - scheduleVisibleTurnMeasure, - }); - - // `rangeChanged` is affected by overscan/increaseViewportBy, so treat it as a - // "rendered DOM changed" signal and derive the pinned turn from real DOM visibility. - const handleRangeChanged = useCallback(() => { - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - scheduleFollowToLatestWithViewportState('range-changed'); - }, [scheduleFollowToLatestWithViewportState, schedulePinReservationReconcile, scheduleVisibleTurnMeasure]); - - const streamingOutputProjection = React.useMemo( - () => projectStreamingOutput(activeSession), - [activeSession], - ); - const isStreamingOutput = isProcessing || streamingOutputProjection.isStreamingOutput; - - const { - isFollowingOutput, - enterFollowOutput, - exitFollowOutput, - armFollowOutputForNewTurn, - activateArmedFollowOutput, - cancelPendingAutoFollowArm, - scheduleFollowToLatest, - handleUserScrollIntent, - handleScroll: handleFollowOutputScroll, - } = useVirtuosoFlowFollowOutput({ + snapshot, + handleScrollerRef, + handleFooterRef, + handleRangeChanged, + getFooterHeightPx, + commands, + } = useFlowViewportController({ activeSessionId: activeSession?.sessionId, latestTurnId, virtualItemCount: virtualItems.length, + userMessageItems, isStreaming: isStreamingOutput, - scrollerRef: scrollerElementRef, - performUserFollowScroll: () => { - scrollToLatestEndPositionInternal('smooth'); - }, - performAutoFollowScroll: performAutoFollowSync, - performLatestTurnStickyPin: () => { - if (latestTurnId) { - requestTurnPinToTop(latestTurnId, { - behavior: 'auto', - pinMode: 'sticky-latest', - }); - } - }, - shouldSuspendAutoFollow, - getAutoFollowDistanceFromBottom: (scroller) => ( - Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop - getTotalBottomCompensationPx()) - ), - onContinuousFollowFrame: () => { - // Keep sticky-latest pin floor aligned with the live DOM as collapses - // shrink the layout. Without this the pin reservation would lag for one - // RAF tick and the viewport would briefly land below the latest user - // message. - reconcileStickyPinReservation(); - }, + inputStackFooterPx, + virtuosoRef, + scrollerElementRef, + scrollerElement, + onScrollerElementChange: setScrollerElement, + onVisibleTurnMeasure: handleVisibleTurnMeasure, }); - useEffect(() => { - if (hasPrimedMountedStreamingTurnFollowRef.current) { - return; - } - - hasPrimedMountedStreamingTurnFollowRef.current = true; - if (!latestTurnId || !isStreamingOutput) { - return; - } - - latestTurnAutoFollowStateRef.current = { - turnId: latestTurnId, - sawPositiveFloor: false, - }; - armFollowOutputForNewTurn(); - }, [ - activeSession?.sessionId, - armFollowOutputForNewTurn, - isStreamingOutput, - latestTurnId, - virtualItems.length, - ]); - - useEffect(() => { - const previousSessionId = previousSessionIdForFollowRef.current; - if (previousSessionId !== activeSession?.sessionId) { - previousSessionIdForFollowRef.current = activeSession?.sessionId; - previousLatestTurnIdForFollowRef.current = latestTurnId; - latestTurnAutoFollowStateRef.current = { - turnId: latestTurnId, - sawPositiveFloor: false, - }; - return; - } - - const previousLatestTurnId = previousLatestTurnIdForFollowRef.current; - if (previousLatestTurnId === latestTurnId) { - return; - } - - previousLatestTurnIdForFollowRef.current = latestTurnId; - latestTurnAutoFollowStateRef.current = { - turnId: latestTurnId, - sawPositiveFloor: false, - }; - - if (!latestTurnId) { - cancelPendingAutoFollowArm(); - return; - } - - armFollowOutputForNewTurn(); - }, [ - activeSession?.sessionId, - armFollowOutputForNewTurn, - cancelPendingAutoFollowArm, - latestTurnId, - ]); - - useEffect(() => { - const trackingState = latestTurnAutoFollowStateRef.current; - if ( - !latestTurnId || - trackingState.turnId !== latestTurnId || - isFollowingOutput || - !isStreamingOutput - ) { - return; - } - - const hasPendingLatestStickyPin = ( - pendingTurnPin?.turnId === latestTurnId && - pendingTurnPin.pinMode === 'sticky-latest' - ); - if (hasPendingLatestStickyPin) { - return; - } - - if ( - bottomReservationState.pin.mode !== 'sticky-latest' || - bottomReservationState.pin.targetTurnId !== latestTurnId - ) { - return; - } - - if (bottomReservationState.pin.floorPx > COMPENSATION_EPSILON_PX) { - trackingState.sawPositiveFloor = true; - return; - } - - if (activateArmedFollowOutput()) { - latestTurnAutoFollowStateRef.current = { - turnId: null, - sawPositiveFloor: false, - }; - } - }, [ - activateArmedFollowOutput, - bottomReservationState.pin.floorPx, - bottomReservationState.pin.mode, - bottomReservationState.pin.targetTurnId, - isFollowingOutput, - isStreamingOutput, - latestTurnId, - pendingTurnPin?.pinMode, - pendingTurnPin?.turnId, - ]); - - followOutputControllerRef.current = { - handleUserScrollIntent, - handleScroll: handleFollowOutputScroll, - scheduleFollowToLatest, - }; - enterFollowOutputRef.current = enterFollowOutput; - exitFollowOutputRef.current = exitFollowOutput; - isFollowingOutputRef.current = isFollowingOutput; - isStreamingOutputRef.current = isStreamingOutput; - useImperativeHandle(ref, () => ({ - scrollToTurn, - scrollToIndex, - scrollToPhysicalBottomAndClearPin, - scrollToLatestEndPosition, - pinTurnToTop, - }), [pinTurnToTop, scrollToTurn, scrollToIndex, scrollToPhysicalBottomAndClearPin, scrollToLatestEndPosition]); - - const handleAtBottomStateChange = useCallback((atBottom: boolean) => { - setIsAtBottom(atBottom); - }, []); - - const processingAffordanceProjection = React.useMemo( + scrollToTurn: commands.scrollToTurn, + scrollToIndex: commands.scrollToIndex, + scrollToPhysicalBottomAndClearPin: commands.scrollToPhysicalBottomAndClearPin, + scrollToLatestEndPosition: commands.scrollToLatestEndPosition, + pinTurnToTop: commands.pinTurnToTop, + }), [commands]); + + const processingAffordanceProjection = useMemo( () => projectProcessingAffordance({ session: activeSession, isProcessing: isStreamingOutput, @@ -472,7 +140,34 @@ export const VirtualMessageList = forwardRef ({ + Header: () =>
, + Footer: () => ( + <> + +
+ + ), + }), [ + getFooterHeightPx, + handleFooterRef, + processingAffordance.reserveSpace, + processingAffordance.resetKey, + processingAffordance.visible, + ]); // Render. if (virtualItems.length === 0) { @@ -503,14 +198,11 @@ export const VirtualMessageList = forwardRef
, - Footer: () => ( - <> - -
- - ), - }} + components={components} /> {!hideScrollAnchor && ( { - pinTurnToTop(turnId, { behavior: 'smooth' }); + commands.pinTurnToTop(turnId, { behavior: 'smooth' }); }} scrollerRef={scrollerElementRef} /> )} 0} - onClick={scrollToLatestEndPosition} + visible={snapshot.showScrollToLatest && virtualItems.length > 0} + onClick={commands.scrollToLatestEndPosition} isInputActive={isInputActive} isInputExpanded={isInputExpanded} inputHeight={inputHeight} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatCore.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatCore.ts index e1bf3b16..e9def5d9 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatCore.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatCore.ts @@ -6,7 +6,6 @@ import { useMemo, useCallback, useRef, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useHeaderStore } from '@/app/stores/headerStore'; import { useExploreGroupState } from './useExploreGroupState'; import { useFlowChatFileActions } from './useFlowChatFileActions'; import { useFlowChatNavigation } from './useFlowChatNavigation'; @@ -24,14 +23,6 @@ import { } from '../../store/modernFlowChatStore'; import type { FlowChatConfig } from '../../types/flow-chat'; import type { LineRange } from '@/shared/markdown'; -import { - getWorkspaceDisplayName, - useWorkspaceContext, -} from '@/infrastructure/contexts/WorkspaceContext'; -import { - fallbackWorkspaceFolderLabel, - resolveWorkspaceForSession, -} from '../../utils/sessionOrdering'; import type { FlowChatHeaderTurnSummary } from './FlowChatHeader'; import type { VirtualMessageListRef } from './VirtualMessageList'; import type { @@ -77,9 +68,6 @@ export function useFlowChatCore(options: UseFlowChatCoreOptions = {}) { const visibleTurnInfo = activeSession.sessionId === modernActiveSession.sessionId ? rawVisibleTurnInfo : null; - const { - openedWorkspacesList, - } = useWorkspaceContext(); const effectiveWorkspacePath = activeSession.workspacePath ?? scopedWorkspacePath ?? undefined; @@ -202,38 +190,6 @@ export function useFlowChatCore(options: UseFlowChatCoreOptions = {}) { return () => cancelAnimationFrame(frameId); }, [searchCurrentMatchVirtualIndex]); - // ── Header store sync ───────────────────────────────────────────────────── - const { setSessionContext, clearSessionContext } = useHeaderStore.getState(); - - const workspaceDisplayName = useMemo(() => { - if (!activeSession?.workspacePath?.trim()) return ''; - const ws = resolveWorkspaceForSession(activeSession, openedWorkspacesList); - if (ws) { - const label = getWorkspaceDisplayName(ws).trim(); - if (label) return label; - } - return fallbackWorkspaceFolderLabel(activeSession.workspacePath); - }, [activeSession, openedWorkspacesList]); - - useEffect(() => { - if (!activeSession.sessionId || !activeSession.descriptor) { - clearSessionContext(); - return; - } - setSessionContext({ - descriptor: activeSession.descriptor, - workspacePath: activeSession.workspacePath, - workspaceDisplayName, - }); - }, [ - activeSession.descriptor, - activeSession.sessionId, - activeSession.workspacePath, - workspaceDisplayName, - setSessionContext, - clearSessionContext, - ]); - // ── Context value builders ──────────────────────────────────────────────── const staticContextValue = useMemo( () => ({ @@ -344,7 +300,6 @@ export function useFlowChatCore(options: UseFlowChatCoreOptions = {}) { // Workspace workspacePath: effectiveWorkspacePath, - workspaceDisplayName, // Pre-built context values staticContextValue, diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts index 6887d527..2d6319c5 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts @@ -10,7 +10,7 @@ import { globalEventBus } from '@/infrastructure/event-bus'; import { createLogger } from '@/shared/utils/logger'; import { flowChatStore } from '../../store/FlowChatStore'; import { useModernFlowChatStore, type VirtualItem } from '../../store/modernFlowChatStore'; -import { flowChatManager } from '../../services/FlowChatManager'; +import { openSession } from '@/app/navigation/navigationController'; import { FLOWCHAT_FOCUS_ITEM_EVENT, FLOWCHAT_PIN_TURN_TO_TOP_EVENT, @@ -168,7 +168,7 @@ export function useFlowChatNavigation({ if (activeSessionId !== sessionId) { try { - await flowChatManager.switchChatSession(sessionId); + await openSession(sessionId); } catch (error) { log.warn('Failed to switch session for focus request', { sessionId, error }); return; diff --git a/src/web-ui/src/flow_chat/hooks/index.ts b/src/web-ui/src/flow_chat/hooks/index.ts index 672c3b5c..1f5a3a49 100644 --- a/src/web-ui/src/flow_chat/hooks/index.ts +++ b/src/web-ui/src/flow_chat/hooks/index.ts @@ -1,5 +1,4 @@ export { useActiveSessionState } from './useActiveSessionState'; -export { useAutoScroll } from './useAutoScroll'; export { useCopyDialog } from './useCopyDialog'; export { useTypewriter } from './useTypewriter'; export { useImeEnterGuard } from './useImeEnterGuard'; diff --git a/src/web-ui/src/flow_chat/hooks/useActiveSessionState.ts b/src/web-ui/src/flow_chat/hooks/useActiveSessionState.ts index 8d3dab0a..b357b096 100644 --- a/src/web-ui/src/flow_chat/hooks/useActiveSessionState.ts +++ b/src/web-ui/src/flow_chat/hooks/useActiveSessionState.ts @@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'; import { stateMachineManager } from '../state-machine'; import { ProcessingPhase } from '../state-machine/types'; import { useFlowChatStoreSelector } from './useFlowChatStoreSelector'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; export interface ActiveSessionState { sessionId: string | null; @@ -18,7 +18,7 @@ export interface ActiveSessionState { } export const useActiveSessionState = (): ActiveSessionState => { - const focusedSessionId = useWorkspaceSurfaceStore(state => state.focusedSessionId); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); const activeSessionSnapshot = useFlowChatStoreSelector((state) => { const session = focusedSessionId ? state.sessions.get(focusedSessionId) diff --git a/src/web-ui/src/flow_chat/hooks/useAgenticOsTimeline.ts b/src/web-ui/src/flow_chat/hooks/useAgenticOsTimeline.ts index b9528546..d9b670b7 100644 --- a/src/web-ui/src/flow_chat/hooks/useAgenticOsTimeline.ts +++ b/src/web-ui/src/flow_chat/hooks/useAgenticOsTimeline.ts @@ -18,7 +18,7 @@ import { } from '../projections/flowChatProjectionScheduler'; import { flowChatStore } from '../store/FlowChatStore'; import { useFlowChatStoreSelector } from './useFlowChatStoreSelector'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; export type { AgenticOsTimelineBucket, @@ -29,7 +29,7 @@ export type { }; export function useAgenticOsTimeline(): AgenticOsTimelineData { - const focusedSessionId = useWorkspaceSurfaceStore(state => state.focusedSessionId); + const focusedSessionId = useWorkspaceSurfaceStore(selectFocusedSessionId); const timelineSignature = useFlowChatStoreSelector(state => getAgenticOsTimelineSignature(state, focusedSessionId) ); diff --git a/src/web-ui/src/flow_chat/hooks/useAutoScroll.ts b/src/web-ui/src/flow_chat/hooks/useAutoScroll.ts deleted file mode 100644 index 14a3e3c8..00000000 --- a/src/web-ui/src/flow_chat/hooks/useAutoScroll.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Auto-scroll hook for chat messages. - * - Pause auto-scroll when the user scrolls up - * - Provide the "scroll to bottom" button state - */ - -import { useRef, useCallback, useState, useEffect, RefObject } from 'react'; - -interface UseAutoScrollOptions { - enabled?: boolean; - threshold?: number; - dependencies?: any[]; -} - -interface UseAutoScrollReturn { - messagesEndRef: RefObject; - messagesContainerRef: RefObject; - isUserScrolledUp: boolean; - scrollToBottom: (force?: boolean) => void; // Force ignores user position. - shouldShowScrollButton: boolean; -} - -export const useAutoScroll = ( - options: UseAutoScrollOptions = {} -): UseAutoScrollReturn => { - const { - enabled = true, - threshold = 100, - dependencies = [] - } = options; - - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); - - const checkUserScrollPosition = useCallback(() => { - if (!messagesContainerRef.current) return; - - const container = messagesContainerRef.current; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - - const isNearBottom = distanceFromBottom < threshold; - setIsUserScrolledUp(!isNearBottom); - }, [threshold]); - - const scrollToBottom = useCallback((force = false) => { - if (!messagesEndRef.current) return; - - // Force scroll, or auto-scroll when user is at the bottom. - if (force || (enabled && !isUserScrolledUp)) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - - // Reset the user-scrolled-up state on force. - if (force) { - setIsUserScrolledUp(false); - } - } - }, [enabled, isUserScrolledUp]); - - useEffect(() => { - const container = messagesContainerRef.current; - if (!container) return; - - // Throttle using rAF to reduce callback frequency. - let rafId: number | null = null; - const throttledCheck = () => { - if (rafId) return; - rafId = requestAnimationFrame(() => { - checkUserScrollPosition(); - rafId = null; - }); - }; - - container.addEventListener('scroll', throttledCheck, { passive: true }); - return () => { - container.removeEventListener('scroll', throttledCheck); - if (rafId) cancelAnimationFrame(rafId); - }; - }, [checkUserScrollPosition]); - - useEffect(() => { - scrollToBottom(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencies); - - const shouldShowScrollButton = isUserScrolledUp; - - return { - messagesEndRef, - messagesContainerRef, - isUserScrolledUp, - scrollToBottom, - shouldShowScrollButton - }; -}; - diff --git a/src/web-ui/src/flow_chat/index.ts b/src/web-ui/src/flow_chat/index.ts index c584f6e1..da1aecac 100644 --- a/src/web-ui/src/flow_chat/index.ts +++ b/src/web-ui/src/flow_chat/index.ts @@ -7,7 +7,6 @@ export { ModernFlowChatContainer as FlowChatContainer } from './components/moder // Other components export { ChatInput } from './components/ChatInput'; export { CurrentSessionTitle } from './components/CurrentSessionTitle'; -export { ScrollToBottomButton } from './components/ScrollToBottomButton'; export { ScrollToLatestBar } from './components/ScrollToLatestBar'; // Services and Stores diff --git a/src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.test.ts b/src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.test.ts deleted file mode 100644 index 7b862c90..00000000 --- a/src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - areBottomReservationStatesEqual, - createInactiveAnchorLock, - createInactiveCollapseIntent, - createInitialBottomReservationState, - getReservationConsumablePx, - getReservationTotalPx, - sanitizeBottomReservationState, -} from './FlowScrollGeometry'; - -describe('FlowScrollGeometry', () => { - it('creates inert anchor and collapse states', () => { - expect(createInactiveAnchorLock()).toEqual({ - active: false, - targetScrollTop: 0, - reason: null, - lockUntilMs: 0, - }); - expect(createInactiveCollapseIntent()).toEqual({ - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, - }); - }); - - it('sanitizes reservation values and keeps floors within reservation size', () => { - const sanitized = sanitizeBottomReservationState({ - collapse: { kind: 'collapse', px: -10, floorPx: 5 }, - pin: { - kind: 'pin', - px: 8, - floorPx: 24, - mode: 'sticky-latest', - targetTurnId: 'turn-1', - }, - }); - - expect(sanitized.collapse).toEqual({ kind: 'collapse', px: 0, floorPx: 0 }); - expect(sanitized.pin).toEqual({ - kind: 'pin', - px: 8, - floorPx: 8, - mode: 'sticky-latest', - targetTurnId: 'turn-1', - }); - expect(getReservationTotalPx(sanitized.pin)).toBe(8); - expect(getReservationConsumablePx(sanitized.pin)).toBe(0); - }); - - it('compares reservation states using the scroll compensation epsilon', () => { - const base = createInitialBottomReservationState(); - const tinyDelta = { - ...base, - pin: { - ...base.pin, - px: 0.25, - }, - }; - const realDelta = { - ...base, - pin: { - ...base.pin, - px: 2, - }, - }; - - expect(areBottomReservationStatesEqual(base, tinyDelta)).toBe(true); - expect(areBottomReservationStatesEqual(base, realDelta)).toBe(false); - }); -}); diff --git a/src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.ts b/src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.ts deleted file mode 100644 index e7de09b6..00000000 --- a/src/web-ui/src/flow_chat/scroll/FlowScrollGeometry.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { FlowChatPinTurnToTopMode } from '../events/flowchatNavigation'; - -export const COMPENSATION_EPSILON_PX = 0.5; -export const ANCHOR_LOCK_MIN_DEVIATION_PX = 0.5; -export const ANCHOR_LOCK_DURATION_MS = 450; -export const PINNED_TURN_VIEWPORT_OFFSET_PX = 61; - -export interface ScrollAnchorLockState { - active: boolean; - targetScrollTop: number; - reason: 'transition-shrink' | 'instant-shrink' | null; - lockUntilMs: number; -} - -export interface PendingCollapseIntentState { - active: boolean; - anchorScrollTop: number; - toolId: string | null; - toolName: string | null; - expiresAtMs: number; - distanceFromBottomBeforeCollapse: number; - baseTotalCompensationPx: number; - cumulativeShrinkPx: number; -} - -type BottomReservationKind = 'collapse' | 'pin'; - -export interface BottomReservationBase { - kind: BottomReservationKind; - px: number; - floorPx: number; -} - -export interface CollapseBottomReservation extends BottomReservationBase { - kind: 'collapse'; -} - -export interface PinBottomReservation extends BottomReservationBase { - kind: 'pin'; - mode: FlowChatPinTurnToTopMode; - targetTurnId: string | null; -} - -export interface BottomReservationState { - collapse: CollapseBottomReservation; - pin: PinBottomReservation; -} - -export interface PendingTurnPinState { - turnId: string; - behavior: ScrollBehavior; - pinMode: FlowChatPinTurnToTopMode; - expiresAtMs: number; - attempts: number; -} - -export function createInitialBottomReservationState(): BottomReservationState { - return { - collapse: { - kind: 'collapse', - px: 0, - floorPx: 0, - }, - pin: { - kind: 'pin', - px: 0, - floorPx: 0, - mode: 'transient', - targetTurnId: null, - }, - }; -} - -export function createInactiveAnchorLock(): ScrollAnchorLockState { - return { - active: false, - targetScrollTop: 0, - reason: null, - lockUntilMs: 0, - }; -} - -export function createInactiveCollapseIntent(): PendingCollapseIntentState { - return { - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, - }; -} - -export function sanitizeReservationPx(value: number): number { - return Number.isFinite(value) ? Math.max(0, value) : 0; -} - -export function sanitizeBottomReservationState(state: BottomReservationState): BottomReservationState { - const collapsePx = sanitizeReservationPx(state.collapse.px); - const collapseFloorPx = Math.min(collapsePx, sanitizeReservationPx(state.collapse.floorPx)); - const pinPx = sanitizeReservationPx(state.pin.px); - const pinFloorPx = Math.min(pinPx, sanitizeReservationPx(state.pin.floorPx)); - - return { - collapse: { - kind: 'collapse', - px: collapsePx, - floorPx: collapseFloorPx, - }, - pin: { - kind: 'pin', - px: pinPx, - floorPx: pinFloorPx, - mode: state.pin.mode ?? 'transient', - targetTurnId: state.pin.targetTurnId ?? null, - }, - }; -} - -export function areBottomReservationStatesEqual( - left: BottomReservationState, - right: BottomReservationState, -): boolean { - return ( - Math.abs(left.collapse.px - right.collapse.px) <= COMPENSATION_EPSILON_PX && - Math.abs(left.collapse.floorPx - right.collapse.floorPx) <= COMPENSATION_EPSILON_PX && - Math.abs(left.pin.px - right.pin.px) <= COMPENSATION_EPSILON_PX && - Math.abs(left.pin.floorPx - right.pin.floorPx) <= COMPENSATION_EPSILON_PX && - left.pin.mode === right.pin.mode && - left.pin.targetTurnId === right.pin.targetTurnId - ); -} - -export function getReservationTotalPx(reservation: BottomReservationBase): number { - return Math.max(0, reservation.px); -} - -export function getReservationConsumablePx(reservation: BottomReservationBase): number { - return Math.max(0, reservation.px - reservation.floorPx); -} diff --git a/src/web-ui/src/flow_chat/scroll/FlowScrollPolicy.ts b/src/web-ui/src/flow_chat/scroll/FlowScrollPolicy.ts index 6a95ec22..92cca0ec 100644 --- a/src/web-ui/src/flow_chat/scroll/FlowScrollPolicy.ts +++ b/src/web-ui/src/flow_chat/scroll/FlowScrollPolicy.ts @@ -13,10 +13,9 @@ export type FlowScrollExitReason = | 'scrollbar-drag' | 'explicit-navigation'; +// Constants for plain/nested panel scroll controllers. The virtualized main +// list uses the viewport machine + scheduler under `viewport/` instead and +// does not rely on timing windows at all. export const FLOW_SCROLL_PROGRAMMATIC_GUARD_MS = 160; export const FLOW_SCROLL_BOTTOM_THRESHOLD_PX = 100; export const FLOW_SCROLL_NESTED_BOTTOM_THRESHOLD_PX = 80; -export const FLOW_SCROLL_VIRTUOSO_AUTO_FOLLOW_THRESHOLD_PX = 24; -export const FLOW_SCROLL_USER_DIRECTION_EPSILON_PX = 0.5; -export const FLOW_SCROLL_USER_INTENT_WINDOW_MS = 450; -export const FLOW_SCROLL_CONTINUOUS_FOLLOW_IDLE_FRAMES = 4; diff --git a/src/web-ui/src/flow_chat/scroll/README.md b/src/web-ui/src/flow_chat/scroll/README.md index d4a52866..d8e9499f 100644 --- a/src/web-ui/src/flow_chat/scroll/README.md +++ b/src/web-ui/src/flow_chat/scroll/README.md @@ -3,54 +3,93 @@ FlowChat scroll behavior is orchestrated from this module, not from individual message, panel, or tool-card components. -## Layers +## Main List: Viewport Machine + Scheduler -- `FlowScrollIntent.ts` detects user intent such as wheel-up, touch-up, +The virtualized main chat list (`VirtualMessageList`) is driven by the +`viewport/` subsystem: + +- `viewport/FlowViewportMachine.ts` — the pure mode state machine. The single + authority for what the viewport is doing: `reading`, `pinned-latest`, + `following`, `finalizing`, or `navigating`. All transitions are explicit + events; nothing infers intent from scroll deltas or timing windows. +- `viewport/FlowViewportScheduler.ts` — the single requestAnimationFrame + pipeline and the **only writer of `scrollTop`**. Each frame it reads + geometry, reconciles reservations (growth consumption, pin floor, reading + shrink protection), computes the mode's target position, and performs at + most one scroll write. It also owns the retargeting ease-out animator used + for smooth navigation (native `behavior: 'smooth'` cannot chase a growing + bottom). +- `viewport/FlowViewportGeometry.ts` — pure math for the synthetic bottom + reservation model (collapse compensation, pin tail + non-consumable floor, + effective content height, the unified content-bottom distance). +- `viewport/useFlowViewportController.ts` — React assembly: input intent + listeners, layout observers, layout mutation events, session/turn/stream + effects, and the imperative command API consumed through + `VirtualMessageListRef`. + +### Core invariants + +- `scrollTop` is written only by the scheduler pipeline (plus Virtuoso itself + during `navigating` when the target item is not rendered yet — the one + documented third-party writer). +- Footer height may additionally be written synchronously in two places that + must land before the browser clamps or paints: collapse-intent + pre-compensation and consumable-reservation consumption on user downward + scroll. These never touch `scrollTop`. +- User intent is derived from input events (wheel up, touch pull-down, upward + keys, scrollbar grab), never from scroll position deltas. An upward intent + immediately dispatches `USER_SCROLL_UP` and hands the viewport to the user. + There are no programmatic-scroll guard windows. +- While `pinned-latest`, content growth below the pinned turn is exchanged 1:1 + against the pin floor so `scrollHeight` stays constant and the pinned + message never moves. The floor survives stream end (short answers stay + pinned) and is only released by a new turn, an explicit navigation away, or + a session change. +- Pin mode is derived from the target: pinning the latest turn is always + `sticky-latest`; pinning an older turn is always `transient`. +- All height comparisons use effective heights + (`scrollHeight - reservations - inputFooter`). The single "at bottom" + semantics is `getContentDistanceFromBottom`, which excludes synthetic tail + space; Virtuoso's `atBottomStateChange` is not used. + +## Shared Layers + +- `FlowScrollIntent.ts` classifies user input: wheel-up, touch-up, keyboard-up, editable targets, and scrollbar gutter interactions. -- `FlowScrollPolicy.ts` defines shared modes, thresholds, and guard windows. -- `FlowScrollGeometry.ts` owns bottom reservations, anchor locks, collapse - intents, and scroll-height sanitation for the virtualized main list. +- `FlowScrollPolicy.ts` defines thresholds for the plain/nested panel + controllers. - `FlowLayoutMutationEvents.ts` is the stable event contract for components that change layout height. Prefer `invalidateFlowLayout({ reason, priority })` over dispatching raw DOM events. - `useFlowLayoutMutationContract.ts` is the preferred component-facing API for expand/collapse actions that affect FlowChat layout. -- `adapters/` binds the shared policy to concrete scroll hosts. - -## Adapters - -- `usePlainFlowScrollController` is for plain panel scroll containers such as - task details and child sessions. -- `useNestedFlowScrollController` is for nested scroll regions inside cards, - such as code previews, thinking output, and explore groups. -- `useVirtuosoFlowFollowOutput` is the Virtuoso-specific follow-output adapter. -- `useVirtuosoFlowGeometryController` owns bottom reservations, anchor locks, - footer compensation, measured-height snapshots, and height-change scheduling - for the virtualized list host. -- `useVirtuosoFlowLayoutMutationBridge` adapts layout mutation events into the - main list's reservation and anchor-lock model. -- `useVirtuosoFlowLayoutObservers` owns ResizeObserver, MutationObserver, and - transition listeners for the virtualized list host. -- `useVirtuosoFlowNavigationController` owns turn pinning, sticky pin - reconciliation, pending pin retries, and imperative navigation commands for - the virtualized list host. -- `useVirtuosoFlowUserIntentBridge` owns scroll, wheel, touch, keyboard, and - scrollbar-drag intent handling for the virtualized list host. -- `useVirtuosoVisibleTurnTracker` owns DOM visibility measurement for the - current turn marker in the virtualized list. - -`VirtualMessageList` should only wire these adapters to the render tree and -expose controller commands through its imperative ref. + +## Panel Adapters + +- `adapters/usePlainFlowScrollController` — plain panel scroll containers such + as task details and child sessions. +- `adapters/useNestedFlowScrollController` — nested scroll regions inside + cards, such as code previews, thinking output, and explore groups. +- `adapters/useTaskDetailPanelScrollController` — the task detail Virtuoso + panel. +- `adapters/useVirtuosoVisibleTurnTracker` — DOM visibility measurement for + the current turn marker in the virtualized list (measurement only; it never + scrolls). ## Component Contract -Components should not directly preserve FlowChat scroll position. They should: +Components must not preserve FlowChat scroll position themselves. They should: 1. Use an adapter when they own a scroll container. -2. Use `useFlowLayoutMutationContract` before/after height-changing disclosure - or predictable content upgrades. +2. Use `useFlowLayoutMutationContract` (or `useToolDisclosureController`) + before/after height-changing disclosure or predictable content upgrades. + Dispatch the collapse intent **before** the collapse state is applied, and + prefer a generous `cardHeight` estimate (over-estimating is invisible; + under-estimating flashes). 3. Keep visual scroll-state decoration local, such as fade gradients. -4. Avoid direct `scrollTop = scrollHeight` follow logic. +4. Never write `scrollTop = scrollHeight` follow logic against the main list. -The scroll layer decides whether the active user intent is following the latest -output, reading history, navigating, or stabilizing layout. +The viewport machine decides whether the active mode is pinned, following, +finalizing, reading, or navigating — and how expand/collapse height changes +are absorbed in each. See `FLOWCHAT_SCROLL_STABILITY.md` next to +`VirtualMessageList.tsx` for the height-change handling matrix. diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowFollowOutput.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowFollowOutput.ts deleted file mode 100644 index 742fd5d5..00000000 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowFollowOutput.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * Virtuoso adapter follow-output controller for the virtualized FlowChat list. - * - * Keeps follow state local to the viewport layer while separating the - * "when should we follow" policy from the low-level list scroll mechanics. - */ - -import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; -import { - FLOW_SCROLL_CONTINUOUS_FOLLOW_IDLE_FRAMES, - FLOW_SCROLL_PROGRAMMATIC_GUARD_MS, - FLOW_SCROLL_USER_DIRECTION_EPSILON_PX, - FLOW_SCROLL_USER_INTENT_WINDOW_MS, - FLOW_SCROLL_VIRTUOSO_AUTO_FOLLOW_THRESHOLD_PX, -} from '../FlowScrollPolicy'; -import { incrementFlowChatCounter } from '../../performance/flowChatPerf'; - -export type FollowOutputEnterReason = 'jump-to-latest' | 'auto-follow'; -export type FollowOutputExitReason = - | 'session-changed' - | 'user-scroll-up' - | 'scroll-to-turn' - | 'scroll-to-index' - | 'pin-turn-to-top'; - -interface UseVirtuosoFlowFollowOutputOptions { - activeSessionId?: string; - latestTurnId: string | null; - virtualItemCount: number; - isStreaming: boolean; - scrollerRef: RefObject; - performUserFollowScroll: () => void; - performAutoFollowScroll: () => void; - performLatestTurnStickyPin: () => void; - /** - * Returns true when auto-follow should be suspended for layout-protection - * reasons (collapse animation, etc.). The continuous follow loop ignores - * this signal because follow mode actively wants to track the bottom even - * while intermediate cards collapse; only the event-driven `scheduleFollowToLatest` - * still respects it for backward compatibility with anchor restore paths. - */ - shouldSuspendAutoFollow?: () => boolean; - getAutoFollowDistanceFromBottom?: (scroller: HTMLElement) => number; - /** - * Optional per-frame hook invoked from inside the continuous follow loop. - * Used to reconcile sticky-latest pin floor in lockstep with the scroll - * adjustment so the pin reservation never lags behind a shrinking layout. - */ - onContinuousFollowFrame?: () => void; -} - -interface UseVirtuosoFlowFollowOutputResult { - isFollowingOutput: boolean; - enterFollowOutput: (reason: FollowOutputEnterReason) => void; - exitFollowOutput: (reason: FollowOutputExitReason) => void; - armFollowOutputForNewTurn: () => void; - activateArmedFollowOutput: () => boolean; - cancelPendingAutoFollowArm: () => void; - scheduleFollowToLatest: (reason: string) => void; - handleUserScrollIntent: () => void; - handleScroll: () => void; -} - -function getDistanceFromBottom(scroller: HTMLElement): number { - return Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop); -} - -export function useVirtuosoFlowFollowOutput({ - activeSessionId, - latestTurnId, - virtualItemCount, - isStreaming, - scrollerRef, - performUserFollowScroll, - performAutoFollowScroll, - performLatestTurnStickyPin, - shouldSuspendAutoFollow, - getAutoFollowDistanceFromBottom, - onContinuousFollowFrame, -}: UseVirtuosoFlowFollowOutputOptions): UseVirtuosoFlowFollowOutputResult { - const [isFollowingOutput, setIsFollowingOutput] = useState(false); - - const isFollowingOutputRef = useRef(isFollowingOutput); - const followFrameRef = useRef(null); - const programmaticScrollUntilMsRef = useRef(0); - const explicitUserScrollIntentUntilMsRef = useRef(0); - const lastObservedScrollTopRef = useRef(0); - const previousSessionIdRef = useRef(activeSessionId); - const armedAutoFollowTurnIdRef = useRef(null); - const continuousFollowFrameRef = useRef(null); - const continuousFollowIdleFramesRef = useRef(0); - const isStreamingRef = useRef(isStreaming); - const performAutoFollowScrollRef = useRef(performAutoFollowScroll); - const onContinuousFollowFrameRef = useRef(onContinuousFollowFrame); - const getAutoFollowDistanceFromBottomRef = useRef(getAutoFollowDistanceFromBottom); - - isStreamingRef.current = isStreaming; - performAutoFollowScrollRef.current = performAutoFollowScroll; - onContinuousFollowFrameRef.current = onContinuousFollowFrame; - getAutoFollowDistanceFromBottomRef.current = getAutoFollowDistanceFromBottom; - - const setFollowingOutput = useCallback((nextValue: boolean) => { - isFollowingOutputRef.current = nextValue; - setIsFollowingOutput(prev => (prev === nextValue ? prev : nextValue)); - if (!nextValue && continuousFollowFrameRef.current !== null) { - cancelAnimationFrame(continuousFollowFrameRef.current); - continuousFollowFrameRef.current = null; - } - }, []); - - const cancelScheduledFollow = useCallback(() => { - if (followFrameRef.current !== null) { - cancelAnimationFrame(followFrameRef.current); - followFrameRef.current = null; - } - }, []); - - const stopContinuousFollowLoop = useCallback(() => { - if (continuousFollowFrameRef.current !== null) { - cancelAnimationFrame(continuousFollowFrameRef.current); - continuousFollowFrameRef.current = null; - } - }, []); - - /** - * Adaptive RAF-driven follow loop. - * - * Why this exists: - * - Streaming text + auto-collapsing tool cards generate dense bursts of - * DOM mutations and CSS transitions. Event-driven follow (via observers) - * is gated by `shouldSuspendAutoFollow` during transitions, which makes - * the viewport visibly stall and then jump after the transition ends. - * - This loop runs while the viewport is visibly behind the tail, pushing - * scrollTop toward the latest token regardless of intermediate layout - * shrink. Once the tail is stable for a few frames, it exits and lets - * event-driven follow wake it again. - * - * Safety: - * - Programmatic scrolls inside this loop bump - * `programmaticScrollUntilMsRef` so the user-intent detector does not - * misclassify them as upward scrolls. - * - The loop bails out as soon as follow is exited, streaming ends, the - * scroller disappears, or the viewport has stayed pinned to the bottom. - */ - const runContinuousFollowFrame = useCallback(() => { - incrementFlowChatCounter('scroll.continuousFollowFrame'); - continuousFollowFrameRef.current = null; - - if (!isFollowingOutputRef.current || !isStreamingRef.current) { - return; - } - - const scroller = scrollerRef.current; - if (!scroller) { - return; - } - - onContinuousFollowFrameRef.current?.(); - - const rawDistance = getDistanceFromBottom(scroller); - const measuredDistance = getAutoFollowDistanceFromBottomRef.current?.(scroller) ?? rawDistance; - if (measuredDistance > FLOW_SCROLL_VIRTUOSO_AUTO_FOLLOW_THRESHOLD_PX) { - continuousFollowIdleFramesRef.current = 0; - programmaticScrollUntilMsRef.current = performance.now() + FLOW_SCROLL_PROGRAMMATIC_GUARD_MS; - explicitUserScrollIntentUntilMsRef.current = 0; - performAutoFollowScrollRef.current(); - lastObservedScrollTopRef.current = scroller.scrollTop; - } else { - continuousFollowIdleFramesRef.current += 1; - } - - if (!isFollowingOutputRef.current || !isStreamingRef.current) { - return; - } - - if (continuousFollowIdleFramesRef.current >= FLOW_SCROLL_CONTINUOUS_FOLLOW_IDLE_FRAMES) { - continuousFollowIdleFramesRef.current = 0; - return; - } - - continuousFollowFrameRef.current = requestAnimationFrame(runContinuousFollowFrame); - }, [scrollerRef]); - - const startContinuousFollowLoop = useCallback(() => { - if (continuousFollowFrameRef.current !== null) { - return; - } - if (!isFollowingOutputRef.current || !isStreamingRef.current) { - return; - } - continuousFollowIdleFramesRef.current = 0; - continuousFollowFrameRef.current = requestAnimationFrame(runContinuousFollowFrame); - }, [runContinuousFollowFrame]); - - const cancelPendingAutoFollowArm = useCallback(() => { - armedAutoFollowTurnIdRef.current = null; - }, []); - - const runProgrammaticScroll = useCallback((scrollAction: () => void) => { - programmaticScrollUntilMsRef.current = performance.now() + FLOW_SCROLL_PROGRAMMATIC_GUARD_MS; - explicitUserScrollIntentUntilMsRef.current = 0; - scrollAction(); - const scroller = scrollerRef.current; - if (scroller) { - lastObservedScrollTopRef.current = scroller.scrollTop; - } - }, [scrollerRef]); - - const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { - cancelPendingAutoFollowArm(); - cancelScheduledFollow(); - explicitUserScrollIntentUntilMsRef.current = 0; - setFollowingOutput(true); - const followAction = reason === 'jump-to-latest' - ? performUserFollowScroll - : performAutoFollowScroll; - runProgrammaticScroll(followAction); - }, [ - cancelPendingAutoFollowArm, - cancelScheduledFollow, - performAutoFollowScroll, - performUserFollowScroll, - runProgrammaticScroll, - setFollowingOutput, - ]); - - const exitFollowOutput = useCallback((_reason: FollowOutputExitReason) => { - cancelPendingAutoFollowArm(); - cancelScheduledFollow(); - explicitUserScrollIntentUntilMsRef.current = 0; - setFollowingOutput(false); - const scroller = scrollerRef.current; - if (scroller) { - lastObservedScrollTopRef.current = scroller.scrollTop; - } - }, [cancelPendingAutoFollowArm, cancelScheduledFollow, scrollerRef, setFollowingOutput]); - - const armFollowOutputForNewTurn = useCallback(() => { - if (!latestTurnId) { - cancelPendingAutoFollowArm(); - return; - } - - armedAutoFollowTurnIdRef.current = latestTurnId; - cancelScheduledFollow(); - setFollowingOutput(false); - runProgrammaticScroll(performLatestTurnStickyPin); - }, [ - cancelPendingAutoFollowArm, - cancelScheduledFollow, - latestTurnId, - performLatestTurnStickyPin, - runProgrammaticScroll, - setFollowingOutput, - ]); - - const activateArmedFollowOutput = useCallback(() => { - const armedTurnId = armedAutoFollowTurnIdRef.current; - const isAlreadyFollowing = isFollowingOutputRef.current; - const isArmedForLatestTurn = Boolean(latestTurnId && armedTurnId === latestTurnId); - const isAutoFollowSuspended = shouldSuspendAutoFollow?.() === true; - - if (!latestTurnId || !isArmedForLatestTurn || isAlreadyFollowing) { - return false; - } - - if (isAutoFollowSuspended) { - return false; - } - - cancelPendingAutoFollowArm(); - cancelScheduledFollow(); - setFollowingOutput(true); - runProgrammaticScroll(performAutoFollowScroll); - return true; - }, [ - cancelPendingAutoFollowArm, - cancelScheduledFollow, - latestTurnId, - performAutoFollowScroll, - runProgrammaticScroll, - setFollowingOutput, - shouldSuspendAutoFollow, - ]); - - const handleUserScrollIntent = useCallback(() => { - if (!isFollowingOutputRef.current && armedAutoFollowTurnIdRef.current === null) { - return; - } - - const now = performance.now(); - if (now <= programmaticScrollUntilMsRef.current) { - return; - } - explicitUserScrollIntentUntilMsRef.current = now + FLOW_SCROLL_USER_INTENT_WINDOW_MS; - }, []); - - const scheduleFollowToLatest = useCallback((_reason: string) => { - if ( - !isFollowingOutputRef.current || - !isStreaming || - virtualItemCount === 0 || - shouldSuspendAutoFollow?.() === true - ) { - return; - } - - if (followFrameRef.current !== null) { - return; - } - - followFrameRef.current = requestAnimationFrame(() => { - followFrameRef.current = null; - - if (!isFollowingOutputRef.current || !isStreaming || virtualItemCount === 0) { - return; - } - - if (shouldSuspendAutoFollow?.() === true) { - return; - } - - const scroller = scrollerRef.current; - if (!scroller) { - return; - } - - const rawDistanceFromBottom = getDistanceFromBottom(scroller); - const distanceFromBottom = getAutoFollowDistanceFromBottom?.(scroller) ?? rawDistanceFromBottom; - if (distanceFromBottom <= FLOW_SCROLL_VIRTUOSO_AUTO_FOLLOW_THRESHOLD_PX) { - return; - } - - runProgrammaticScroll(performAutoFollowScroll); - startContinuousFollowLoop(); - }); - }, [getAutoFollowDistanceFromBottom, isStreaming, performAutoFollowScroll, runProgrammaticScroll, scrollerRef, shouldSuspendAutoFollow, startContinuousFollowLoop, virtualItemCount]); - - const handleScroll = useCallback(() => { - const scroller = scrollerRef.current; - if (!scroller) { - return; - } - - const currentScrollTop = scroller.scrollTop; - const previousScrollTop = lastObservedScrollTopRef.current; - lastObservedScrollTopRef.current = currentScrollTop; - - if (!isFollowingOutputRef.current && armedAutoFollowTurnIdRef.current === null) { - return; - } - - if (performance.now() <= programmaticScrollUntilMsRef.current) { - return; - } - - if (shouldSuspendAutoFollow?.() === true) { - return; - } - - const upwardDelta = previousScrollTop - currentScrollTop; - if (upwardDelta > FLOW_SCROLL_USER_DIRECTION_EPSILON_PX) { - const now = performance.now(); - const hasRecentExplicitUserIntent = now <= explicitUserScrollIntentUntilMsRef.current; - const distanceFromBottom = getDistanceFromBottom(scroller); - if (!hasRecentExplicitUserIntent) { - if ( - isFollowingOutputRef.current && - distanceFromBottom <= FLOW_SCROLL_VIRTUOSO_AUTO_FOLLOW_THRESHOLD_PX - ) { - return; - } - return; - } - - explicitUserScrollIntentUntilMsRef.current = 0; - - if (!isFollowingOutputRef.current) { - cancelPendingAutoFollowArm(); - return; - } - - exitFollowOutput('user-scroll-up'); - } - }, [cancelPendingAutoFollowArm, exitFollowOutput, scrollerRef, shouldSuspendAutoFollow]); - - useEffect(() => { - const scroller = scrollerRef.current; - if (scroller) { - lastObservedScrollTopRef.current = scroller.scrollTop; - } - }, [scrollerRef]); - - useEffect(() => { - const previousSessionId = previousSessionIdRef.current; - if (previousSessionId === activeSessionId) { - return; - } - - previousSessionIdRef.current = activeSessionId; - cancelPendingAutoFollowArm(); - cancelScheduledFollow(); - explicitUserScrollIntentUntilMsRef.current = 0; - const nextFollowState = Boolean(activeSessionId && virtualItemCount === 0); - - if (nextFollowState) { - setFollowingOutput(true); - return; - } - - setFollowingOutput(false); - }, [ - activeSessionId, - cancelPendingAutoFollowArm, - cancelScheduledFollow, - latestTurnId, - setFollowingOutput, - virtualItemCount, - ]); - - useEffect(() => { - if (!isFollowingOutput || !isStreaming) { - stopContinuousFollowLoop(); - return; - } - - scheduleFollowToLatest('streaming-started'); - startContinuousFollowLoop(); - }, [isFollowingOutput, isStreaming, scheduleFollowToLatest, startContinuousFollowLoop, stopContinuousFollowLoop]); - - useEffect(() => { - return () => { - cancelScheduledFollow(); - stopContinuousFollowLoop(); - }; - }, [cancelScheduledFollow, stopContinuousFollowLoop]); - - return { - isFollowingOutput, - enterFollowOutput, - exitFollowOutput, - armFollowOutputForNewTurn, - activateArmedFollowOutput, - cancelPendingAutoFollowArm, - scheduleFollowToLatest, - handleUserScrollIntent, - handleScroll, - }; -} diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowGeometryController.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowGeometryController.ts deleted file mode 100644 index bfd6e62a..00000000 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowGeometryController.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject, type RefObject } from 'react'; -import { - ANCHOR_LOCK_DURATION_MS, - ANCHOR_LOCK_MIN_DEVIATION_PX, - COMPENSATION_EPSILON_PX, - areBottomReservationStatesEqual, - createInactiveAnchorLock, - createInactiveCollapseIntent, - createInitialBottomReservationState, - getReservationConsumablePx, - getReservationTotalPx, - sanitizeBottomReservationState, - type BottomReservationState, - type PendingCollapseIntentState, - type ScrollAnchorLockState, -} from '../FlowScrollGeometry'; - -interface UseVirtuosoFlowGeometryControllerOptions { - footerElementRef: RefObject; - scrollerElementRef: RefObject; - inputStackFooterPxRef: MutableRefObject; - layoutTransitionCountRef: MutableRefObject; - isFollowingOutputRef: MutableRefObject; - isStreamingOutputRef: MutableRefObject; -} - -export function useVirtuosoFlowGeometryController({ - footerElementRef, - scrollerElementRef, - inputStackFooterPxRef, - layoutTransitionCountRef, - isFollowingOutputRef, - isStreamingOutputRef, -}: UseVirtuosoFlowGeometryControllerOptions) { - const [bottomReservationState, setBottomReservationState] = useState( - () => createInitialBottomReservationState() - ); - - const bottomReservationStateRef = useRef(createInitialBottomReservationState()); - const previousMeasuredHeightRef = useRef(null); - const previousScrollTopRef = useRef(0); - const measureFrameRef = useRef(null); - const anchorLockRef = useRef(createInactiveAnchorLock()); - const pendingCollapseIntentRef = useRef(createInactiveCollapseIntent()); - - const getFooterHeightPx = useCallback((compensationPx: number) => { - return inputStackFooterPxRef.current + compensationPx; - }, [inputStackFooterPxRef]); - - const getTotalBottomCompensationPx = useCallback((state: BottomReservationState = bottomReservationStateRef.current) => { - return getReservationTotalPx(state.collapse) + getReservationTotalPx(state.pin); - }, []); - - const snapshotMeasuredContentHeight = useCallback(( - scroller: HTMLElement, - reservationState: BottomReservationState = bottomReservationStateRef.current, - ) => { - const compensationPx = getTotalBottomCompensationPx(reservationState); - return Math.max(0, scroller.scrollHeight - compensationPx - inputStackFooterPxRef.current); - }, [getTotalBottomCompensationPx, inputStackFooterPxRef]); - - const updateBottomReservationState = useCallback(( - updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState), - ) => { - setBottomReservationState(prev => { - const rawNext = typeof updater === 'function' ? updater(prev) : updater; - const next = sanitizeBottomReservationState(rawNext); - bottomReservationStateRef.current = next; - return areBottomReservationStatesEqual(next, prev) ? prev : next; - }); - }, []); - - const resetBottomReservations = useCallback(() => { - updateBottomReservationState(createInitialBottomReservationState()); - }, [updateBottomReservationState]); - - const resetTransientGeometryState = useCallback(() => { - previousMeasuredHeightRef.current = null; - previousScrollTopRef.current = 0; - anchorLockRef.current = createInactiveAnchorLock(); - pendingCollapseIntentRef.current = createInactiveCollapseIntent(); - resetBottomReservations(); - }, [resetBottomReservations]); - - const consumeBottomCompensation = useCallback((amountPx: number) => { - if (amountPx <= COMPENSATION_EPSILON_PX) { - return bottomReservationStateRef.current; - } - - let resolvedNextState = bottomReservationStateRef.current; - updateBottomReservationState(prev => { - let remaining = Math.max(0, amountPx); - - const collapseConsumablePx = getReservationConsumablePx(prev.collapse); - const collapseConsumed = Math.min(collapseConsumablePx, remaining); - remaining -= collapseConsumed; - - const pinConsumablePx = getReservationConsumablePx(prev.pin); - const pinConsumed = Math.min(pinConsumablePx, remaining); - - const nextState: BottomReservationState = { - collapse: { - ...prev.collapse, - px: Math.max(prev.collapse.floorPx, prev.collapse.px - collapseConsumed), - }, - pin: { - ...prev.pin, - px: Math.max(prev.pin.floorPx, prev.pin.px - pinConsumed), - }, - }; - resolvedNextState = nextState; - return nextState; - }); - return resolvedNextState; - }, [updateBottomReservationState]); - - const applyFooterCompensationNow = useCallback((compensation: number | BottomReservationState) => { - const footer = footerElementRef.current; - const scroller = scrollerElementRef.current; - if (!footer || !scroller) return; - - const compensationPx = typeof compensation === 'number' - ? compensation - : getTotalBottomCompensationPx(compensation); - const footerHeightPx = getFooterHeightPx(compensationPx); - footer.style.height = `${footerHeightPx}px`; - footer.style.minHeight = `${footerHeightPx}px`; - void footer.offsetHeight; - void scroller.scrollHeight; - }, [footerElementRef, getFooterHeightPx, getTotalBottomCompensationPx, scrollerElementRef]); - - const releaseAnchorLock = useCallback((_reason: string) => { - if (!anchorLockRef.current.active) return; - anchorLockRef.current = createInactiveAnchorLock(); - }, []); - - const activateAnchorLock = useCallback((targetScrollTop: number, reason: 'transition-shrink' | 'instant-shrink') => { - const nextTarget = Math.max(anchorLockRef.current.targetScrollTop, targetScrollTop); - anchorLockRef.current = { - active: true, - targetScrollTop: nextTarget, - reason, - lockUntilMs: performance.now() + ANCHOR_LOCK_DURATION_MS, - }; - }, []); - - const restoreAnchorLockNow = useCallback((reason: string) => { - const scroller = scrollerElementRef.current; - const lockState = anchorLockRef.current; - if (!scroller || !lockState.active) return false; - - const now = performance.now(); - if (now > lockState.lockUntilMs && layoutTransitionCountRef.current === 0) { - releaseAnchorLock(`expired-before-${reason}`); - return false; - } - - const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const targetScrollTop = Math.min(lockState.targetScrollTop, maxScrollTop); - const currentScrollTop = scroller.scrollTop; - const restoreDelta = targetScrollTop - currentScrollTop; - - if (Math.abs(restoreDelta) <= ANCHOR_LOCK_MIN_DEVIATION_PX) { - return false; - } - - scroller.scrollTop = targetScrollTop; - previousScrollTopRef.current = targetScrollTop; - return true; - }, [layoutTransitionCountRef, releaseAnchorLock, scrollerElementRef]); - - const measureHeightChange = useCallback(() => { - const scroller = scrollerElementRef.current; - if (!scroller) return; - - const currentScrollTop = scroller.scrollTop; - const previousScrollTop = previousScrollTopRef.current; - const currentTotalCompensation = getTotalBottomCompensationPx(); - const effectiveScrollHeight = Math.max( - 0, - scroller.scrollHeight - currentTotalCompensation - inputStackFooterPxRef.current, - ); - const previousMeasuredHeight = previousMeasuredHeightRef.current; - previousMeasuredHeightRef.current = effectiveScrollHeight; - - if (previousMeasuredHeight === null) { - previousScrollTopRef.current = currentScrollTop; - return; - } - - const heightDelta = effectiveScrollHeight - previousMeasuredHeight; - if (Math.abs(heightDelta) <= COMPENSATION_EPSILON_PX) { - previousScrollTopRef.current = currentScrollTop; - return; - } - - const distanceFromBottom = Math.max( - 0, - scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop - ); - - if (heightDelta > 0) { - if (currentTotalCompensation > COMPENSATION_EPSILON_PX && layoutTransitionCountRef.current > 0) { - previousScrollTopRef.current = currentScrollTop; - return; - } - - const nextReservationState = consumeBottomCompensation(heightDelta); - applyFooterCompensationNow(nextReservationState); - previousScrollTopRef.current = currentScrollTop; - return; - } - - const shrinkAmount = -heightDelta; - if (isFollowingOutputRef.current && isStreamingOutputRef.current) { - previousScrollTopRef.current = currentScrollTop; - return; - } - - const collapseIntent = pendingCollapseIntentRef.current; - const now = performance.now(); - const hasValidCollapseIntent = collapseIntent.active && collapseIntent.expiresAtMs >= now; - const fallbackAdditionalCompensation = Math.max(0, shrinkAmount - distanceFromBottom); - const cumulativeShrinkPx = hasValidCollapseIntent - ? collapseIntent.cumulativeShrinkPx + shrinkAmount - : 0; - const resolvedIntentCompensation = hasValidCollapseIntent - ? collapseIntent.baseTotalCompensationPx + Math.max(0, cumulativeShrinkPx - collapseIntent.distanceFromBottomBeforeCollapse) - : 0; - const nextTotalCompensation = hasValidCollapseIntent - ? ( - layoutTransitionCountRef.current > 0 - ? Math.max(currentTotalCompensation, resolvedIntentCompensation) - : resolvedIntentCompensation - ) - : currentTotalCompensation + fallbackAdditionalCompensation; - - if (hasValidCollapseIntent) { - pendingCollapseIntentRef.current = { - ...collapseIntent, - cumulativeShrinkPx, - }; - } - - if (!hasValidCollapseIntent && fallbackAdditionalCompensation <= COMPENSATION_EPSILON_PX) { - previousScrollTopRef.current = currentScrollTop; - return; - } - - const nextReservationState: BottomReservationState = { - ...bottomReservationStateRef.current, - collapse: { - ...bottomReservationStateRef.current.collapse, - px: Math.max(0, nextTotalCompensation - getReservationTotalPx(bottomReservationStateRef.current.pin)), - floorPx: 0, - }, - }; - updateBottomReservationState(nextReservationState); - if (nextTotalCompensation > COMPENSATION_EPSILON_PX) { - const anchorTarget = - hasValidCollapseIntent - ? collapseIntent.anchorScrollTop - : previousScrollTop; - - activateAnchorLock( - anchorTarget, - layoutTransitionCountRef.current > 0 ? 'transition-shrink' : 'instant-shrink' - ); - applyFooterCompensationNow(nextReservationState); - restoreAnchorLockNow('measure-shrink'); - if (layoutTransitionCountRef.current === 0) { - pendingCollapseIntentRef.current = createInactiveCollapseIntent(); - } - } - - previousScrollTopRef.current = currentScrollTop; - }, [ - activateAnchorLock, - applyFooterCompensationNow, - consumeBottomCompensation, - getTotalBottomCompensationPx, - inputStackFooterPxRef, - isFollowingOutputRef, - isStreamingOutputRef, - layoutTransitionCountRef, - restoreAnchorLockNow, - scrollerElementRef, - updateBottomReservationState, - ]); - - const scheduleHeightMeasure = useCallback((frames: number = 1) => { - if (measureFrameRef.current !== null) { - cancelAnimationFrame(measureFrameRef.current); - measureFrameRef.current = null; - } - - const run = (remainingFrames: number) => { - measureFrameRef.current = requestAnimationFrame(() => { - if (remainingFrames > 1) { - run(remainingFrames - 1); - return; - } - - measureFrameRef.current = null; - measureHeightChange(); - }); - }; - - run(Math.max(1, frames)); - }, [measureHeightChange]); - - const cancelScheduledHeightMeasure = useCallback(() => { - if (measureFrameRef.current !== null) { - cancelAnimationFrame(measureFrameRef.current); - measureFrameRef.current = null; - } - }, []); - - useEffect(() => cancelScheduledHeightMeasure, [cancelScheduledHeightMeasure]); - - return { - bottomReservationState, - bottomReservationStateRef, - previousMeasuredHeightRef, - previousScrollTopRef, - anchorLockRef, - pendingCollapseIntentRef, - getFooterHeightPx, - getTotalBottomCompensationPx, - snapshotMeasuredContentHeight, - updateBottomReservationState, - resetBottomReservations, - resetTransientGeometryState, - consumeBottomCompensation, - applyFooterCompensationNow, - releaseAnchorLock, - activateAnchorLock, - scheduleHeightMeasure, - cancelScheduledHeightMeasure, - }; -} diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutMutationBridge.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutMutationBridge.ts deleted file mode 100644 index 829f4ea1..00000000 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutMutationBridge.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, type MutableRefObject } from 'react'; -import { - FLOW_LAYOUT_COLLAPSE_INTENT_EVENT, - FLOW_LAYOUT_MUTATION_EVENT, - isFlowLayoutCollapseIntentEvent, - isFlowLayoutMutationEvent, -} from '../FlowLayoutMutationEvents'; -import { - COMPENSATION_EPSILON_PX, - getReservationTotalPx, - type BottomReservationState, - type PendingCollapseIntentState, -} from '../FlowScrollGeometry'; - -interface UseVirtuosoFlowLayoutMutationBridgeOptions { - scrollerElement: HTMLElement | null; - isFollowingOutputRef: MutableRefObject; - isStreamingOutputRef: MutableRefObject; - bottomReservationStateRef: MutableRefObject; - pendingCollapseIntentRef: MutableRefObject; - scheduleHeightMeasure: (frames?: number) => void; - scheduleVisibleTurnMeasure: (frames?: number) => void; - schedulePinReservationReconcile: (frames?: number) => void; - getTotalBottomCompensationPx: (state?: BottomReservationState) => number; - updateBottomReservationState: ( - updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState), - ) => void; - applyFooterCompensationNow: (compensation: number | BottomReservationState) => void; - activateAnchorLock: (targetScrollTop: number, reason: 'transition-shrink' | 'instant-shrink') => void; -} - -export function useVirtuosoFlowLayoutMutationBridge({ - scrollerElement, - isFollowingOutputRef, - isStreamingOutputRef, - bottomReservationStateRef, - pendingCollapseIntentRef, - scheduleHeightMeasure, - scheduleVisibleTurnMeasure, - schedulePinReservationReconcile, - getTotalBottomCompensationPx, - updateBottomReservationState, - applyFooterCompensationNow, - activateAnchorLock, -}: UseVirtuosoFlowLayoutMutationBridgeOptions): void { - useEffect(() => { - if (!scrollerElement) { - return; - } - - const handleLayoutMutation = (event: Event) => { - const priority = isFlowLayoutMutationEvent(event) - ? event.detail?.priority - : undefined; - const frames = priority === 'high' ? 1 : 2; - scheduleHeightMeasure(frames); - scheduleVisibleTurnMeasure(frames); - schedulePinReservationReconcile(frames); - }; - - const handleLayoutCollapseIntent = (event: Event) => { - if (!isFlowLayoutCollapseIntentEvent(event)) { - return; - } - - const detail = event.detail; - if (isFollowingOutputRef.current && isStreamingOutputRef.current) { - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - return; - } - - const baseTotalCompensationPx = getTotalBottomCompensationPx(); - const distanceFromBottom = Math.max( - 0, - scrollerElement.scrollHeight - scrollerElement.clientHeight - scrollerElement.scrollTop, - ); - const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - baseTotalCompensationPx); - const estimatedShrink = Math.max(0, detail?.cardHeight ?? 0); - const provisionalTotalCompensationPx = Math.max( - 0, - baseTotalCompensationPx + Math.max(0, estimatedShrink - effectiveDistanceFromBottom), - ); - - pendingCollapseIntentRef.current = { - active: true, - anchorScrollTop: scrollerElement.scrollTop, - toolId: detail?.toolId ?? null, - toolName: detail?.toolName ?? null, - expiresAtMs: performance.now() + 1000, - distanceFromBottomBeforeCollapse: effectiveDistanceFromBottom, - baseTotalCompensationPx, - cumulativeShrinkPx: 0, - }; - - if (provisionalTotalCompensationPx - baseTotalCompensationPx > COMPENSATION_EPSILON_PX) { - const nextReservationState: BottomReservationState = { - ...bottomReservationStateRef.current, - collapse: { - ...bottomReservationStateRef.current.collapse, - px: Math.max( - 0, - provisionalTotalCompensationPx - getReservationTotalPx(bottomReservationStateRef.current.pin), - ), - floorPx: 0, - }, - }; - updateBottomReservationState(nextReservationState); - applyFooterCompensationNow(nextReservationState); - activateAnchorLock(scrollerElement.scrollTop, 'instant-shrink'); - } - - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - }; - - window.addEventListener(FLOW_LAYOUT_MUTATION_EVENT, handleLayoutMutation); - window.addEventListener(FLOW_LAYOUT_COLLAPSE_INTENT_EVENT, handleLayoutCollapseIntent as EventListener); - - return () => { - window.removeEventListener(FLOW_LAYOUT_MUTATION_EVENT, handleLayoutMutation); - window.removeEventListener(FLOW_LAYOUT_COLLAPSE_INTENT_EVENT, handleLayoutCollapseIntent as EventListener); - }; - }, [ - activateAnchorLock, - applyFooterCompensationNow, - bottomReservationStateRef, - getTotalBottomCompensationPx, - isFollowingOutputRef, - isStreamingOutputRef, - pendingCollapseIntentRef, - scheduleHeightMeasure, - schedulePinReservationReconcile, - scheduleVisibleTurnMeasure, - scrollerElement, - updateBottomReservationState, - ]); -} diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutObservers.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutObservers.ts deleted file mode 100644 index 0f08f51d..00000000 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowLayoutObservers.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { useEffect, type MutableRefObject } from 'react'; -import { createInactiveCollapseIntent, type PendingCollapseIntentState } from '../FlowScrollGeometry'; -import { incrementFlowChatCounter } from '../../performance/flowChatPerf'; - -interface UseVirtuosoFlowLayoutObserversOptions { - scrollerElement: HTMLElement | null; - isProcessing: boolean; - layoutTransitionCountRef: MutableRefObject; - pendingCollapseIntentRef: MutableRefObject; - deferredFollowReasonRef: MutableRefObject; - previousMeasuredHeightRef: MutableRefObject; - previousScrollTopRef: MutableRefObject; - snapshotMeasuredContentHeight: (scroller: HTMLElement) => number; - scheduleHeightMeasure: (frames?: number) => void; - scheduleVisibleTurnMeasure: (frames?: number) => void; - schedulePinReservationReconcile: (frames?: number) => void; - scheduleFollowToLatestWithViewportState: (reason: string) => void; - shouldSuspendAutoFollow: () => boolean; - scheduleFollowToLatest: (reason: string) => void; -} - -function isLayoutTransitionProperty(propertyName: string): boolean { - return ( - propertyName === 'grid-template-rows' || - propertyName === 'height' || - propertyName === 'max-height' - ); -} - -function hasSemanticMutation(mutations: MutationRecord[]): boolean { - return mutations.some(mutation => ( - mutation.type === 'characterData' || - mutation.type === 'childList' - )); -} - -export function useVirtuosoFlowLayoutObservers({ - scrollerElement, - isProcessing, - layoutTransitionCountRef, - pendingCollapseIntentRef, - deferredFollowReasonRef, - previousMeasuredHeightRef, - previousScrollTopRef, - snapshotMeasuredContentHeight, - scheduleHeightMeasure, - scheduleVisibleTurnMeasure, - schedulePinReservationReconcile, - scheduleFollowToLatestWithViewportState, - shouldSuspendAutoFollow, - scheduleFollowToLatest, -}: UseVirtuosoFlowLayoutObserversOptions): void { - useEffect(() => { - if (!scrollerElement) { - previousMeasuredHeightRef.current = null; - return; - } - - const resizeTarget = - scrollerElement.firstElementChild instanceof HTMLElement - ? scrollerElement.firstElementChild - : scrollerElement; - - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scrollerElement); - previousScrollTopRef.current = scrollerElement.scrollTop; - let resizeObserver: ResizeObserver | null = null; - let mutationObserver: MutationObserver | null = null; - let mutationPending = false; - let disposed = false; - let observerSetupFrame: number | null = null; - let observerInstallFrame: number | null = null; - - observerSetupFrame = requestAnimationFrame(() => { - observerInstallFrame = requestAnimationFrame(() => { - if (disposed) { - return; - } - - resizeObserver = new ResizeObserver(() => { - incrementFlowChatCounter('scroll.resizeObserver'); - scheduleHeightMeasure(); - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - scheduleFollowToLatestWithViewportState('resize-observer'); - }); - resizeObserver.observe(resizeTarget); - - mutationObserver = new MutationObserver((mutations) => { - incrementFlowChatCounter('scroll.mutationRecords', mutations.length); - if (mutationPending || !isProcessing || !hasSemanticMutation(mutations)) { - return; - } - - mutationPending = true; - requestAnimationFrame(() => { - incrementFlowChatCounter('scroll.mutationFrame'); - mutationPending = false; - scheduleHeightMeasure(2); - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - scheduleFollowToLatestWithViewportState('mutation-observer'); - }); - }); - mutationObserver.observe(scrollerElement, { - subtree: true, - childList: true, - characterData: true, - }); - }); - }); - - const handleTransitionRun = (event: TransitionEvent) => { - if (!isLayoutTransitionProperty(event.propertyName)) return; - layoutTransitionCountRef.current += 1; - }; - - const handleTransitionFinish = (event: TransitionEvent) => { - if (!isLayoutTransitionProperty(event.propertyName)) return; - layoutTransitionCountRef.current = Math.max(0, layoutTransitionCountRef.current - 1); - scheduleHeightMeasure(2); - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - - if (layoutTransitionCountRef.current === 0 && pendingCollapseIntentRef.current.active) { - pendingCollapseIntentRef.current = createInactiveCollapseIntent(); - } - - if (layoutTransitionCountRef.current === 0 && deferredFollowReasonRef.current && !shouldSuspendAutoFollow()) { - const deferredReason = deferredFollowReasonRef.current; - deferredFollowReasonRef.current = null; - scheduleFollowToLatest(`${deferredReason}-after-transition`); - } - }; - - scrollerElement.addEventListener('transitionrun', handleTransitionRun, true); - scrollerElement.addEventListener('transitionend', handleTransitionFinish, true); - scrollerElement.addEventListener('transitioncancel', handleTransitionFinish, true); - scheduleVisibleTurnMeasure(2); - - return () => { - disposed = true; - if (observerSetupFrame !== null) { - cancelAnimationFrame(observerSetupFrame); - } - if (observerInstallFrame !== null) { - cancelAnimationFrame(observerInstallFrame); - } - scrollerElement.removeEventListener('transitionrun', handleTransitionRun, true); - scrollerElement.removeEventListener('transitionend', handleTransitionFinish, true); - scrollerElement.removeEventListener('transitioncancel', handleTransitionFinish, true); - resizeObserver?.disconnect(); - mutationObserver?.disconnect(); - }; - }, [ - deferredFollowReasonRef, - isProcessing, - layoutTransitionCountRef, - pendingCollapseIntentRef, - previousMeasuredHeightRef, - previousScrollTopRef, - scheduleFollowToLatest, - scheduleFollowToLatestWithViewportState, - scheduleHeightMeasure, - schedulePinReservationReconcile, - scheduleVisibleTurnMeasure, - scrollerElement, - shouldSuspendAutoFollow, - snapshotMeasuredContentHeight, - ]); -} diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowNavigationController.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowNavigationController.ts deleted file mode 100644 index 8ea0a031..00000000 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowNavigationController.ts +++ /dev/null @@ -1,656 +0,0 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'; -import type { VirtuosoHandle } from 'react-virtuoso'; -import type { FlowChatPinTurnToTopMode } from '../../events/flowchatNavigation'; -import type { VirtualItem } from '../../store/modernFlowChatStore'; -import type { - FollowOutputEnterReason, - FollowOutputExitReason, -} from './useVirtuosoFlowFollowOutput'; -import { - COMPENSATION_EPSILON_PX, - PINNED_TURN_VIEWPORT_OFFSET_PX, - sanitizeReservationPx, - type BottomReservationState, - type PendingCollapseIntentState, - type PendingTurnPinState, - type PinBottomReservation, -} from '../FlowScrollGeometry'; - -interface UserMessageVirtualItem { - item: VirtualItem; - index: number; -} - -interface UseVirtuosoFlowNavigationControllerOptions { - activeSessionId: string | undefined; - virtuosoRef: MutableRefObject; - scrollerElement: HTMLElement | null; - scrollerElementRef: MutableRefObject; - virtualItemCount: number; - userMessageItems: UserMessageVirtualItem[]; - latestTurnId: string | null; - bottomReservationStateRef: MutableRefObject; - previousMeasuredHeightRef: MutableRefObject; - previousScrollTopRef: MutableRefObject; - pendingCollapseIntentRef: MutableRefObject; - layoutTransitionCountRef: MutableRefObject; - getTotalBottomCompensationPx: () => number; - snapshotMeasuredContentHeight: ( - scroller: HTMLElement, - reservationState?: BottomReservationState, - ) => number; - updateBottomReservationState: ( - updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState), - ) => void; - resetBottomReservations: () => void; - resetTransientGeometryState: () => void; - applyFooterCompensationNow: (compensation: number | BottomReservationState) => void; - releaseAnchorLock: (reason: string) => void; - scheduleVisibleTurnMeasure: (frames?: number) => void; - exitFollowOutputRef: MutableRefObject<(reason: FollowOutputExitReason) => void>; - enterFollowOutputRef: MutableRefObject<(reason: FollowOutputEnterReason) => void>; -} - -export function useVirtuosoFlowNavigationController({ - activeSessionId, - virtuosoRef, - scrollerElement, - scrollerElementRef, - virtualItemCount, - userMessageItems, - latestTurnId, - bottomReservationStateRef, - previousMeasuredHeightRef, - previousScrollTopRef, - pendingCollapseIntentRef, - layoutTransitionCountRef, - getTotalBottomCompensationPx, - snapshotMeasuredContentHeight, - updateBottomReservationState, - resetBottomReservations, - resetTransientGeometryState, - applyFooterCompensationNow, - releaseAnchorLock, - scheduleVisibleTurnMeasure, - exitFollowOutputRef, - enterFollowOutputRef, -}: UseVirtuosoFlowNavigationControllerOptions) { - const [pendingTurnPin, setPendingTurnPin] = useState(null); - const pinReservationReconcileFrameRef = useRef(null); - - const getRenderedUserMessageElement = useCallback((turnId: string) => { - const scroller = scrollerElementRef.current; - if (!scroller) return null; - - return scroller.querySelector( - `.virtual-item-wrapper[data-item-type="user-message"][data-turn-id="${turnId}"]`, - ); - }, [scrollerElementRef]); - - const buildPinReservation = useCallback(( - turnId: string, - pinMode: FlowChatPinTurnToTopMode, - requiredTailSpacePx: number, - currentPinReservation: PinBottomReservation = bottomReservationStateRef.current.pin, - ): PinBottomReservation => { - const resolvedRequiredTailSpacePx = sanitizeReservationPx(requiredTailSpacePx); - const nextFloorPx = pinMode === 'sticky-latest' - ? resolvedRequiredTailSpacePx - : 0; - const shouldPreserveCurrentPx = ( - currentPinReservation.mode === pinMode && - currentPinReservation.targetTurnId === turnId && - ( - pinMode === 'transient' || - currentPinReservation.floorPx > COMPENSATION_EPSILON_PX - ) - ); - const preservedPx = shouldPreserveCurrentPx ? currentPinReservation.px : 0; - const additiveRetryPx = ( - shouldPreserveCurrentPx && - pinMode === 'transient' && - resolvedRequiredTailSpacePx > COMPENSATION_EPSILON_PX - ) - ? currentPinReservation.px + resolvedRequiredTailSpacePx - : 0; - const shouldRetainTarget = ( - pinMode === 'sticky-latest' || - resolvedRequiredTailSpacePx > COMPENSATION_EPSILON_PX || - shouldPreserveCurrentPx - ); - - return { - kind: 'pin', - px: Math.max(nextFloorPx, resolvedRequiredTailSpacePx, preservedPx, additiveRetryPx), - floorPx: nextFloorPx, - mode: pinMode, - targetTurnId: shouldRetainTarget ? turnId : null, - }; - }, [bottomReservationStateRef]); - - const resolveTurnPinMetrics = useCallback((turnId: string, ignoredTailSpacePx: number = 0) => { - const scroller = scrollerElementRef.current; - if (!scroller) return null; - - const targetElement = getRenderedUserMessageElement(turnId); - if (!targetElement) return null; - - const scrollerRect = scroller.getBoundingClientRect(); - const targetRect = targetElement.getBoundingClientRect(); - const viewportTop = scrollerRect.top + PINNED_TURN_VIEWPORT_OFFSET_PX; - const desiredScrollTop = Math.max(0, scroller.scrollTop + (targetRect.top - viewportTop)); - const effectiveScrollHeight = Math.max(0, scroller.scrollHeight - Math.max(0, ignoredTailSpacePx)); - const rawMaxScrollTop = effectiveScrollHeight - scroller.clientHeight; - const maxScrollTop = Math.max(0, rawMaxScrollTop); - const missingTailSpace = Math.max(0, desiredScrollTop - rawMaxScrollTop); - - return { - targetElement, - viewportTop, - desiredScrollTop, - maxScrollTop, - missingTailSpace, - }; - }, [getRenderedUserMessageElement, scrollerElementRef]); - - const reconcileStickyPinReservation = useCallback(() => { - const scroller = scrollerElementRef.current; - const currentState = bottomReservationStateRef.current; - const pinReservation = currentState.pin; - if (!scroller || pinReservation.mode !== 'sticky-latest' || !pinReservation.targetTurnId) { - return false; - } - - const collapseIntent = pendingCollapseIntentRef.current; - const hasActiveCollapseTransition = ( - layoutTransitionCountRef.current > 0 && - collapseIntent.active && - collapseIntent.expiresAtMs >= performance.now() - ); - if (hasActiveCollapseTransition) { - return false; - } - - const resolvedMetrics = resolveTurnPinMetrics( - pinReservation.targetTurnId, - pinReservation.px, - ); - if (!resolvedMetrics) { - return false; - } - - const requiredFloorPx = sanitizeReservationPx(resolvedMetrics.missingTailSpace); - const hadOnlyFloor = pinReservation.px <= pinReservation.floorPx + COMPENSATION_EPSILON_PX; - const nextPinPx = hadOnlyFloor - ? requiredFloorPx - : Math.max(requiredFloorPx, pinReservation.px); - const nextPinReservation: PinBottomReservation = { - ...pinReservation, - px: nextPinPx, - floorPx: requiredFloorPx, - }; - - if ( - Math.abs(nextPinReservation.px - pinReservation.px) <= COMPENSATION_EPSILON_PX && - Math.abs(nextPinReservation.floorPx - pinReservation.floorPx) <= COMPENSATION_EPSILON_PX - ) { - return false; - } - - const nextState: BottomReservationState = { - ...currentState, - pin: nextPinReservation, - }; - updateBottomReservationState(nextState); - applyFooterCompensationNow(nextState); - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); - return true; - }, [ - applyFooterCompensationNow, - bottomReservationStateRef, - layoutTransitionCountRef, - pendingCollapseIntentRef, - previousMeasuredHeightRef, - resolveTurnPinMetrics, - scrollerElementRef, - snapshotMeasuredContentHeight, - updateBottomReservationState, - ]); - - const schedulePinReservationReconcile = useCallback((frames: number = 1) => { - if (pinReservationReconcileFrameRef.current !== null) { - cancelAnimationFrame(pinReservationReconcileFrameRef.current); - pinReservationReconcileFrameRef.current = null; - } - - const run = (remainingFrames: number) => { - pinReservationReconcileFrameRef.current = requestAnimationFrame(() => { - if (remainingFrames > 1) { - run(remainingFrames - 1); - return; - } - - pinReservationReconcileFrameRef.current = null; - reconcileStickyPinReservation(); - }); - }; - - run(Math.max(1, frames)); - }, [reconcileStickyPinReservation]); - - const tryResolvePendingTurnPin = useCallback((request: PendingTurnPinState) => { - const scroller = scrollerElementRef.current; - const virtuoso = virtuosoRef.current; - - if (!scroller || !virtuoso) return false; - - const targetItem = userMessageItems.find(({ item }) => item.turnId === request.turnId); - if (!targetItem) return false; - - const currentPinReservation = bottomReservationStateRef.current.pin; - let ignoredTailSpacePx = 0; - if (currentPinReservation.px > COMPENSATION_EPSILON_PX) { - ignoredTailSpacePx = currentPinReservation.px; - } - const resolvedMetrics = resolveTurnPinMetrics(request.turnId, ignoredTailSpacePx); - if (!resolvedMetrics) { - const fallbackBehavior: ScrollBehavior = request.pinMode === 'sticky-latest' - ? 'auto' - : targetItem.index === 0 - ? 'auto' - : request.attempts === 0 && request.behavior === 'smooth' - ? 'smooth' - : 'auto'; - const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const provisionalPinPx = request.pinMode === 'sticky-latest' - ? Math.max(maxScrollTop, currentPinReservation.px) - : 0; - - if (request.pinMode === 'sticky-latest' && provisionalPinPx > COMPENSATION_EPSILON_PX) { - const nextReservationState: BottomReservationState = { - ...bottomReservationStateRef.current, - pin: { - kind: 'pin', - px: provisionalPinPx, - floorPx: 0, - mode: request.pinMode, - targetTurnId: request.turnId, - }, - }; - updateBottomReservationState(nextReservationState); - applyFooterCompensationNow(nextReservationState); - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextReservationState); - } - - virtuoso.scrollToIndex({ - index: targetItem.index, - align: 'start', - behavior: fallbackBehavior, - }); - return false; - } - - const nextReservationState: BottomReservationState = { - ...bottomReservationStateRef.current, - pin: buildPinReservation( - request.turnId, - request.pinMode, - resolvedMetrics.missingTailSpace, - ), - }; - updateBottomReservationState(nextReservationState); - applyFooterCompensationNow(nextReservationState); - - const resolvedMaxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const targetScrollTop = Math.min(resolvedMetrics.desiredScrollTop, resolvedMaxScrollTop); - if (Math.abs(scroller.scrollTop - targetScrollTop) > COMPENSATION_EPSILON_PX) { - scroller.scrollTop = targetScrollTop; - } - - const verifyPinAlignment = (frameLabel: string) => { - const liveTargetElement = getRenderedUserMessageElement(request.turnId); - const liveRect = liveTargetElement?.getBoundingClientRect(); - const viewportTop = liveTargetElement - ? scroller.getBoundingClientRect().top + PINNED_TURN_VIEWPORT_OFFSET_PX - : null; - const deltaToViewportTop = liveRect && viewportTop != null - ? liveRect.top - viewportTop - : null; - - const stickyPinStillTargetsRequest = ( - bottomReservationStateRef.current.pin.mode === 'sticky-latest' && - bottomReservationStateRef.current.pin.targetTurnId === request.turnId - ); - const shouldRealign = ( - frameLabel !== 'immediate' && - deltaToViewportTop != null && - Math.abs(deltaToViewportTop) > 1.5 && - ( - request.pinMode === 'transient' - ? Math.abs(scroller.scrollTop - targetScrollTop) <= 2 - : stickyPinStillTargetsRequest - ) - ); - if (!shouldRealign) { - return; - } - - const correctedMaxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const correctedScrollTop = Math.min( - correctedMaxScrollTop, - Math.max(0, scroller.scrollTop + deltaToViewportTop), - ); - if (Math.abs(correctedScrollTop - scroller.scrollTop) <= COMPENSATION_EPSILON_PX) { - return; - } - - scroller.scrollTop = correctedScrollTop; - previousScrollTopRef.current = correctedScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( - scroller, - bottomReservationStateRef.current, - ); - scheduleVisibleTurnMeasure(2); - schedulePinReservationReconcile(2); - }; - verifyPinAlignment('immediate'); - requestAnimationFrame(() => { - verifyPinAlignment('raf-1'); - requestAnimationFrame(() => { - verifyPinAlignment('raf-2'); - }); - }); - - previousScrollTopRef.current = targetScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextReservationState); - - const alignedRect = resolvedMetrics.targetElement.getBoundingClientRect(); - return Math.abs(alignedRect.top - resolvedMetrics.viewportTop) <= 1.5; - }, [ - applyFooterCompensationNow, - bottomReservationStateRef, - buildPinReservation, - getRenderedUserMessageElement, - previousMeasuredHeightRef, - previousScrollTopRef, - resolveTurnPinMetrics, - schedulePinReservationReconcile, - scheduleVisibleTurnMeasure, - scrollerElementRef, - snapshotMeasuredContentHeight, - updateBottomReservationState, - userMessageItems, - virtuosoRef, - ]); - - const clearPinReservationForUserNavigation = useCallback(() => { - const currentState = bottomReservationStateRef.current; - const scroller = scrollerElementRef.current; - const hasActivePin = ( - currentState.pin.px > COMPENSATION_EPSILON_PX || - currentState.pin.floorPx > COMPENSATION_EPSILON_PX || - currentState.pin.targetTurnId !== null || - currentState.pin.mode !== 'transient' - ); - - releaseAnchorLock('user-navigation'); - setPendingTurnPin(null); - - if (!hasActivePin) { - return; - } - - const nextReservationState: BottomReservationState = { - ...currentState, - pin: { - kind: 'pin', - px: 0, - floorPx: 0, - mode: 'transient', - targetTurnId: null, - }, - }; - updateBottomReservationState(nextReservationState); - applyFooterCompensationNow(nextReservationState); - - if (scroller) { - previousScrollTopRef.current = scroller.scrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextReservationState); - } - }, [ - applyFooterCompensationNow, - bottomReservationStateRef, - previousMeasuredHeightRef, - previousScrollTopRef, - releaseAnchorLock, - scrollerElementRef, - snapshotMeasuredContentHeight, - updateBottomReservationState, - ]); - - const scrollToLatestEndPositionInternal = useCallback((behavior: ScrollBehavior) => { - if (virtuosoRef.current && virtualItemCount > 0) { - releaseAnchorLock('scroll-to-latest'); - setPendingTurnPin(null); - virtuosoRef.current.scrollTo({ top: 999999999, behavior }); - } - }, [releaseAnchorLock, virtualItemCount, virtuosoRef]); - - const requestTurnPinToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { - const requestedPinMode = options?.pinMode ?? 'transient'; - const requestedBehavior = options?.behavior ?? 'auto'; - const targetItem = userMessageItems.find(({ item }) => item.turnId === turnId); - if (!targetItem || !virtuosoRef.current) { - return false; - } - - if (targetItem.index === 0 && requestedPinMode === 'transient') { - setPendingTurnPin(null); - virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); - return true; - } - - setPendingTurnPin({ - turnId, - behavior: requestedBehavior, - pinMode: requestedPinMode, - expiresAtMs: performance.now() + 1500, - attempts: 0, - }); - return true; - }, [userMessageItems, virtuosoRef]); - - const performAutoFollowSync = useCallback(() => { - if (!latestTurnId) { - return; - } - - const currentPinReservation = bottomReservationStateRef.current.pin; - const totalBottomCompensationPx = getTotalBottomCompensationPx(); - const hasPendingLatestStickyPin = ( - pendingTurnPin?.turnId === latestTurnId && - pendingTurnPin.pinMode === 'sticky-latest' - ); - const hasAppliedLatestStickyPin = ( - currentPinReservation.mode === 'sticky-latest' && - currentPinReservation.targetTurnId === latestTurnId - ); - const shouldKeepStickyLatest = ( - hasAppliedLatestStickyPin && - currentPinReservation.floorPx > COMPENSATION_EPSILON_PX - ); - const shouldPreserveSyntheticTail = ( - hasAppliedLatestStickyPin && - totalBottomCompensationPx > COMPENSATION_EPSILON_PX - ); - - if (hasPendingLatestStickyPin) { - return; - } - - if (!hasAppliedLatestStickyPin) { - requestTurnPinToTop(latestTurnId, { - behavior: 'auto', - pinMode: 'sticky-latest', - }); - return; - } - - if (shouldKeepStickyLatest) { - return; - } - - if (shouldPreserveSyntheticTail) { - return; - } - - scrollToLatestEndPositionInternal('auto'); - }, [ - bottomReservationStateRef, - getTotalBottomCompensationPx, - latestTurnId, - pendingTurnPin?.pinMode, - pendingTurnPin?.turnId, - requestTurnPinToTop, - scrollToLatestEndPositionInternal, - ]); - - const scrollToTurn = useCallback((turnIndex: number) => { - if (!virtuosoRef.current) return; - if (turnIndex < 1 || turnIndex > userMessageItems.length) return; - - const targetItem = userMessageItems[turnIndex - 1]; - if (!targetItem) return; - - exitFollowOutputRef.current('scroll-to-turn'); - clearPinReservationForUserNavigation(); - - if (targetItem.index === 0) { - virtuosoRef.current.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - virtuosoRef.current.scrollToIndex({ - index: targetItem.index, - behavior: 'smooth', - align: 'center', - }); - } - }, [clearPinReservationForUserNavigation, exitFollowOutputRef, userMessageItems, virtuosoRef]); - - const scrollToIndex = useCallback((index: number) => { - if (!virtuosoRef.current) return; - if (index < 0 || index >= virtualItemCount) return; - - exitFollowOutputRef.current('scroll-to-index'); - clearPinReservationForUserNavigation(); - - if (index === 0) { - virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); - } else { - virtuosoRef.current.scrollToIndex({ index, align: 'center', behavior: 'auto' }); - } - }, [clearPinReservationForUserNavigation, exitFollowOutputRef, virtualItemCount, virtuosoRef]); - - const pinTurnToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { - const shouldExitFollowOutput = !( - options?.pinMode === 'sticky-latest' && - turnId === latestTurnId - ); - if (shouldExitFollowOutput) { - exitFollowOutputRef.current('pin-turn-to-top'); - clearPinReservationForUserNavigation(); - } - - return requestTurnPinToTop(turnId, options); - }, [ - clearPinReservationForUserNavigation, - exitFollowOutputRef, - latestTurnId, - requestTurnPinToTop, - ]); - - const scrollToPhysicalBottomAndClearPin = useCallback(() => { - if (virtuosoRef.current && virtualItemCount > 0) { - clearPinReservationForUserNavigation(); - virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'smooth' }); - } - }, [clearPinReservationForUserNavigation, virtualItemCount, virtuosoRef]); - - const scrollToLatestEndPosition = useCallback(() => { - enterFollowOutputRef.current('jump-to-latest'); - }, [enterFollowOutputRef]); - - useEffect(() => { - setPendingTurnPin(null); - resetTransientGeometryState(); - }, [activeSessionId, resetTransientGeometryState]); - - useEffect(() => { - if (virtualItemCount === 0) { - previousMeasuredHeightRef.current = null; - setPendingTurnPin(null); - resetBottomReservations(); - } - }, [previousMeasuredHeightRef, resetBottomReservations, virtualItemCount]); - - useEffect(() => { - return () => { - if (pinReservationReconcileFrameRef.current !== null) { - cancelAnimationFrame(pinReservationReconcileFrameRef.current); - pinReservationReconcileFrameRef.current = null; - } - }; - }, []); - - useEffect(() => { - schedulePinReservationReconcile(2); - }, [activeSessionId, schedulePinReservationReconcile, scrollerElement, userMessageItems, virtualItemCount]); - - useEffect(() => { - if (!pendingTurnPin) return; - - if (performance.now() > pendingTurnPin.expiresAtMs) { - setPendingTurnPin(null); - return; - } - - const frameId = requestAnimationFrame(() => { - const resolved = tryResolvePendingTurnPin(pendingTurnPin); - if (resolved) { - setPendingTurnPin(null); - scheduleVisibleTurnMeasure(2); - return; - } - - setPendingTurnPin(prev => { - if (!prev || prev.turnId !== pendingTurnPin.turnId) { - return prev; - } - - return { - ...prev, - attempts: prev.attempts + 1, - behavior: 'auto', - }; - }); - }); - - return () => { - cancelAnimationFrame(frameId); - }; - }, [pendingTurnPin, scheduleVisibleTurnMeasure, tryResolvePendingTurnPin]); - - return { - pendingTurnPin, - clearPinReservationForUserNavigation, - performAutoFollowSync, - requestTurnPinToTop, - reconcileStickyPinReservation, - schedulePinReservationReconcile, - scrollToLatestEndPositionInternal, - scrollToTurn, - scrollToIndex, - pinTurnToTop, - scrollToPhysicalBottomAndClearPin, - scrollToLatestEndPosition, - }; -} diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowUserIntentBridge.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowUserIntentBridge.ts deleted file mode 100644 index a0db679b..00000000 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoFlowUserIntentBridge.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { useEffect, useRef, type MutableRefObject } from 'react'; -import { - ANCHOR_LOCK_MIN_DEVIATION_PX, - COMPENSATION_EPSILON_PX, - type BottomReservationState, - type ScrollAnchorLockState, -} from '../FlowScrollGeometry'; -import { - TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX, - isEditableElement, - isPointerOnScrollbarGutter, - isUpwardScrollIntentKey, -} from '../FlowScrollIntent'; - -interface VirtuosoFollowOutputController { - handleUserScrollIntent: () => void; - handleScroll: () => void; -} - -interface UseVirtuosoFlowUserIntentBridgeOptions { - scrollerElement: HTMLElement | null; - anchorLockRef: MutableRefObject; - layoutTransitionCountRef: MutableRefObject; - previousScrollTopRef: MutableRefObject; - previousMeasuredHeightRef: MutableRefObject; - followOutputControllerRef: MutableRefObject; - releaseAnchorLock: (reason: string) => void; - getTotalBottomCompensationPx: () => number; - consumeBottomCompensation: (amountPx: number) => BottomReservationState; - applyFooterCompensationNow: (compensation: number | BottomReservationState) => void; - snapshotMeasuredContentHeight: ( - scroller: HTMLElement, - reservationState?: BottomReservationState, - ) => number; - scheduleVisibleTurnMeasure: (frames?: number) => void; -} - -export function useVirtuosoFlowUserIntentBridge({ - scrollerElement, - anchorLockRef, - layoutTransitionCountRef, - previousScrollTopRef, - previousMeasuredHeightRef, - followOutputControllerRef, - releaseAnchorLock, - getTotalBottomCompensationPx, - consumeBottomCompensation, - applyFooterCompensationNow, - snapshotMeasuredContentHeight, - scheduleVisibleTurnMeasure, -}: UseVirtuosoFlowUserIntentBridgeOptions): void { - const touchScrollIntentStartYRef = useRef(null); - const scrollbarPointerInteractionActiveRef = useRef(false); - - useEffect(() => { - if (!scrollerElement) { - touchScrollIntentStartYRef.current = null; - scrollbarPointerInteractionActiveRef.current = false; - return; - } - - const handleScroll = () => { - const now = performance.now(); - if (anchorLockRef.current.active && now > anchorLockRef.current.lockUntilMs && layoutTransitionCountRef.current === 0) { - releaseAnchorLock('expired-before-scroll'); - } - - const currentTotalCompensation = getTotalBottomCompensationPx(); - if ( - currentTotalCompensation > COMPENSATION_EPSILON_PX && - !anchorLockRef.current.active && - layoutTransitionCountRef.current === 0 - ) { - const nextScrollTop = scrollerElement.scrollTop; - const scrollDelta = nextScrollTop - previousScrollTopRef.current; - if (scrollDelta > COMPENSATION_EPSILON_PX) { - const nextCompensationState = consumeBottomCompensation(scrollDelta); - applyFooterCompensationNow(nextCompensationState); - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( - scrollerElement, - nextCompensationState, - ); - } - } - - if (getTotalBottomCompensationPx() > COMPENSATION_EPSILON_PX) { - const nextScrollTop = scrollerElement.scrollTop; - const maxScrollTop = Math.max(0, scrollerElement.scrollHeight - scrollerElement.clientHeight); - if (anchorLockRef.current.active && performance.now() <= anchorLockRef.current.lockUntilMs) { - const targetScrollTop = Math.min(anchorLockRef.current.targetScrollTop, maxScrollTop); - const restoreDelta = targetScrollTop - nextScrollTop; - if (Math.abs(restoreDelta) > ANCHOR_LOCK_MIN_DEVIATION_PX) { - scrollerElement.scrollTop = targetScrollTop; - previousScrollTopRef.current = targetScrollTop; - return; - } - } - } - - previousScrollTopRef.current = scrollerElement.scrollTop; - scheduleVisibleTurnMeasure(); - followOutputControllerRef.current.handleScroll(); - - if (anchorLockRef.current.active && performance.now() > anchorLockRef.current.lockUntilMs && layoutTransitionCountRef.current === 0) { - releaseAnchorLock('expired-after-scroll'); - } - }; - - const handleWheel = (event: WheelEvent) => { - if (event.deltaY < 0) { - followOutputControllerRef.current.handleUserScrollIntent(); - } - }; - - const handleTouchStart = (event: TouchEvent) => { - touchScrollIntentStartYRef.current = event.touches[0]?.clientY ?? null; - }; - - const handleTouchMove = (event: TouchEvent) => { - const startY = touchScrollIntentStartYRef.current; - const currentY = event.touches[0]?.clientY; - if (startY === null || currentY === undefined) { - return; - } - - if (currentY - startY > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { - touchScrollIntentStartYRef.current = currentY; - followOutputControllerRef.current.handleUserScrollIntent(); - } - }; - - const resetTouchScrollIntent = () => { - touchScrollIntentStartYRef.current = null; - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (!isUpwardScrollIntentKey(event) || isEditableElement(event.target)) { - return; - } - - followOutputControllerRef.current.handleUserScrollIntent(); - }; - - const handlePointerDown = (event: PointerEvent) => { - if (event.pointerType === 'touch' || event.button !== 0) { - return; - } - - if (!isPointerOnScrollbarGutter(scrollerElement, event.clientX, event.clientY)) { - return; - } - - scrollbarPointerInteractionActiveRef.current = true; - followOutputControllerRef.current.handleUserScrollIntent(); - }; - - const handlePointerMove = (event: PointerEvent) => { - if (!scrollbarPointerInteractionActiveRef.current || event.pointerType === 'touch') { - return; - } - - if ((event.buttons & 1) !== 1) { - scrollbarPointerInteractionActiveRef.current = false; - return; - } - - followOutputControllerRef.current.handleUserScrollIntent(); - }; - - const endScrollbarPointerInteraction = () => { - scrollbarPointerInteractionActiveRef.current = false; - }; - - scrollerElement.addEventListener('scroll', handleScroll, { passive: true }); - scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); - scrollerElement.addEventListener('touchstart', handleTouchStart, { passive: true }); - scrollerElement.addEventListener('touchmove', handleTouchMove, { passive: true }); - scrollerElement.addEventListener('touchend', resetTouchScrollIntent, { passive: true }); - scrollerElement.addEventListener('touchcancel', resetTouchScrollIntent, { passive: true }); - scrollerElement.addEventListener('keydown', handleKeyDown, true); - scrollerElement.addEventListener('pointerdown', handlePointerDown, true); - window.addEventListener('pointermove', handlePointerMove, true); - window.addEventListener('pointerup', endScrollbarPointerInteraction, true); - window.addEventListener('pointercancel', endScrollbarPointerInteraction, true); - - return () => { - scrollerElement.removeEventListener('scroll', handleScroll); - scrollerElement.removeEventListener('wheel', handleWheel); - scrollerElement.removeEventListener('touchstart', handleTouchStart); - scrollerElement.removeEventListener('touchmove', handleTouchMove); - scrollerElement.removeEventListener('touchend', resetTouchScrollIntent); - scrollerElement.removeEventListener('touchcancel', resetTouchScrollIntent); - scrollerElement.removeEventListener('keydown', handleKeyDown, true); - scrollerElement.removeEventListener('pointerdown', handlePointerDown, true); - window.removeEventListener('pointermove', handlePointerMove, true); - window.removeEventListener('pointerup', endScrollbarPointerInteraction, true); - window.removeEventListener('pointercancel', endScrollbarPointerInteraction, true); - touchScrollIntentStartYRef.current = null; - scrollbarPointerInteractionActiveRef.current = false; - }; - }, [ - anchorLockRef, - applyFooterCompensationNow, - consumeBottomCompensation, - followOutputControllerRef, - getTotalBottomCompensationPx, - layoutTransitionCountRef, - previousMeasuredHeightRef, - previousScrollTopRef, - releaseAnchorLock, - scheduleVisibleTurnMeasure, - scrollerElement, - snapshotMeasuredContentHeight, - ]); -} diff --git a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoVisibleTurnTracker.ts b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoVisibleTurnTracker.ts index 02795090..14929f8b 100644 --- a/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoVisibleTurnTracker.ts +++ b/src/web-ui/src/flow_chat/scroll/adapters/useVirtuosoVisibleTurnTracker.ts @@ -3,7 +3,7 @@ import { useModernFlowChatStore, type VisibleTurnInfo, } from '../../store/modernFlowChatStore'; -import { PINNED_TURN_VIEWPORT_OFFSET_PX } from '../FlowScrollGeometry'; +import { PINNED_TURN_VIEWPORT_OFFSET_PX } from '../viewport/FlowViewportGeometry'; interface UserMessageRenderItem { item: { diff --git a/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.test.ts b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.test.ts new file mode 100644 index 00000000..c36bf9c5 --- /dev/null +++ b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + absorbPinnedContentGrowth, + clearConsumableCompensation, + clearPinReservation, + consumeCompensation, + createInitialGeometry, + getContentDistanceFromBottom, + getEffectiveContentHeight, + getFollowTargetScrollTop, + getTotalCompensationPx, + hasActiveStickyFloor, + reconcileStickyPinReservation, + resolvePinMetrics, + sanitizeGeometry, + type ScrollerMetrics, + type ViewportGeometryState, +} from './FlowViewportGeometry'; + +const metrics: ScrollerMetrics = { scrollTop: 500, scrollHeight: 2000, clientHeight: 800 }; + +function geometry(partial: Partial): ViewportGeometryState { + return { ...createInitialGeometry(), ...partial }; +} + +describe('FlowViewportGeometry', () => { + it('sanitizes negative values and clamps the floor to the reservation', () => { + const sanitized = sanitizeGeometry(geometry({ collapsePx: -4, pinPx: 10, pinFloorPx: 25 })); + expect(sanitized.collapsePx).toBe(0); + expect(sanitized.pinPx).toBe(10); + expect(sanitized.pinFloorPx).toBe(10); + }); + + it('consumes collapse space first, then pin space above the floor only', () => { + const state = geometry({ collapsePx: 30, pinPx: 100, pinFloorPx: 80 }); + const next = consumeCompensation(state, 40); + expect(next.collapsePx).toBe(0); + expect(next.pinPx).toBe(90); + expect(next.pinFloorPx).toBe(80); + + const exhausted = consumeCompensation(next, 500); + expect(exhausted.collapsePx).toBe(0); + expect(exhausted.pinPx).toBe(80); + expect(exhausted.pinFloorPx).toBe(80); + }); + + it('clears only consumable space, preserving the sticky floor', () => { + const state = geometry({ + collapsePx: 20, + pinPx: 120, + pinFloorPx: 90, + pinMode: 'sticky-latest', + pinTargetTurnId: 'turn-1', + }); + const next = clearConsumableCompensation(state); + expect(next.collapsePx).toBe(0); + expect(next.pinPx).toBe(90); + expect(next.pinFloorPx).toBe(90); + expect(next.pinTargetTurnId).toBe('turn-1'); + + const cleared = clearPinReservation(next); + expect(getTotalCompensationPx(cleared)).toBe(0); + expect(cleared.pinTargetTurnId).toBeNull(); + }); + + it('bases effective height and bottom distance on compensation-free content', () => { + const state = geometry({ pinPx: 200, pinFloorPx: 200 }); + expect(getEffectiveContentHeight(metrics, state, 100)).toBe(1700); + // content bottom: 2000 - 200 - 800 - 500 = 500 + expect(getContentDistanceFromBottom(metrics, state)).toBe(500); + expect(getFollowTargetScrollTop(metrics, state)).toBe(1000); + }); + + it('computes missing tail space for pin alignment', () => { + // Wants to scroll 400px further down while only 700px of range remains + // past the current pin reservation. + const pinMetrics = resolvePinMetrics( + { scrollTop: 900, scrollHeight: 2000, clientHeight: 800 }, + 400, + 100, + ); + expect(pinMetrics.desiredScrollTop).toBe(1300); + // rawMax = 2000 - 100 - 800 = 1100 -> missing 200 + expect(pinMetrics.missingTailSpacePx).toBe(200); + }); + + it('detects an active sticky floor from geometry state', () => { + expect(hasActiveStickyFloor(geometry({ pinFloorPx: 0 }))).toBe(false); + expect( + hasActiveStickyFloor(geometry({ + pinPx: 200, + pinFloorPx: 200, + pinMode: 'sticky-latest', + pinTargetTurnId: 'turn-1', + })), + ).toBe(true); + }); + + it('preserves the sticky floor at equilibrium when missing tail is zero', () => { + const state = geometry({ + pinPx: 500, + pinFloorPx: 500, + pinMode: 'sticky-latest', + pinTargetTurnId: 'turn-2', + }); + const next = reconcileStickyPinReservation(state, 0, false, 'turn-2'); + expect(next.pinPx).toBe(500); + expect(next.pinFloorPx).toBe(500); + expect(next.pinTargetTurnId).toBe('turn-2'); + }); + + it('shrinks the sticky floor 1:1 during pinned content growth', () => { + const state = geometry({ + pinPx: 500, + pinFloorPx: 500, + pinMode: 'sticky-latest', + pinTargetTurnId: 'turn-2', + }); + const next = absorbPinnedContentGrowth(state, 120); + expect(next.pinPx).toBe(380); + expect(next.pinFloorPx).toBe(380); + }); +}); diff --git a/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.ts b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.ts new file mode 100644 index 00000000..09a8c68a --- /dev/null +++ b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportGeometry.ts @@ -0,0 +1,325 @@ +/** + * FlowChat viewport geometry. + * + * Pure math for the synthetic bottom reservation model used by the + * virtualized main chat list: + * + * - `collapsePx` protects the viewport against height loss near the bottom + * while the user is reading (shrink compensation). + * - `pinPx` / `pinFloorPx` provide the synthetic tail space that keeps the + * latest user message pinned to the reading offset. The floor part is + * never consumable; only reservation space above the floor may be consumed + * by content growth or user downward scrolling. + * + * All height comparisons must use the effective content height: + * `scrollHeight - totalCompensation - inputFooterPx`. Raw `scrollHeight` + * deltas would misattribute reservation changes to content changes. + */ + +export const VIEWPORT_EPSILON_PX = 0.5; + +/** Reading offset for a pinned user turn; matches the header spacer height. */ +export const PINNED_TURN_VIEWPORT_OFFSET_PX = 61; + +/** Content-bottom distance beyond which the scroll-to-latest bar shows. */ +export const SCROLL_TO_LATEST_THRESHOLD_PX = 50; + +/** Content-bottom distance within which a user downward scroll re-enters follow. */ +export const REENTER_FOLLOW_THRESHOLD_PX = 100; + +/** How long a shrink anchor lock stays enforceable. */ +export const ANCHOR_LOCK_DURATION_MS = 450; + +/** How long a collapse intent stays valid while waiting for the real shrink. */ +export const COLLAPSE_INTENT_TTL_MS = 1000; + +/** How long a turn pin keeps retrying while the target item is not rendered. */ +export const PIN_RETRY_TTL_MS = 1500; + +/** Stable frames required before finalizing settles into reading. */ +export const FINALIZE_STABLE_FRAMES = 8; + +/** Upper bound for the finalizing window. */ +export const FINALIZE_TIMEOUT_MS = 800; + +/** Stable frames required before an externally driven navigation settles. */ +export const NAVIGATION_STABLE_FRAMES = 6; + +/** Duration of scheduler-driven smooth scroll animations. */ +export const VIEWPORT_ANIMATION_MS = 260; + +/** Idle frames after which the continuous pipeline goes to sleep. */ +export const PIPELINE_IDLE_FRAMES = 4; + +export type ViewportPinMode = 'transient' | 'sticky-latest'; + +export interface ViewportGeometryState { + /** Consumable shrink-protection reservation. */ + collapsePx: number; + /** Total pin reservation; always >= pinFloorPx. */ + pinPx: number; + /** Non-consumable part of the pin reservation. */ + pinFloorPx: number; + pinMode: ViewportPinMode; + pinTargetTurnId: string | null; +} + +export interface ScrollerMetrics { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +export function createInitialGeometry(): ViewportGeometryState { + return { + collapsePx: 0, + pinPx: 0, + pinFloorPx: 0, + pinMode: 'transient', + pinTargetTurnId: null, + }; +} + +function sanitizePx(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +export function sanitizeGeometry(state: ViewportGeometryState): ViewportGeometryState { + const pinPx = sanitizePx(state.pinPx); + const pinFloorPx = Math.min(pinPx, sanitizePx(state.pinFloorPx)); + return { + collapsePx: sanitizePx(state.collapsePx), + pinPx, + pinFloorPx, + pinMode: state.pinMode ?? 'transient', + pinTargetTurnId: state.pinTargetTurnId ?? null, + }; +} + +export function getTotalCompensationPx(state: ViewportGeometryState): number { + return sanitizePx(state.collapsePx) + sanitizePx(state.pinPx); +} + +export function getConsumableCompensationPx(state: ViewportGeometryState): number { + return sanitizePx(state.collapsePx) + Math.max(0, state.pinPx - state.pinFloorPx); +} + +/** + * Consume compensation from the collapse reservation first, then from pin + * space above the floor. Floors are never consumed. + */ +export function consumeCompensation( + state: ViewportGeometryState, + amountPx: number, +): ViewportGeometryState { + if (amountPx <= VIEWPORT_EPSILON_PX) { + return state; + } + + let remaining = Math.max(0, amountPx); + const collapseConsumed = Math.min(state.collapsePx, remaining); + remaining -= collapseConsumed; + const pinConsumable = Math.max(0, state.pinPx - state.pinFloorPx); + const pinConsumed = Math.min(pinConsumable, remaining); + + return sanitizeGeometry({ + ...state, + collapsePx: state.collapsePx - collapseConsumed, + pinPx: state.pinPx - pinConsumed, + }); +} + +export function clearPinReservation(state: ViewportGeometryState): ViewportGeometryState { + return { + ...state, + pinPx: 0, + pinFloorPx: 0, + pinMode: 'transient', + pinTargetTurnId: null, + }; +} + +/** Drop everything consumable; keep only the sticky pin floor. */ +export function clearConsumableCompensation(state: ViewportGeometryState): ViewportGeometryState { + return sanitizeGeometry({ + ...state, + collapsePx: 0, + pinPx: state.pinFloorPx, + }); +} + +export function areGeometriesEqual( + left: ViewportGeometryState, + right: ViewportGeometryState, +): boolean { + return ( + Math.abs(left.collapsePx - right.collapsePx) <= VIEWPORT_EPSILON_PX && + Math.abs(left.pinPx - right.pinPx) <= VIEWPORT_EPSILON_PX && + Math.abs(left.pinFloorPx - right.pinFloorPx) <= VIEWPORT_EPSILON_PX && + left.pinMode === right.pinMode && + left.pinTargetTurnId === right.pinTargetTurnId + ); +} + +/** + * Effective content height with synthetic reservations and the input-stack + * footer removed. This is the only valid basis for growth/shrink deltas. + */ +export function getEffectiveContentHeight( + metrics: ScrollerMetrics, + state: ViewportGeometryState, + inputFooterPx: number, +): number { + return Math.max(0, metrics.scrollHeight - getTotalCompensationPx(state) - inputFooterPx); +} + +/** + * Distance between the viewport bottom and the end of real content + * (synthetic reservation space excluded). The single "at bottom" semantics + * used by follow targeting, bar visibility, and follow re-entry. + */ +export function getContentDistanceFromBottom( + metrics: ScrollerMetrics, + state: ViewportGeometryState, +): number { + return Math.max( + 0, + metrics.scrollHeight - getTotalCompensationPx(state) - metrics.clientHeight - metrics.scrollTop, + ); +} + +/** + * Scroll target that aligns the end of real content (plus input clearance) + * with the viewport bottom, without scrolling into synthetic tail space. + */ +export function getFollowTargetScrollTop( + metrics: ScrollerMetrics, + state: ViewportGeometryState, +): number { + return Math.max( + 0, + metrics.scrollHeight - getTotalCompensationPx(state) - metrics.clientHeight, + ); +} + +export function getMaxScrollTop(metrics: ScrollerMetrics): number { + return Math.max(0, metrics.scrollHeight - metrics.clientHeight); +} + +/** + * Pin alignment math for a rendered target element. + * + * `missingTailSpacePx` is how much synthetic tail is required so that + * `desiredScrollTop` becomes reachable once the current pin reservation is + * excluded from the available scroll range. + */ +export interface PinMetrics { + desiredScrollTop: number; + missingTailSpacePx: number; +} + +export function resolvePinMetrics( + metrics: ScrollerMetrics, + targetTopDeltaPx: number, + currentPinPx: number, +): PinMetrics { + const desiredScrollTop = Math.max(0, metrics.scrollTop + targetTopDeltaPx); + const rawMaxScrollTop = metrics.scrollHeight - Math.max(0, currentPinPx) - metrics.clientHeight; + const missingTailSpacePx = Math.max(0, desiredScrollTop - rawMaxScrollTop); + return { desiredScrollTop, missingTailSpacePx }; +} + +/** Whether a live sticky pin floor is active for the latest turn's layout. */ +export function hasActiveStickyFloor(state: ViewportGeometryState): boolean { + return ( + state.pinMode === 'sticky-latest' && + state.pinFloorPx > VIEWPORT_EPSILON_PX && + state.pinTargetTurnId !== null + ); +} + +/** + * Reconcile the sticky pin reservation from a pin-alignment measurement. + * + * `missingTailSpacePx` is **incremental** tail needed beyond what `pinPx` + * already provisions in `scrollHeight`. At equilibrium (message aligned and + * reservation correct) it is zero — that means "keep the current floor", not + * "floor should be zero". + */ +export function reconcileStickyPinReservation( + state: ViewportGeometryState, + missingTailSpacePx: number, + holdFloorDuringTransition: boolean, + turnId: string, +): ViewportGeometryState { + if (holdFloorDuringTransition) { + return sanitizeGeometry({ + ...state, + pinMode: 'sticky-latest', + pinTargetTurnId: turnId, + }); + } + + const hadOnlyFloor = state.pinPx <= state.pinFloorPx + VIEWPORT_EPSILON_PX; + + if (hadOnlyFloor && missingTailSpacePx <= VIEWPORT_EPSILON_PX) { + return sanitizeGeometry({ + ...state, + pinMode: 'sticky-latest', + pinTargetTurnId: turnId, + }); + } + + if (hadOnlyFloor) { + return sanitizeGeometry({ + ...state, + pinPx: missingTailSpacePx, + pinFloorPx: missingTailSpacePx, + pinMode: 'sticky-latest', + pinTargetTurnId: turnId, + }); + } + + const nextFloor = missingTailSpacePx; + const nextPx = Math.max(nextFloor, state.pinPx); + return sanitizeGeometry({ + ...state, + pinPx: nextPx, + pinFloorPx: nextFloor, + pinMode: 'sticky-latest', + pinTargetTurnId: turnId, + }); +} + +/** + * Equal exchange while pinned: content growth below the pinned turn shrinks the + * floor 1:1 so scrollHeight stays constant. Falls back to consumable + * consumption when a consumable headroom exists above the floor. + */ +export function absorbPinnedContentGrowth( + state: ViewportGeometryState, + growthPx: number, +): ViewportGeometryState { + if (growthPx <= VIEWPORT_EPSILON_PX) { + return state; + } + + const hadOnlyFloor = state.pinPx <= state.pinFloorPx + VIEWPORT_EPSILON_PX; + if (!hadOnlyFloor || state.pinFloorPx <= VIEWPORT_EPSILON_PX) { + return consumeCompensation(state, growthPx); + } + + const shrinkBy = Math.min(state.pinFloorPx, growthPx); + const nextFloor = state.pinFloorPx - shrinkBy; + return sanitizeGeometry({ + ...state, + pinPx: nextFloor, + pinFloorPx: nextFloor, + }); +} + +/** Ease-out cubic used by scheduler-driven scroll animations. */ +export function easeOutCubic(t: number): number { + const clamped = Math.min(1, Math.max(0, t)); + return 1 - Math.pow(1 - clamped, 3); +} diff --git a/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.test.ts b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.test.ts new file mode 100644 index 00000000..cc159d22 --- /dev/null +++ b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + READING_MODE, + reduceViewportMode, + type ViewportContext, + type ViewportMode, +} from './FlowViewportMachine'; + +const streamingCtx: ViewportContext = { + isStreaming: true, + latestTurnId: 'turn-2', + stickyPinTurnId: null, +}; +const idleCtx: ViewportContext = { + isStreaming: false, + latestTurnId: 'turn-2', + stickyPinTurnId: null, +}; +const idleWithFloorCtx: ViewportContext = { + isStreaming: false, + latestTurnId: 'turn-2', + stickyPinTurnId: 'turn-2', +}; + +const pinnedLatest: ViewportMode = { kind: 'pinned-latest', turnId: 'turn-2' }; +const following: ViewportMode = { kind: 'following' }; +const finalizing: ViewportMode = { kind: 'finalizing', sinceMs: 1000 }; +const stickyNavigating: ViewportMode = { + kind: 'navigating', + target: { type: 'turn-pin-top', turnId: 'turn-2', pinMode: 'sticky-latest', behavior: 'auto' }, +}; + +describe('FlowViewportMachine', () => { + it('routes a sent turn into sticky pin navigation', () => { + const next = reduceViewportMode(READING_MODE, { type: 'TURN_SENT', turnId: 'turn-2' }, streamingCtx); + expect(next).toEqual(stickyNavigating); + }); + + it('keeps TURN_SENT idempotent for the already pinned turn', () => { + expect( + reduceViewportMode(pinnedLatest, { type: 'TURN_SENT', turnId: 'turn-2' }, streamingCtx), + ).toBe(pinnedLatest); + expect( + reduceViewportMode(stickyNavigating, { type: 'TURN_SENT', turnId: 'turn-2' }, streamingCtx), + ).toBe(stickyNavigating); + }); + + it('treats a sticky NAVIGATE to the already pinned turn as a no-op', () => { + expect( + reduceViewportMode(pinnedLatest, { + type: 'NAVIGATE', + target: { type: 'turn-pin-top', turnId: 'turn-2', pinMode: 'sticky-latest', behavior: 'smooth' }, + }, streamingCtx), + ).toBe(pinnedLatest); + }); + + it('settles sticky pin navigation of the latest turn into pinned-latest', () => { + const next = reduceViewportMode( + stickyNavigating, + { type: 'NAVIGATION_SETTLED', nowMs: 5 }, + streamingCtx, + ); + expect(next).toEqual(pinnedLatest); + }); + + it('settles transient pin navigation into reading', () => { + const navigating: ViewportMode = { + kind: 'navigating', + target: { type: 'turn-pin-top', turnId: 'turn-1', pinMode: 'transient', behavior: 'smooth' }, + }; + expect( + reduceViewportMode(navigating, { type: 'NAVIGATION_SETTLED', nowMs: 5 }, streamingCtx), + ).toEqual(READING_MODE); + }); + + it('activates follow once the pin floor is consumed while streaming', () => { + expect(reduceViewportMode(pinnedLatest, { type: 'PIN_FLOOR_CONSUMED' }, streamingCtx)) + .toEqual(following); + expect(reduceViewportMode(pinnedLatest, { type: 'PIN_FLOOR_CONSUMED' }, idleCtx)) + .toBe(pinnedLatest); + }); + + it('always yields to explicit upward user intent', () => { + for (const mode of [pinnedLatest, following, finalizing, stickyNavigating]) { + expect(reduceViewportMode(mode, { type: 'USER_SCROLL_UP' }, streamingCtx)) + .toEqual(READING_MODE); + } + }); + + it('re-enters follow when the user returns to the content bottom mid-stream', () => { + expect( + reduceViewportMode(READING_MODE, { type: 'USER_REACHED_CONTENT_BOTTOM' }, streamingCtx), + ).toEqual(following); + expect( + reduceViewportMode(READING_MODE, { type: 'USER_REACHED_CONTENT_BOTTOM' }, idleCtx), + ).toBe(READING_MODE); + }); + + it('re-enters pinned-latest when the user scrolls back down onto a live sticky floor', () => { + expect( + reduceViewportMode(READING_MODE, { type: 'USER_REACHED_CONTENT_BOTTOM' }, idleWithFloorCtx), + ).toEqual(pinnedLatest); + // A floor owned by a superseded turn must not re-pin. + expect( + reduceViewportMode(READING_MODE, { type: 'USER_REACHED_CONTENT_BOTTOM' }, { + ...idleWithFloorCtx, + stickyPinTurnId: 'turn-1', + }), + ).toBe(READING_MODE); + }); + + it('keeps pinned-latest alive across stream end (short answers stay pinned)', () => { + expect(reduceViewportMode(pinnedLatest, { type: 'STREAM_ENDED', nowMs: 100 }, idleCtx)) + .toBe(pinnedLatest); + }); + + it('moves follow into finalizing on stream end, then into reading on settle', () => { + const next = reduceViewportMode(following, { type: 'STREAM_ENDED', nowMs: 100 }, idleCtx); + expect(next).toEqual({ kind: 'finalizing', sinceMs: 100 }); + expect(reduceViewportMode(next, { type: 'FINALIZE_SETTLED' }, idleCtx)).toEqual(READING_MODE); + }); + + it('resumes follow when a stream flaps back on during finalizing', () => { + expect(reduceViewportMode(finalizing, { type: 'STREAM_STARTED' }, streamingCtx)) + .toEqual(following); + }); + + it('jump-to-latest settles into follow while streaming, pinned-latest when a sticky floor is live', () => { + const navigating = reduceViewportMode(READING_MODE, { type: 'USER_JUMP_LATEST' }, streamingCtx); + expect(navigating.kind).toBe('navigating'); + expect(reduceViewportMode(navigating, { type: 'NAVIGATION_SETTLED', nowMs: 5 }, streamingCtx)) + .toEqual(following); + expect( + reduceViewportMode(navigating, { type: 'NAVIGATION_SETTLED', nowMs: 5 }, idleWithFloorCtx), + ).toEqual(pinnedLatest); + expect(reduceViewportMode(navigating, { type: 'NAVIGATION_SETTLED', nowMs: 5 }, idleCtx)) + .toEqual(READING_MODE); + }); + + it('initializes sessions by streaming state', () => { + expect( + reduceViewportMode(following, { + type: 'SESSION_CHANGED', + latestTurnId: 'turn-9', + isStreaming: true, + }, streamingCtx), + ).toEqual({ + kind: 'navigating', + target: { type: 'turn-pin-top', turnId: 'turn-9', pinMode: 'sticky-latest', behavior: 'auto' }, + }); + expect( + reduceViewportMode(following, { + type: 'SESSION_CHANGED', + latestTurnId: 'turn-9', + isStreaming: false, + }, idleCtx), + ).toEqual(READING_MODE); + }); +}); diff --git a/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.ts b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.ts new file mode 100644 index 00000000..29807e81 --- /dev/null +++ b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportMachine.ts @@ -0,0 +1,223 @@ +/** + * FlowChat viewport mode state machine. + * + * The single authority for "what is the viewport doing right now". All scroll + * behavior branches on the current mode; nothing else may infer intent from + * scroll deltas or timing windows. + * + * Modes: + * - `reading` user owns the viewport; no automatic scrolling. + * - `pinned-latest` latest user turn is pinned to the reading offset with a + * synthetic tail floor; content growth is absorbed by the + * floor (equal exchange) so the viewport never moves. + * - `following` viewport chases the end of content every frame. + * - `finalizing` stream just ended; keep chasing the tail while terminal + * auto-collapses settle, then hand off to reading. + * - `navigating` an explicit navigation (pin, center, jump-to-latest) is + * being executed by the scheduler. + * + * The reducer is pure; the scheduler owns all side effects. + */ + +import type { ViewportPinMode } from './FlowViewportGeometry'; + +export type ViewportNavigationTarget = + | { + type: 'turn-pin-top'; + turnId: string; + pinMode: ViewportPinMode; + behavior: ScrollBehavior; + } + | { type: 'index-center'; index: number; behavior: ScrollBehavior } + | { type: 'latest-end'; behavior: ScrollBehavior; clearPin: boolean }; + +export type ViewportMode = + | { kind: 'reading' } + | { kind: 'pinned-latest'; turnId: string } + | { kind: 'following' } + | { kind: 'finalizing'; sinceMs: number } + | { kind: 'navigating'; target: ViewportNavigationTarget }; + +export type ViewportEvent = + | { type: 'TURN_SENT'; turnId: string } + | { type: 'PIN_FLOOR_CONSUMED' } + | { type: 'USER_SCROLL_UP' } + | { type: 'USER_REACHED_CONTENT_BOTTOM' } + | { type: 'USER_JUMP_LATEST' } + | { type: 'NAVIGATE'; target: ViewportNavigationTarget } + | { type: 'NAVIGATION_SETTLED'; nowMs: number } + | { type: 'STREAM_STARTED' } + | { type: 'STREAM_ENDED'; nowMs: number } + | { type: 'FINALIZE_SETTLED' } + | { type: 'SESSION_CHANGED'; latestTurnId: string | null; isStreaming: boolean }; + +export interface ViewportContext { + isStreaming: boolean; + latestTurnId: string | null; + /** + * Turn that currently owns a live sticky pin floor (synthetic tail blank), + * or null when no floor is active. The floor belongs to the latest turn's + * reading layout and survives detours into history; reaching the bottom + * again re-enters pinned-latest instead of stopping in reading. + */ + stickyPinTurnId: string | null; +} + +export const READING_MODE: ViewportMode = { kind: 'reading' }; + +function createStickyPinNavigation(turnId: string): ViewportMode { + return { + kind: 'navigating', + target: { + type: 'turn-pin-top', + turnId, + pinMode: 'sticky-latest', + behavior: 'auto', + }, + }; +} + +export function reduceViewportMode( + mode: ViewportMode, + event: ViewportEvent, + context: ViewportContext, +): ViewportMode { + switch (event.type) { + case 'TURN_SENT': { + // Idempotent per turn: repeated pin requests for the turn we are + // already pinning or pinned to are no-ops. + if (mode.kind === 'pinned-latest' && mode.turnId === event.turnId) { + return mode; + } + if ( + mode.kind === 'navigating' && + mode.target.type === 'turn-pin-top' && + mode.target.pinMode === 'sticky-latest' && + mode.target.turnId === event.turnId + ) { + return mode; + } + return createStickyPinNavigation(event.turnId); + } + + case 'NAVIGATE': { + if ( + mode.kind === 'navigating' && + JSON.stringify(mode.target) === JSON.stringify(event.target) + ) { + return mode; + } + // Re-pinning the turn we are already pinned to is a no-op. + if ( + mode.kind === 'pinned-latest' && + event.target.type === 'turn-pin-top' && + event.target.pinMode === 'sticky-latest' && + event.target.turnId === mode.turnId + ) { + return mode; + } + return { kind: 'navigating', target: event.target }; + } + + case 'NAVIGATION_SETTLED': { + if (mode.kind !== 'navigating') { + return mode; + } + const target = mode.target; + if ( + target.type === 'turn-pin-top' && + target.pinMode === 'sticky-latest' && + target.turnId === context.latestTurnId + ) { + return { kind: 'pinned-latest', turnId: target.turnId }; + } + if (target.type === 'latest-end') { + if ( + !context.isStreaming && + context.stickyPinTurnId && + context.stickyPinTurnId === context.latestTurnId + ) { + return { kind: 'pinned-latest', turnId: context.stickyPinTurnId }; + } + return context.isStreaming ? { kind: 'following' } : READING_MODE; + } + return READING_MODE; + } + + case 'PIN_FLOOR_CONSUMED': { + if (mode.kind === 'pinned-latest' && context.isStreaming) { + return { kind: 'following' }; + } + return mode; + } + + case 'USER_SCROLL_UP': { + return mode.kind === 'reading' ? mode : READING_MODE; + } + + case 'USER_REACHED_CONTENT_BOTTOM': { + if (mode.kind !== 'reading') { + return mode; + } + if (context.isStreaming) { + return { kind: 'following' }; + } + // Scrolling back down to the pinned layout restores it: the sticky + // floor survived the detour, so re-arm the equal-exchange invariant. + if (context.stickyPinTurnId && context.stickyPinTurnId === context.latestTurnId) { + return { kind: 'pinned-latest', turnId: context.stickyPinTurnId }; + } + return mode; + } + + case 'USER_JUMP_LATEST': { + return { + kind: 'navigating', + target: { type: 'latest-end', behavior: 'smooth', clearPin: false }, + }; + } + + case 'STREAM_STARTED': { + // A stream that flaps back on during finalizing resumes following. + return mode.kind === 'finalizing' ? { kind: 'following' } : mode; + } + + case 'STREAM_ENDED': { + if (mode.kind === 'following') { + return { kind: 'finalizing', sinceMs: event.nowMs }; + } + // pinned-latest deliberately survives stream end: short answers keep + // their synthetic tail so the pinned turn never drops. + return mode; + } + + case 'FINALIZE_SETTLED': { + return mode.kind === 'finalizing' ? READING_MODE : mode; + } + + case 'SESSION_CHANGED': { + if (event.isStreaming && event.latestTurnId) { + return createStickyPinNavigation(event.latestTurnId); + } + return READING_MODE; + } + } +} + +export function areViewportModesEqual(left: ViewportMode, right: ViewportMode): boolean { + if (left === right) return true; + if (left.kind !== right.kind) return false; + switch (left.kind) { + case 'pinned-latest': + return left.turnId === (right as Extract).turnId; + case 'finalizing': + return left.sinceMs === (right as Extract).sinceMs; + case 'navigating': + return ( + JSON.stringify(left.target) === + JSON.stringify((right as Extract).target) + ); + default: + return true; + } +} diff --git a/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportScheduler.ts b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportScheduler.ts new file mode 100644 index 00000000..e196fa7a --- /dev/null +++ b/src/web-ui/src/flow_chat/scroll/viewport/FlowViewportScheduler.ts @@ -0,0 +1,1002 @@ +/** + * FlowChat viewport scheduler. + * + * The single requestAnimationFrame pipeline that owns the main list's + * `scrollTop`. Every frame it: + * + * 1. reads scroller geometry, + * 2. reconciles the reservation model (growth consumption, pin floor, + * reading shrink protection), + * 3. computes the target scrollTop for the current viewport mode, + * 4. performs at most one scrollTop write. + * + * Nothing else writes `scrollTop`. Input listeners dispatch machine events; + * layout observers only mark geometry dirty and wake the pipeline. + * + * Two synchronous footer-height (never scrollTop) writes are allowed outside + * the pipeline because they must land before the browser clamps or paints: + * - collapse-intent pre-compensation (reading / pinned modes), + * - consumable-reservation consumption on user downward scroll. + * + * The scheduler also owns the built-in ease-out animator used for smooth + * navigation. Unlike native `behavior: 'smooth'`, the animator re-resolves + * its destination every frame, so chasing a still-growing bottom stays fluid. + */ + +import { + ANCHOR_LOCK_DURATION_MS, + COLLAPSE_INTENT_TTL_MS, + FINALIZE_STABLE_FRAMES, + FINALIZE_TIMEOUT_MS, + NAVIGATION_STABLE_FRAMES, + PIN_RETRY_TTL_MS, + PINNED_TURN_VIEWPORT_OFFSET_PX, + PIPELINE_IDLE_FRAMES, + REENTER_FOLLOW_THRESHOLD_PX, + SCROLL_TO_LATEST_THRESHOLD_PX, + VIEWPORT_ANIMATION_MS, + VIEWPORT_EPSILON_PX, + areGeometriesEqual, + absorbPinnedContentGrowth, + clearConsumableCompensation, + clearPinReservation, + consumeCompensation, + createInitialGeometry, + easeOutCubic, + getContentDistanceFromBottom, + getEffectiveContentHeight, + getFollowTargetScrollTop, + getMaxScrollTop, + getTotalCompensationPx, + hasActiveStickyFloor, + reconcileStickyPinReservation, + resolvePinMetrics, + sanitizeGeometry, + type ScrollerMetrics, + type ViewportGeometryState, +} from './FlowViewportGeometry'; +import { + READING_MODE, + areViewportModesEqual, + reduceViewportMode, + type ViewportContext, + type ViewportEvent, + type ViewportMode, +} from './FlowViewportMachine'; +import { incrementFlowChatCounter } from '../../performance/flowChatPerf'; + +export interface FlowViewportHost { + getScroller(): HTMLElement | null; + getFooter(): HTMLElement | null; + getInputFooterPx(): number; + isStreaming(): boolean; + getLatestTurnId(): string | null; + /** Returns -1 when the turn has no user-message virtual item. */ + findUserMessageIndex(turnId: string): number; + getUserMessageElement(turnId: string): HTMLElement | null; + /** Ask Virtuoso to bring an index into range (the only third-party scroll writer). */ + virtuosoScrollToIndex(index: number, align: 'start' | 'center', behavior: ScrollBehavior): void; + onVisibleTurnMeasure(): void; +} + +export interface ViewportSnapshot { + modeKind: ViewportMode['kind']; + showScrollToLatest: boolean; +} + +interface AnchorLockState { + active: boolean; + targetScrollTop: number; + untilMs: number; +} + +interface CollapseIntentState { + active: boolean; + anchorScrollTop: number; + baseCompensationPx: number; + distanceFromBottomBeforeCollapse: number; + cumulativeShrinkPx: number; + expiresAtMs: number; +} + +interface AnimationState { + active: boolean; + startScrollTop: number; + startMs: number; + durationMs: number; +} + +interface NavigationRuntime { + startedAtMs: number; + issuedVirtuosoScroll: boolean; + stableFrames: number; + lastScrollTop: number; +} + +const PIN_ALIGN_TOLERANCE_PX = 1.5; + +export class FlowViewportScheduler { + private readonly host: FlowViewportHost; + + private mode: ViewportMode = READING_MODE; + private geometry: ViewportGeometryState = createInitialGeometry(); + + private anchorLock: AnchorLockState = { active: false, targetScrollTop: 0, untilMs: 0 }; + private collapseIntent: CollapseIntentState = createInactiveCollapseIntent(); + private animation: AnimationState = { active: false, startScrollTop: 0, startMs: 0, durationMs: 0 }; + private navigation: NavigationRuntime = createNavigationRuntime(0); + + private transitionCount = 0; + private previousEffectiveHeight: number | null = null; + private previousScrollTop = 0; + private frameHandle: number | null = null; + private idleFrames = 0; + private finalizeStableFrames = 0; + private pinnedFramesSinceEstablished = 0; + private disposed = false; + + private snapshot: ViewportSnapshot = { modeKind: 'reading', showScrollToLatest: false }; + private readonly listeners = new Set<() => void>(); + + constructor(host: FlowViewportHost) { + this.host = host; + } + + // ── External store API ───────────────────────────────────────────────────── + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): ViewportSnapshot => this.snapshot; + + getMode(): ViewportMode { + return this.mode; + } + + getFooterHeightPx(): number { + return this.host.getInputFooterPx() + getTotalCompensationPx(this.geometry); + } + + /** Turn owning a live sticky pin floor, or null when no floor is active. */ + private getStickyPinTurnId(): string | null { + return hasActiveStickyFloor(this.geometry) ? this.geometry.pinTargetTurnId : null; + } + + // ── Lifecycle ────────────────────────────────────────────────────────────── + + dispose(): void { + this.disposed = true; + this.cancelFrame(); + this.listeners.clear(); + } + + resetForSession(latestTurnId: string | null, isStreaming: boolean): void { + this.geometry = createInitialGeometry(); + this.anchorLock = { active: false, targetScrollTop: 0, untilMs: 0 }; + this.collapseIntent = createInactiveCollapseIntent(); + this.animation.active = false; + this.previousEffectiveHeight = null; + this.previousScrollTop = 0; + this.idleFrames = 0; + this.finalizeStableFrames = 0; + this.applyFooterNow(); + this.dispatch({ type: 'SESSION_CHANGED', latestTurnId, isStreaming }); + } + + resetForEmptyList(): void { + this.geometry = createInitialGeometry(); + this.previousEffectiveHeight = null; + this.anchorLock.active = false; + this.collapseIntent = createInactiveCollapseIntent(); + this.applyFooterNow(); + } + + attachScroller(): void { + const scroller = this.host.getScroller(); + if (scroller) { + this.previousScrollTop = scroller.scrollTop; + this.previousEffectiveHeight = null; + } + this.applyFooterNow(); + this.wake(); + } + + attachFooter(): void { + this.applyFooterNow(); + } + + onInputFooterChanged(): void { + this.applyFooterNow(); + this.previousEffectiveHeight = null; + this.wake(); + } + + // ── Machine dispatch ─────────────────────────────────────────────────────── + + dispatch(event: ViewportEvent): void { + const context: ViewportContext = { + isStreaming: this.host.isStreaming(), + latestTurnId: this.host.getLatestTurnId(), + stickyPinTurnId: this.getStickyPinTurnId(), + }; + const nextMode = reduceViewportMode(this.mode, event, context); + if (areViewportModesEqual(this.mode, nextMode)) { + return; + } + + incrementFlowChatCounter(`viewport.mode.${nextMode.kind}`); + const previousMode = this.mode; + this.mode = nextMode; + this.onModeChanged(previousMode, nextMode); + this.publishSnapshot(); + this.wake(); + } + + private onModeChanged(previous: ViewportMode, next: ViewportMode): void { + this.animation.active = false; + this.idleFrames = 0; + this.finalizeStableFrames = 0; + + if (next.kind === 'navigating') { + this.navigation = createNavigationRuntime(now()); + this.anchorLock.active = false; + // Navigating into history is a detour, not a layout change: the sticky + // floor keeps belonging to the latest turn's reading layout and is only + // released by the explicit clear-pin bottom jump (or a new turn / + // session change). + if (next.target.type === 'latest-end' && next.target.clearPin) { + this.geometry = clearPinReservation(this.geometry); + this.applyFooterNow(); + } + } + + if (next.kind === 'pinned-latest') { + this.pinnedFramesSinceEstablished = 0; + } + + if (next.kind === 'following' && previous.kind === 'pinned-latest') { + // The floor just reached zero; drop any leftover consumable pin space so + // the follow target and the pinned position coincide exactly. + this.geometry = sanitizeGeometry({ + ...this.geometry, + pinPx: this.geometry.pinFloorPx, + }); + this.applyFooterNow(); + } + + if (next.kind === 'reading' && previous.kind === 'finalizing') { + this.geometry = clearConsumableCompensation(this.geometry); + this.applyFooterNow(); + this.snapshotEffectiveHeight(); + } + } + + // ── Input / layout entry points ──────────────────────────────────────────── + + /** Wheel-up, touch pull-down, upward key, scrollbar grab. */ + handleUserScrollUpIntent(): void { + this.dispatch({ type: 'USER_SCROLL_UP' }); + } + + handleScrollEvent(): void { + const scroller = this.host.getScroller(); + if (!scroller) return; + + const metrics = readMetrics(scroller); + const delta = metrics.scrollTop - this.previousScrollTop; + + // Synchronous consumption: only when no sticky floor is active. The sticky + // floor is a layout contract for the latest turn — it must survive detours + // into history and only be released explicitly (new turn / session / clear). + const stickyFloorActive = this.getStickyPinTurnId() !== null; + if ( + delta > VIEWPORT_EPSILON_PX && + !this.anchorLock.active && + this.transitionCount === 0 && + !stickyFloorActive && + (this.mode.kind === 'reading' || this.mode.kind === 'pinned-latest') + ) { + const next = consumeCompensation(this.geometry, delta); + if (!areGeometriesEqual(next, this.geometry)) { + this.geometry = next; + this.applyFooterNow(); + this.snapshotEffectiveHeight(); + } + } + + if (delta > VIEWPORT_EPSILON_PX && this.mode.kind === 'reading') { + const fresh = readMetrics(scroller); + const stickyPinTurnId = this.getStickyPinTurnId(); + if (this.host.isStreaming()) { + if (getContentDistanceFromBottom(fresh, this.geometry) <= REENTER_FOLLOW_THRESHOLD_PX) { + this.dispatch({ type: 'USER_REACHED_CONTENT_BOTTOM' }); + } + } else if ( + stickyPinTurnId !== null && + getContentDistanceFromBottom(fresh, this.geometry) <= REENTER_FOLLOW_THRESHOLD_PX + ) { + // Pinned layout lives at the content bottom, not the physical bottom + // (which includes the synthetic tail). Arriving at content bottom + // restores pinned-latest with the preserved floor. + this.dispatch({ type: 'USER_REACHED_CONTENT_BOTTOM' }); + } + } + + this.previousScrollTop = scroller.scrollTop; + this.updateShowScrollToLatest(readMetrics(scroller)); + this.host.onVisibleTurnMeasure(); + this.idleFrames = 0; + this.wake(); + } + + handleContentResize(): void { + this.idleFrames = 0; + this.host.onVisibleTurnMeasure(); + this.wake(); + } + + handleLayoutMutation(): void { + this.idleFrames = 0; + this.host.onVisibleTurnMeasure(); + this.wake(); + } + + handleRangeChanged(): void { + this.idleFrames = 0; + this.host.onVisibleTurnMeasure(); + this.wake(); + } + + transitionStarted(): void { + this.transitionCount += 1; + this.idleFrames = 0; + this.wake(); + } + + transitionEnded(): void { + this.transitionCount = Math.max(0, this.transitionCount - 1); + if (this.transitionCount === 0 && this.collapseIntent.active) { + this.collapseIntent = createInactiveCollapseIntent(); + } + this.idleFrames = 0; + this.wake(); + } + + /** + * Pre-compensation for an announced collapse. Must run synchronously inside + * the dispatching event so the footer grows before the browser clamps. + */ + handleCollapseIntent(estimatedShrinkPx: number | null): void { + const scroller = this.host.getScroller(); + if (!scroller) return; + + const estimate = Math.max(0, estimatedShrinkPx ?? 0); + const metrics = readMetrics(scroller); + + switch (this.mode.kind) { + case 'following': + case 'finalizing': + case 'navigating': { + // Tail chasing absorbs the shrink; protection would fight it. + this.wake(); + return; + } + case 'pinned-latest': { + if (estimate <= VIEWPORT_EPSILON_PX) { + this.wake(); + return; + } + // Grow the floor before layout shrinks so scrollHeight never dips and + // the pinned turn cannot be clamped downward. The per-frame pin + // reconcile converges to the measured value afterwards. + this.geometry = sanitizeGeometry({ + ...this.geometry, + pinFloorPx: this.geometry.pinFloorPx + estimate, + pinPx: Math.max(this.geometry.pinPx, this.geometry.pinFloorPx + estimate), + }); + this.applyFooterNow(); + this.snapshotEffectiveHeight(); + this.wake(); + return; + } + case 'reading': { + const baseCompensationPx = getTotalCompensationPx(this.geometry); + const distanceFromBottom = Math.max( + 0, + metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop, + ); + const effectiveDistance = Math.max(0, distanceFromBottom - baseCompensationPx); + const provisionalPx = Math.max(0, estimate - effectiveDistance); + + this.collapseIntent = { + active: true, + anchorScrollTop: metrics.scrollTop, + baseCompensationPx, + distanceFromBottomBeforeCollapse: effectiveDistance, + cumulativeShrinkPx: 0, + expiresAtMs: now() + COLLAPSE_INTENT_TTL_MS, + }; + + if (provisionalPx > VIEWPORT_EPSILON_PX) { + this.geometry = sanitizeGeometry({ + ...this.geometry, + collapsePx: this.geometry.collapsePx + provisionalPx, + }); + this.applyFooterNow(); + this.activateAnchorLock(metrics.scrollTop); + } + this.wake(); + return; + } + } + } + + /** Latest turn changed within the same session (message sent / restored). */ + notifyTurnSent(turnId: string): void { + this.dispatch({ type: 'TURN_SENT', turnId }); + } + + notifyStreamingChanged(isStreaming: boolean): void { + if (isStreaming) { + this.dispatch({ type: 'STREAM_STARTED' }); + } else { + this.dispatch({ type: 'STREAM_ENDED', nowMs: now() }); + } + this.wake(); + } + + // ── Pipeline ─────────────────────────────────────────────────────────────── + + wake(): void { + if (this.disposed || this.frameHandle !== null) { + return; + } + this.frameHandle = requestAnimationFrame(this.runFrame); + } + + private cancelFrame(): void { + if (this.frameHandle !== null) { + cancelAnimationFrame(this.frameHandle); + this.frameHandle = null; + } + } + + private readonly runFrame = (): void => { + this.frameHandle = null; + if (this.disposed) return; + + const scroller = this.host.getScroller(); + if (!scroller) return; + + incrementFlowChatCounter('viewport.frame'); + const metrics = readMetrics(scroller); + + this.reconcileHeightChange(scroller, metrics); + + let keepRunning = false; + switch (this.mode.kind) { + case 'navigating': + keepRunning = this.stepNavigation(scroller); + break; + case 'pinned-latest': + keepRunning = this.stepPinned(scroller); + break; + case 'following': + keepRunning = this.stepFollow(scroller, false); + break; + case 'finalizing': + keepRunning = this.stepFollow(scroller, true); + break; + case 'reading': + keepRunning = this.stepReading(scroller); + break; + } + + this.updateShowScrollToLatest(readMetrics(scroller)); + this.previousScrollTop = scroller.scrollTop; + + if (keepRunning) { + this.idleFrames = 0; + this.wake(); + return; + } + + this.idleFrames += 1; + if (this.idleFrames < PIPELINE_IDLE_FRAMES) { + this.wake(); + } + }; + + /** + * Growth consumes consumable reservations; shrink outside tail-chasing + * modes triggers reading protection. Uses effective heights only. + */ + private reconcileHeightChange(scroller: HTMLElement, metrics: ScrollerMetrics): void { + const effectiveHeight = getEffectiveContentHeight( + metrics, + this.geometry, + this.host.getInputFooterPx(), + ); + const previous = this.previousEffectiveHeight; + this.previousEffectiveHeight = effectiveHeight; + + if (previous === null) { + return; + } + + const delta = effectiveHeight - previous; + if (Math.abs(delta) <= VIEWPORT_EPSILON_PX) { + return; + } + + if (delta > 0) { + // Growth. During an active layout transition with compensation present, + // hold consumption until the transition finishes (intermediate sizes). + if (getTotalCompensationPx(this.geometry) > VIEWPORT_EPSILON_PX && this.transitionCount > 0) { + return; + } + // Sticky floor is frozen while the user is away in reading/navigating. + // Only pinned-latest applies equal exchange; height deltas from + // virtualization while scrolling history are ignored. + if (this.getStickyPinTurnId() !== null && this.mode.kind !== 'pinned-latest') { + return; + } + const next = this.mode.kind === 'pinned-latest' + ? absorbPinnedContentGrowth(this.geometry, delta) + : consumeCompensation(this.geometry, delta); + if (!areGeometriesEqual(next, this.geometry)) { + this.geometry = next; + this.applyFooterNow(); + this.previousEffectiveHeight = getEffectiveContentHeight( + readMetrics(scroller), + this.geometry, + this.host.getInputFooterPx(), + ); + } + return; + } + + // Shrink. + const shrink = -delta; + if (this.mode.kind !== 'reading') { + // pinned: the pin floor reconcile restores the tail this frame. + // following/finalizing/navigating: chasing absorbs the shrink. + return; + } + + const distanceFromBottom = Math.max( + 0, + metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop, + ); + const intent = this.collapseIntent; + const hasIntent = intent.active && intent.expiresAtMs >= now(); + const fallbackPx = Math.max(0, shrink - distanceFromBottom); + + let nextTotal: number; + if (hasIntent) { + const cumulative = intent.cumulativeShrinkPx + shrink; + this.collapseIntent = { ...intent, cumulativeShrinkPx: cumulative }; + const resolved = intent.baseCompensationPx + + Math.max(0, cumulative - intent.distanceFromBottomBeforeCollapse); + nextTotal = this.transitionCount > 0 + ? Math.max(getTotalCompensationPx(this.geometry), resolved) + : resolved; + } else { + if (fallbackPx <= VIEWPORT_EPSILON_PX) { + return; + } + nextTotal = getTotalCompensationPx(this.geometry) + fallbackPx; + } + + if (nextTotal > VIEWPORT_EPSILON_PX) { + this.geometry = sanitizeGeometry({ + ...this.geometry, + collapsePx: Math.max(0, nextTotal - this.geometry.pinPx), + }); + const anchorTarget = hasIntent ? intent.anchorScrollTop : this.previousScrollTop; + this.activateAnchorLock(anchorTarget); + this.applyFooterNow(); + this.enforceAnchorLock(scroller); + if (this.transitionCount === 0) { + this.collapseIntent = createInactiveCollapseIntent(); + } + this.previousEffectiveHeight = getEffectiveContentHeight( + readMetrics(scroller), + this.geometry, + this.host.getInputFooterPx(), + ); + } + } + + // ── Mode steps ───────────────────────────────────────────────────────────── + + private stepPinned(scroller: HTMLElement): boolean { + const mode = this.mode; + if (mode.kind !== 'pinned-latest') return false; + + const element = this.host.getUserMessageElement(mode.turnId); + if (!element) { + // Virtualized out (large resize or fast history jump); bring it back. + const index = this.host.findUserMessageIndex(mode.turnId); + if (index < 0) { + this.dispatch({ type: 'USER_SCROLL_UP' }); + return false; + } + this.host.virtuosoScrollToIndex(index, 'start', 'auto'); + return true; + } + + const metrics = readMetrics(scroller); + const scrollerRect = scroller.getBoundingClientRect(); + const targetRect = element.getBoundingClientRect(); + const topDelta = targetRect.top - (scrollerRect.top + PINNED_TURN_VIEWPORT_OFFSET_PX); + const { desiredScrollTop, missingTailSpacePx } = resolvePinMetrics( + metrics, + topDelta, + this.geometry.pinPx, + ); + + // Equal exchange: floor tracks the live missing-tail measurement. At + // equilibrium missingTailSpacePx is zero (incremental need), which means + // the current reservation is correct — reconcileStickyPinReservation + // preserves it instead of zeroing the floor. + const holdReconcile = this.transitionCount > 0 && missingTailSpacePx < this.geometry.pinFloorPx; + const nextGeometry = reconcileStickyPinReservation( + this.geometry, + missingTailSpacePx, + holdReconcile, + mode.turnId, + ); + if (!areGeometriesEqual(nextGeometry, this.geometry)) { + this.geometry = nextGeometry; + this.applyFooterNow(); + this.snapshotEffectiveHeight(); + } + + const target = Math.min(desiredScrollTop, getMaxScrollTop(readMetrics(scroller))); + const deviation = target - scroller.scrollTop; + if (Math.abs(deviation) > PIN_ALIGN_TOLERANCE_PX) { + scroller.scrollTop = target; + } + + this.pinnedFramesSinceEstablished += 1; + if ( + this.pinnedFramesSinceEstablished > 1 && + this.geometry.pinFloorPx <= VIEWPORT_EPSILON_PX && + this.host.isStreaming() && + this.transitionCount === 0 + ) { + this.dispatch({ type: 'PIN_FLOOR_CONSUMED' }); + return true; + } + + // Keep running every frame while streaming; otherwise settle and let + // observers wake us for late layout changes. + return this.host.isStreaming() || Math.abs(deviation) > PIN_ALIGN_TOLERANCE_PX; + } + + private stepFollow(scroller: HTMLElement, finalizing: boolean): boolean { + const metrics = readMetrics(scroller); + const target = Math.min( + getFollowTargetScrollTop(metrics, this.geometry), + getMaxScrollTop(metrics), + ); + const distance = target - scroller.scrollTop; + + let moved = false; + if (distance > VIEWPORT_EPSILON_PX) { + // Only chase downward. Upward corrections would fight the browser's own + // clamp behavior during shrink and feel like a bounce. + scroller.scrollTop = target; + moved = true; + } + + if (finalizing) { + const mode = this.mode; + const sinceMs = mode.kind === 'finalizing' ? mode.sinceMs : now(); + if (moved || this.transitionCount > 0) { + this.finalizeStableFrames = 0; + } else { + this.finalizeStableFrames += 1; + } + if ( + this.finalizeStableFrames >= FINALIZE_STABLE_FRAMES || + now() - sinceMs >= FINALIZE_TIMEOUT_MS + ) { + this.dispatch({ type: 'FINALIZE_SETTLED' }); + return false; + } + return true; + } + + return this.host.isStreaming() || moved; + } + + private stepReading(scroller: HTMLElement): boolean { + return this.enforceAnchorLock(scroller); + } + + private stepNavigation(scroller: HTMLElement): boolean { + const mode = this.mode; + if (mode.kind !== 'navigating') return false; + const target = mode.target; + + if (now() - this.navigation.startedAtMs > PIN_RETRY_TTL_MS && target.type === 'turn-pin-top') { + this.settleNavigation(); + return false; + } + + switch (target.type) { + case 'latest-end': { + const metrics = readMetrics(scroller); + const destination = Math.min( + getFollowTargetScrollTop(metrics, this.geometry), + getMaxScrollTop(metrics), + ); + const arrived = this.driveScroll(scroller, destination, target.behavior); + if (arrived) { + this.settleNavigation(); + return false; + } + return true; + } + + case 'index-center': { + if (!this.navigation.issuedVirtuosoScroll) { + this.navigation.issuedVirtuosoScroll = true; + this.navigation.lastScrollTop = scroller.scrollTop; + this.host.virtuosoScrollToIndex(target.index, 'center', target.behavior); + return true; + } + // Virtuoso drives this scroll; settle once the position stabilizes. + if (Math.abs(scroller.scrollTop - this.navigation.lastScrollTop) <= VIEWPORT_EPSILON_PX) { + this.navigation.stableFrames += 1; + } else { + this.navigation.stableFrames = 0; + this.navigation.lastScrollTop = scroller.scrollTop; + } + if (this.navigation.stableFrames >= NAVIGATION_STABLE_FRAMES) { + this.settleNavigation(); + return false; + } + return true; + } + + case 'turn-pin-top': { + const element = this.host.getUserMessageElement(target.turnId); + if (!element) { + const index = this.host.findUserMessageIndex(target.turnId); + if (index < 0) { + this.settleNavigation(); + return false; + } + if (target.pinMode === 'sticky-latest') { + // Provisional tail so the eventual alignment has enough scroll + // range even before the item renders. Preserve any live floor. + const metrics = readMetrics(scroller); + const provisional = Math.max(getMaxScrollTop(metrics), this.geometry.pinPx); + if (provisional > this.geometry.pinPx + VIEWPORT_EPSILON_PX) { + this.geometry = sanitizeGeometry({ + ...this.geometry, + pinPx: provisional, + pinFloorPx: this.geometry.pinFloorPx, + pinMode: 'sticky-latest', + pinTargetTurnId: target.turnId, + }); + this.applyFooterNow(); + this.snapshotEffectiveHeight(); + } + } + this.host.virtuosoScrollToIndex(index, 'start', 'auto'); + return true; + } + + const metrics = readMetrics(scroller); + const scrollerRect = scroller.getBoundingClientRect(); + const targetRect = element.getBoundingClientRect(); + const topDelta = targetRect.top - (scrollerRect.top + PINNED_TURN_VIEWPORT_OFFSET_PX); + const { desiredScrollTop, missingTailSpacePx } = resolvePinMetrics( + metrics, + topDelta, + this.geometry.pinPx, + ); + + const isSticky = target.pinMode === 'sticky-latest'; + if (isSticky) { + this.geometry = reconcileStickyPinReservation( + this.geometry, + missingTailSpacePx, + false, + target.turnId, + ); + } else { + // Transient: borrow consumable space only; never touch the floor. + this.geometry = sanitizeGeometry({ + ...this.geometry, + pinPx: Math.max(missingTailSpacePx, this.geometry.pinPx), + }); + } + this.applyFooterNow(); + this.snapshotEffectiveHeight(); + + const destination = Math.min(desiredScrollTop, getMaxScrollTop(readMetrics(scroller))); + const arrived = this.driveScroll(scroller, destination, target.behavior); + if (arrived) { + this.settleNavigation(); + return false; + } + return true; + } + } + } + + private settleNavigation(): void { + this.dispatch({ type: 'NAVIGATION_SETTLED', nowMs: now() }); + this.host.onVisibleTurnMeasure(); + } + + /** + * Move toward a destination either instantly or through the retargeting + * ease-out animator. Returns true once within tolerance. + */ + private driveScroll( + scroller: HTMLElement, + destination: number, + behavior: ScrollBehavior, + ): boolean { + const current = scroller.scrollTop; + if (Math.abs(destination - current) <= PIN_ALIGN_TOLERANCE_PX) { + this.animation.active = false; + return true; + } + + if (behavior !== 'smooth') { + scroller.scrollTop = destination; + return false; + } + + if (!this.animation.active) { + this.animation = { + active: true, + startScrollTop: current, + startMs: now(), + durationMs: VIEWPORT_ANIMATION_MS, + }; + } + + const t = (now() - this.animation.startMs) / this.animation.durationMs; + const eased = easeOutCubic(t); + // Destination is re-resolved by the caller every frame, so a growing + // bottom is chased without restarting the animation. + const next = this.animation.startScrollTop + (destination - this.animation.startScrollTop) * eased; + scroller.scrollTop = next; + + if (t >= 1) { + this.animation.active = false; + scroller.scrollTop = destination; + return true; + } + return false; + } + + // ── Anchor lock ──────────────────────────────────────────────────────────── + + private activateAnchorLock(targetScrollTop: number): void { + this.anchorLock = { + active: true, + targetScrollTop: Math.max(this.anchorLock.active ? this.anchorLock.targetScrollTop : 0, targetScrollTop), + untilMs: now() + ANCHOR_LOCK_DURATION_MS, + }; + } + + private enforceAnchorLock(scroller: HTMLElement): boolean { + if (!this.anchorLock.active) { + return false; + } + if (now() > this.anchorLock.untilMs && this.transitionCount === 0) { + this.anchorLock.active = false; + return false; + } + + const metrics = readMetrics(scroller); + const target = Math.min(this.anchorLock.targetScrollTop, getMaxScrollTop(metrics)); + if (Math.abs(target - scroller.scrollTop) > VIEWPORT_EPSILON_PX) { + scroller.scrollTop = target; + this.previousScrollTop = target; + } + return true; + } + + // ── Footer / snapshot plumbing ───────────────────────────────────────────── + + /** + * Synchronous footer DOM write with forced layout reads so the new height + * participates in this task's layout, before any scroll clamping. + */ + private applyFooterNow(): void { + const footer = this.host.getFooter(); + if (!footer) return; + + const heightPx = this.getFooterHeightPx(); + footer.style.height = `${heightPx}px`; + footer.style.minHeight = `${heightPx}px`; + void footer.offsetHeight; + const scroller = this.host.getScroller(); + if (scroller) { + void scroller.scrollHeight; + } + } + + private snapshotEffectiveHeight(): void { + const scroller = this.host.getScroller(); + if (!scroller) return; + this.previousEffectiveHeight = getEffectiveContentHeight( + readMetrics(scroller), + this.geometry, + this.host.getInputFooterPx(), + ); + } + + private updateShowScrollToLatest(metrics: ScrollerMetrics): void { + const show = + this.mode.kind === 'reading' && + getContentDistanceFromBottom(metrics, this.geometry) > SCROLL_TO_LATEST_THRESHOLD_PX; + if (show !== this.snapshot.showScrollToLatest) { + this.publishSnapshot(); + } + } + + private publishSnapshot(): void { + const scroller = this.host.getScroller(); + const show = scroller + ? this.mode.kind === 'reading' && + getContentDistanceFromBottom(readMetrics(scroller), this.geometry) > + SCROLL_TO_LATEST_THRESHOLD_PX + : false; + const next: ViewportSnapshot = { modeKind: this.mode.kind, showScrollToLatest: show }; + if ( + next.modeKind === this.snapshot.modeKind && + next.showScrollToLatest === this.snapshot.showScrollToLatest + ) { + return; + } + this.snapshot = next; + for (const listener of this.listeners) { + listener(); + } + } +} + +function readMetrics(scroller: HTMLElement): ScrollerMetrics { + return { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }; +} + +function createInactiveCollapseIntent(): CollapseIntentState { + return { + active: false, + anchorScrollTop: 0, + baseCompensationPx: 0, + distanceFromBottomBeforeCollapse: 0, + cumulativeShrinkPx: 0, + expiresAtMs: 0, + }; +} + +function createNavigationRuntime(startedAtMs: number): NavigationRuntime { + return { + startedAtMs, + issuedVirtuosoScroll: false, + stableFrames: 0, + lastScrollTop: 0, + }; +} + +function now(): number { + return performance.now(); +} diff --git a/src/web-ui/src/flow_chat/scroll/viewport/useFlowViewportController.ts b/src/web-ui/src/flow_chat/scroll/viewport/useFlowViewportController.ts new file mode 100644 index 00000000..ccf90719 --- /dev/null +++ b/src/web-ui/src/flow_chat/scroll/viewport/useFlowViewportController.ts @@ -0,0 +1,435 @@ +/** + * React assembly for the FlowChat viewport system. + * + * Wires the mode machine + frame scheduler to a Virtuoso host: input intent + * listeners, layout observers, layout mutation events, session/turn/stream + * effects, and the imperative navigation command API. + * + * This hook is the only integration point `VirtualMessageList` needs. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type MutableRefObject, + type RefObject, +} from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import { + FLOW_LAYOUT_COLLAPSE_INTENT_EVENT, + FLOW_LAYOUT_MUTATION_EVENT, + isFlowLayoutCollapseIntentEvent, +} from '../FlowLayoutMutationEvents'; +import { + TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX, + isEditableElement, + isPointerOnScrollbarGutter, + isUpwardScrollIntentKey, +} from '../FlowScrollIntent'; +import type { ViewportPinMode } from './FlowViewportGeometry'; +import { FlowViewportScheduler, type FlowViewportHost, type ViewportSnapshot } from './FlowViewportScheduler'; +import type { ViewportMode } from './FlowViewportMachine'; + +interface UserMessageRenderItem { + item: { turnId: string; data?: unknown }; + index: number; +} + +export interface UseFlowViewportControllerOptions { + activeSessionId: string | undefined; + latestTurnId: string | null; + virtualItemCount: number; + userMessageItems: UserMessageRenderItem[]; + isStreaming: boolean; + inputStackFooterPx: number; + virtuosoRef: RefObject; + scrollerElementRef: MutableRefObject; + /** Scroller element mirrored into parent state; listener effects rebind on change. */ + scrollerElement: HTMLElement | null; + onScrollerElementChange: (element: HTMLElement | null) => void; + onVisibleTurnMeasure: () => void; +} + +export interface FlowViewportCommands { + scrollToTurn: (turnIndex: number) => void; + scrollToIndex: (index: number) => void; + pinTurnToTop: ( + turnId: string, + options?: { behavior?: ScrollBehavior; pinMode?: ViewportPinMode }, + ) => boolean; + scrollToLatestEndPosition: () => void; + scrollToPhysicalBottomAndClearPin: () => void; +} + +export interface UseFlowViewportControllerResult { + snapshot: ViewportSnapshot; + handleScrollerRef: (el: HTMLElement | Window | null) => void; + handleFooterRef: (el: HTMLDivElement | null) => void; + handleRangeChanged: () => void; + getFooterHeightPx: () => number; + commands: FlowViewportCommands; + getMode: () => ViewportMode; +} + +const LAYOUT_TRANSITION_PROPERTIES = new Set(['grid-template-rows', 'height', 'max-height']); + +export function useFlowViewportController( + options: UseFlowViewportControllerOptions, +): UseFlowViewportControllerResult { + const { + activeSessionId, + latestTurnId, + virtualItemCount, + userMessageItems, + isStreaming, + inputStackFooterPx, + virtuosoRef, + scrollerElementRef, + scrollerElement, + onScrollerElementChange, + onVisibleTurnMeasure, + } = options; + + const footerElementRef = useRef(null); + + // Live host values readable from scheduler callbacks without re-binding. + const hostStateRef = useRef({ + isStreaming, + latestTurnId, + inputStackFooterPx, + userMessageItems, + onVisibleTurnMeasure, + }); + hostStateRef.current = { + isStreaming, + latestTurnId, + inputStackFooterPx, + userMessageItems, + onVisibleTurnMeasure, + }; + + const schedulerRef = useRef(null); + if (schedulerRef.current === null) { + const host: FlowViewportHost = { + getScroller: () => scrollerElementRef.current, + getFooter: () => footerElementRef.current, + getInputFooterPx: () => hostStateRef.current.inputStackFooterPx, + isStreaming: () => hostStateRef.current.isStreaming, + getLatestTurnId: () => hostStateRef.current.latestTurnId, + findUserMessageIndex: (turnId: string) => { + const entry = hostStateRef.current.userMessageItems.find( + ({ item }) => item.turnId === turnId, + ); + return entry ? entry.index : -1; + }, + getUserMessageElement: (turnId: string) => { + const scroller = scrollerElementRef.current; + if (!scroller) return null; + return scroller.querySelector( + `.virtual-item-wrapper[data-item-type="user-message"][data-turn-id="${CSS.escape(turnId)}"]`, + ); + }, + virtuosoScrollToIndex: (index, align, behavior) => { + virtuosoRef.current?.scrollToIndex({ + index, + align, + behavior: behavior === 'smooth' ? 'smooth' : 'auto', + }); + }, + onVisibleTurnMeasure: () => hostStateRef.current.onVisibleTurnMeasure(), + }; + schedulerRef.current = new FlowViewportScheduler(host); + } + const scheduler = schedulerRef.current; + + useEffect(() => () => scheduler.dispose(), [scheduler]); + + const snapshot = useSyncExternalStore(scheduler.subscribe, scheduler.getSnapshot); + + // ── Scroller / footer wiring ─────────────────────────────────────────────── + + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { + const element = el instanceof HTMLElement ? el : null; + scrollerElementRef.current = element; + onScrollerElementChange(element); + if (element) { + scheduler.attachScroller(); + } + }, [onScrollerElementChange, scheduler, scrollerElementRef]); + + const handleFooterRef = useCallback((el: HTMLDivElement | null) => { + footerElementRef.current = el; + if (el) { + scheduler.attachFooter(); + } + }, [scheduler]); + + // ── Session / turn / stream effects ──────────────────────────────────────── + + const previousSessionIdRef = useRef(undefined); + const previousLatestTurnIdRef = useRef(null); + const hasMountedRef = useRef(false); + + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + previousSessionIdRef.current = activeSessionId; + previousLatestTurnIdRef.current = latestTurnId; + // Initial mount inherits session semantics: a streaming session pins + // its latest turn; a static one starts in reading mode. + scheduler.resetForSession(latestTurnId, isStreaming); + return; + } + + if (previousSessionIdRef.current !== activeSessionId) { + previousSessionIdRef.current = activeSessionId; + previousLatestTurnIdRef.current = latestTurnId; + scheduler.resetForSession(latestTurnId, isStreaming); + return; + } + + if (previousLatestTurnIdRef.current !== latestTurnId) { + previousLatestTurnIdRef.current = latestTurnId; + if (latestTurnId) { + scheduler.notifyTurnSent(latestTurnId); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSessionId, latestTurnId, scheduler]); + + const previousStreamingRef = useRef(isStreaming); + useEffect(() => { + if (previousStreamingRef.current === isStreaming) { + return; + } + previousStreamingRef.current = isStreaming; + scheduler.notifyStreamingChanged(isStreaming); + }, [isStreaming, scheduler]); + + useEffect(() => { + if (virtualItemCount === 0) { + scheduler.resetForEmptyList(); + } + }, [scheduler, virtualItemCount]); + + useEffect(() => { + scheduler.onInputFooterChanged(); + }, [inputStackFooterPx, scheduler]); + + // ── Input intent + scroll listeners ──────────────────────────────────────── + + useEffect(() => { + const scroller = scrollerElement; + if (!scroller) return; + + let touchStartY: number | null = null; + let scrollbarDragActive = false; + + const handleScroll = () => scheduler.handleScrollEvent(); + + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0) { + scheduler.handleUserScrollUpIntent(); + } + }; + + const handleTouchStart = (event: TouchEvent) => { + touchStartY = event.touches[0]?.clientY ?? null; + }; + + const handleTouchMove = (event: TouchEvent) => { + const currentY = event.touches[0]?.clientY; + if (touchStartY === null || currentY === undefined) return; + if (currentY - touchStartY > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { + touchStartY = currentY; + scheduler.handleUserScrollUpIntent(); + } + }; + + const resetTouch = () => { + touchStartY = null; + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!isUpwardScrollIntentKey(event) || isEditableElement(event.target)) { + return; + } + scheduler.handleUserScrollUpIntent(); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === 'touch' || event.button !== 0) return; + if (!isPointerOnScrollbarGutter(scroller, event.clientX, event.clientY)) return; + scrollbarDragActive = true; + scheduler.handleUserScrollUpIntent(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!scrollbarDragActive || event.pointerType === 'touch') return; + if ((event.buttons & 1) !== 1) { + scrollbarDragActive = false; + return; + } + scheduler.handleUserScrollUpIntent(); + }; + + const endScrollbarDrag = () => { + scrollbarDragActive = false; + }; + + scroller.addEventListener('scroll', handleScroll, { passive: true }); + scroller.addEventListener('wheel', handleWheel, { passive: true }); + scroller.addEventListener('touchstart', handleTouchStart, { passive: true }); + scroller.addEventListener('touchmove', handleTouchMove, { passive: true }); + scroller.addEventListener('touchend', resetTouch, { passive: true }); + scroller.addEventListener('touchcancel', resetTouch, { passive: true }); + scroller.addEventListener('keydown', handleKeyDown, true); + scroller.addEventListener('pointerdown', handlePointerDown, true); + window.addEventListener('pointermove', handlePointerMove, true); + window.addEventListener('pointerup', endScrollbarDrag, true); + window.addEventListener('pointercancel', endScrollbarDrag, true); + + return () => { + scroller.removeEventListener('scroll', handleScroll); + scroller.removeEventListener('wheel', handleWheel); + scroller.removeEventListener('touchstart', handleTouchStart); + scroller.removeEventListener('touchmove', handleTouchMove); + scroller.removeEventListener('touchend', resetTouch); + scroller.removeEventListener('touchcancel', resetTouch); + scroller.removeEventListener('keydown', handleKeyDown, true); + scroller.removeEventListener('pointerdown', handlePointerDown, true); + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', endScrollbarDrag, true); + window.removeEventListener('pointercancel', endScrollbarDrag, true); + }; + }, [scheduler, scrollerElement]); + + // ── Layout observers ─────────────────────────────────────────────────────── + + useEffect(() => { + const scroller = scrollerElement; + if (!scroller) return; + + const resizeTarget = scroller.firstElementChild instanceof HTMLElement + ? scroller.firstElementChild + : scroller; + + const resizeObserver = new ResizeObserver(() => { + scheduler.handleContentResize(); + }); + resizeObserver.observe(resizeTarget); + + const handleTransitionRun = (event: TransitionEvent) => { + if (!LAYOUT_TRANSITION_PROPERTIES.has(event.propertyName)) return; + scheduler.transitionStarted(); + }; + const handleTransitionFinish = (event: TransitionEvent) => { + if (!LAYOUT_TRANSITION_PROPERTIES.has(event.propertyName)) return; + scheduler.transitionEnded(); + }; + + scroller.addEventListener('transitionrun', handleTransitionRun, true); + scroller.addEventListener('transitionend', handleTransitionFinish, true); + scroller.addEventListener('transitioncancel', handleTransitionFinish, true); + + return () => { + resizeObserver.disconnect(); + scroller.removeEventListener('transitionrun', handleTransitionRun, true); + scroller.removeEventListener('transitionend', handleTransitionFinish, true); + scroller.removeEventListener('transitioncancel', handleTransitionFinish, true); + }; + }, [scheduler, scrollerElement]); + + // ── Layout mutation contract ─────────────────────────────────────────────── + + useEffect(() => { + const handleMutation = () => { + scheduler.handleLayoutMutation(); + }; + const handleCollapseIntent = (event: Event) => { + if (!isFlowLayoutCollapseIntentEvent(event)) return; + scheduler.handleCollapseIntent(event.detail?.cardHeight ?? null); + }; + + window.addEventListener(FLOW_LAYOUT_MUTATION_EVENT, handleMutation); + window.addEventListener(FLOW_LAYOUT_COLLAPSE_INTENT_EVENT, handleCollapseIntent as EventListener); + return () => { + window.removeEventListener(FLOW_LAYOUT_MUTATION_EVENT, handleMutation); + window.removeEventListener(FLOW_LAYOUT_COLLAPSE_INTENT_EVENT, handleCollapseIntent as EventListener); + }; + }, [scheduler]); + + // ── Commands ─────────────────────────────────────────────────────────────── + + const commands = useMemo(() => ({ + scrollToTurn: (turnIndex: number) => { + const items = hostStateRef.current.userMessageItems; + if (turnIndex < 1 || turnIndex > items.length) return; + const target = items[turnIndex - 1]; + if (!target) return; + scheduler.dispatch({ + type: 'NAVIGATE', + target: { type: 'index-center', index: target.index, behavior: 'smooth' }, + }); + }, + scrollToIndex: (index: number) => { + scheduler.dispatch({ + type: 'NAVIGATE', + target: { type: 'index-center', index, behavior: 'auto' }, + }); + }, + pinTurnToTop: (turnId, pinOptions) => { + const exists = hostStateRef.current.userMessageItems.some( + ({ item }) => item.turnId === turnId, + ); + if (!exists) return false; + // Pin mode is derived from the target, not trusted from the caller: + // pinning the latest turn must always keep (or rebuild) the sticky tail + // floor, while pinning an older turn is a transient detour that leaves + // the floor untouched for the return trip. This keeps anchor-dot jumps, + // header jumps, and send-message pins consistent without per-caller + // mode decisions. + const pinMode: ViewportPinMode = + turnId === hostStateRef.current.latestTurnId ? 'sticky-latest' : 'transient'; + scheduler.dispatch({ + type: 'NAVIGATE', + target: { + type: 'turn-pin-top', + turnId, + pinMode, + behavior: pinOptions?.behavior ?? 'auto', + }, + }); + return true; + }, + scrollToLatestEndPosition: () => { + scheduler.dispatch({ type: 'USER_JUMP_LATEST' }); + }, + scrollToPhysicalBottomAndClearPin: () => { + scheduler.dispatch({ + type: 'NAVIGATE', + target: { type: 'latest-end', behavior: 'smooth', clearPin: true }, + }); + }, + }), [scheduler]); + + const handleRangeChanged = useCallback(() => { + scheduler.handleRangeChanged(); + }, [scheduler]); + + const getFooterHeightPx = useCallback(() => scheduler.getFooterHeightPx(), [scheduler]); + const getMode = useCallback(() => scheduler.getMode(), [scheduler]); + + return { + snapshot, + handleScrollerRef, + handleFooterRef, + handleRangeChanged, + getFooterHeightPx, + commands, + getMode, + }; +} diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 649436df..ba83249e 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -14,7 +14,8 @@ import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; import { createLogger } from '@/shared/utils/logger'; import type { WorkspaceInfo } from '@/shared/types'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; +import { openSession } from '@/app/navigation/navigationController'; import { compareSessionsForDisplay, sessionBelongsToWorkspaceNavRow, @@ -26,8 +27,9 @@ import { saveAllInProgressTurns, immediateSaveDialogTurn, createChatSession as createChatSessionModule, - switchChatSession as switchChatSessionModule, + activateSessionData as activateSessionDataModule, deleteChatSession as deleteChatSessionModule, + retargetEmptyChatSessionWorkspace as retargetEmptyChatSessionWorkspaceModule, renameChatSessionTitle as renameChatSessionTitleModule, forkChatSession as forkChatSessionModule, cleanupSaveState, @@ -140,7 +142,7 @@ export class FlowChatManager { this.sessionMatchesWorkspaceRow(session, workspacePath) ); const hasWorkspaceSessions = workspaceSessions.length > 0; - const focusedSessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const focusedSessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); const focusedSession = focusedSessionId ? state.sessions.get(focusedSessionId) ?? null : null; @@ -177,7 +179,7 @@ export class FlowChatManager { } this.context.flowChatStore.switchSession(latestSession.sessionId); - useWorkspaceSurfaceStore.getState().focusSession(latestSession.sessionId); + await openSession(latestSession.sessionId); } this.context.workspaceContextPath = workspacePath; @@ -202,11 +204,15 @@ export class FlowChatManager { if ( options?.createDefaultSession && - !options.skipAutoSelectSession && (!result.hasWorkspaceSessions || !result.hasFocusedWorkspaceSession) ) { const createdSessionId = await this.createChatSession( - options.defaultSessionConfig ?? {}, + { + ...(options.defaultSessionConfig ?? {}), + navigate: options.skipAutoSelectSession + ? false + : options.defaultSessionConfig?.navigate, + }, options.defaultSessionDescriptor ?? options.preferredDescriptor ); return { @@ -234,7 +240,7 @@ export class FlowChatManager { hasWorkspaceSessions: boolean ): FlowChatInitializationResult { const state = this.context.flowChatStore.getState(); - const focusedSessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const focusedSessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); const focusedSession = focusedSessionId ? state.sessions.get(focusedSessionId) ?? null : null; @@ -444,7 +450,12 @@ export class FlowChatManager { } async switchChatSession(sessionId: string): Promise { - return switchChatSessionModule(this.context, sessionId); + const { openSession: openSessionNav } = await import('@/app/navigation/navigationController'); + await openSessionNav(sessionId); + } + + async activateSessionData(sessionId: string): Promise { + return activateSessionDataModule(this.context, sessionId); } async persistSessionMetadata(sessionId: string): Promise { @@ -496,6 +507,21 @@ export class FlowChatManager { }); } + async retargetEmptySessionWorkspace( + sessionId: string, + workspace: Pick, + options?: { + preferredDescriptor?: SessionDescriptor; + } + ): Promise { + return retargetEmptyChatSessionWorkspaceModule( + this.context, + sessionId, + workspace, + options?.preferredDescriptor + ); + } + async sendMessage( message: string, sessionId?: string, @@ -515,8 +541,7 @@ export class FlowChatManager { const surfaceState = useWorkspaceSurfaceStore.getState(); const targetSessionId = sessionId || - surfaceState.composerTargetSessionId || - surfaceState.focusedSessionId; + selectFocusedSessionId(surfaceState); if (!targetSessionId) { throw new Error('No active session'); @@ -629,7 +654,7 @@ export class FlowChatManager { } getCurrentSession() { - const sessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const sessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); return sessionId ? this.context.flowChatStore.getState().sessions.get(sessionId) ?? null : null; diff --git a/src/web-ui/src/flow_chat/services/childSessionPanels.ts b/src/web-ui/src/flow_chat/services/childSessionPanels.ts index 391332ab..4ce92340 100644 --- a/src/web-ui/src/flow_chat/services/childSessionPanels.ts +++ b/src/web-ui/src/flow_chat/services/childSessionPanels.ts @@ -1,16 +1,12 @@ import { i18nService } from '@/infrastructure/i18n'; import { appManager } from '@/app/services/AppManager'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { openSession } from '@/app/navigation/navigationController'; import type { WorkspaceSurfaceContext } from '@/app/navigation/workspaceSurfaceTypes'; import { createTab } from '@/shared/utils/tabUtils'; import type { PanelContent } from '@/app/components/panels/base/types'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import type { CanvasTab } from '@/app/components/panels/content-canvas/types'; import { flowChatStore } from '../store/FlowChatStore'; -import { flowChatManager } from './FlowChatManager'; -import { syncSessionToModernStore } from './storeSync'; -import { isSystemAgenticOsSession } from '../domain/sessionDescriptor'; -import { systemRuntimeScope } from '@/shared/types/runtime-scope'; export const SIDE_THREAD_SESSION_PANEL_TYPE = 'btw-session' as const; export type ChildSessionPanelType = typeof SIDE_THREAD_SESSION_PANEL_TYPE; @@ -136,20 +132,7 @@ export async function openMainSession( await options.activateWorkspace(options.workspaceId); } - if (useWorkspaceSurfaceStore.getState().focusedSessionId === sessionId) { - syncSessionToModernStore(sessionId); - } else { - await flowChatManager.switchChatSession(sessionId); - syncSessionToModernStore(sessionId); - } - - const session = flowChatStore.getState().sessions.get(sessionId); - useWorkspaceSurfaceStore.getState().openSurface( - session && isSystemAgenticOsSession(session.descriptor) - ? { kind: 'agentic-os-home', agenticOsSessionId: sessionId, scope: systemRuntimeScope() } - : { kind: 'session', sessionId }, - { context: options?.context } - ); + await openSession(sessionId, { context: options?.context }); } export function openBtwSessionInAuxPane(params: { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 70e46847..de432afd 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -4,7 +4,7 @@ */ import { FlowChatStore } from '../../store/FlowChatStore'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; import { useSessionTurnQueueStore } from '../../store/sessionTurnQueueStore'; @@ -90,7 +90,7 @@ export function isAppWindowFocused(): boolean { } function shouldMarkUnreadCompletion(sessionId: string): boolean { - const focusedSessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const focusedSessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); return sessionId !== focusedSessionId || !isAppWindowFocused(); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index df1c3205..46659b62 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -29,7 +29,7 @@ import { getBackendAgentType } from '../../domain/sessionDescriptor'; import { canHydrateSession, isSessionHydrating } from '../../domain/sessionLoadPhase'; import { useSessionTurnQueueStore } from '../../store/sessionTurnQueueStore'; import type { Session } from '../../types/flow-chat'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; const log = createLogger('MessageModule'); @@ -476,8 +476,7 @@ export async function cancelTaskForSession( } export async function cancelCurrentTask(context: FlowChatContext): Promise { - const sessionId = useWorkspaceSurfaceStore.getState().composerTargetSessionId || - useWorkspaceSurfaceStore.getState().focusedSessionId; + const sessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); if (!sessionId) { log.debug('No focused session to cancel'); return false; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 3100c759..a0317c64 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -1,19 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowChatStore } from '../../store/FlowChatStore'; -import { getProductAppRuntimeSessionDescriptor } from '../../domain/sessionDescriptor'; +import { + getDefaultSessionDescriptor, + getProductAppRuntimeSessionDescriptor, +} from '../../domain/sessionDescriptor'; import type { FlowChatContext } from './types'; -import { createChatSession } from './SessionModule'; +import { createChatSession, retargetEmptyChatSessionWorkspace } from './SessionModule'; const agentApiMock = vi.hoisted(() => ({ createSession: vi.fn(), + ensureCoordinatorSession: vi.fn(), + updateSessionWorkspace: vi.fn(), })); -const openSurfaceMock = vi.hoisted(() => vi.fn()); +const sessionApiMock = vi.hoisted(() => ({ + deleteSession: vi.fn(), + loadSessionMetadata: vi.fn(), + saveSessionMetadata: vi.fn(), +})); + +const openSessionMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: agentApiMock, })); +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: sessionApiMock, +})); + vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { getConfig: vi.fn(async (key: string) => { @@ -31,12 +46,8 @@ vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ }, })); -vi.mock('@/app/navigation/workspaceSurfaceStore', () => ({ - useWorkspaceSurfaceStore: { - getState: vi.fn(() => ({ - openSurface: openSurfaceMock, - })), - }, +vi.mock('@/app/navigation/navigationController', () => ({ + openSession: openSessionMock, })); function createTestContext(store: FlowChatStore): FlowChatContext { @@ -45,6 +56,7 @@ function createTestContext(store: FlowChatStore): FlowChatContext { processingManager: { clearSessionStatus: vi.fn(), registerStatus: vi.fn(), + getSessionStatuses: vi.fn(() => []), } as unknown as FlowChatContext['processingManager'], eventBatcher: { getBufferSize: () => 0, @@ -70,12 +82,30 @@ describe('createChatSession workspace scope', () => { beforeEach(() => { agentApiMock.createSession.mockReset(); - openSurfaceMock.mockReset(); + agentApiMock.ensureCoordinatorSession.mockReset(); + agentApiMock.updateSessionWorkspace.mockReset(); + sessionApiMock.deleteSession.mockReset(); + sessionApiMock.loadSessionMetadata.mockReset(); + sessionApiMock.saveSessionMetadata.mockReset(); + openSessionMock.mockReset(); + vi.stubGlobal('window', { + dispatchEvent: vi.fn(), + }); + vi.stubGlobal('CustomEvent', class { + public readonly type: string; + public readonly detail: unknown; + + constructor(type: string, init?: CustomEventInit) { + this.type = type; + this.detail = init?.detail; + } + }); }); afterEach(() => { const store = FlowChatStore.getInstance(); sessionIds.splice(0).forEach(sessionId => store.removeSession(sessionId)); + vi.unstubAllGlobals(); }); it('keeps an explicit workspace path for agentic_os Product App runtime sessions', async () => { @@ -109,4 +139,58 @@ describe('createChatSession workspace scope', () => { ); expect(store.getState().sessions.get(sessionId)?.workspacePath).toBe(workspacePath); }); + + it('retargets the current empty workspace session without opening target workspace history', async () => { + const store = FlowChatStore.getInstance(); + const context = createTestContext(store); + const sessionId = `draft-session-${Date.now()}`; + const oldWorkspace = { id: 'old-workspace', rootPath: 'D:/workspace/old' }; + const newWorkspace = { id: 'new-workspace', rootPath: 'D:/workspace/new' }; + const descriptor = getDefaultSessionDescriptor(); + sessionIds.push(sessionId); + + store.createSession( + sessionId, + { + workspacePath: oldWorkspace.rootPath, + workspaceId: oldWorkspace.id, + storageScope: 'workspace', + }, + undefined, + 'Draft', + 128128, + descriptor, + oldWorkspace.rootPath, + 'workspace', + ); + + agentApiMock.updateSessionWorkspace.mockResolvedValue(undefined); + agentApiMock.ensureCoordinatorSession.mockResolvedValue(undefined); + sessionApiMock.loadSessionMetadata.mockResolvedValue(null); + + await retargetEmptyChatSessionWorkspace( + context, + sessionId, + newWorkspace, + descriptor, + ); + + const session = store.getState().sessions.get(sessionId); + expect(session?.sessionId).toBe(sessionId); + expect(session?.workspacePath).toBe(newWorkspace.rootPath); + expect(session?.workspaceId).toBe(newWorkspace.id); + expect(session?.config.workspacePath).toBe(newWorkspace.rootPath); + expect(session?.config.workspaceId).toBe(newWorkspace.id); + expect(agentApiMock.updateSessionWorkspace).toHaveBeenCalledWith({ + sessionId, + workspacePath: newWorkspace.rootPath, + }); + expect(agentApiMock.createSession).not.toHaveBeenCalled(); + expect(sessionApiMock.deleteSession).toHaveBeenCalledWith( + sessionId, + oldWorkspace.rootPath, + 'workspace', + ); + expect(openSessionMock).toHaveBeenCalledWith(sessionId); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 1420e348..427a5599 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -11,10 +11,10 @@ import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import type { WorkspaceInfo } from '@/shared/types'; import type { FlowChatContext, SessionConfig } from './types'; -import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; +import { touchSessionActivity, cleanupSaveState, updateSessionMetadata } from './PersistenceModule'; import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; -import { systemRuntimeScope } from '@/shared/types/runtime-scope'; import { resolveSessionTypeDefinitionForDescriptor } from '@/app/session-profiles'; +import { sessionMatchesWorkspace } from '../../utils/workspaceScope'; import { getBackendAgentType, getDefaultSessionDescriptor, @@ -242,18 +242,10 @@ export async function createChatSession( storageScope ); - const surfacePolicy = sessionType.lifecycle.defaultSurface; - if (surfacePolicy === 'agentic-os-home') { - useWorkspaceSurfaceStore.getState().openSurface({ - kind: 'agentic-os-home', - agenticOsSessionId: response.sessionId, - scope: systemRuntimeScope(), - }); - } else if (surfacePolicy === 'session') { - useWorkspaceSurfaceStore.getState().openSurface({ - kind: 'session', - sessionId: response.sessionId, - }); + const shouldNavigate = config.navigate !== false; + if (shouldNavigate) { + const { openSession: openSessionNav } = await import('@/app/navigation/navigationController'); + await openSessionNav(response.sessionId); } return response.sessionId; @@ -278,19 +270,16 @@ export async function createChatSession( } /** - * Switch to specified session + * Background session activation: touch activity and hydrate history. + * Does not change navigation surface — use navigationController.openSession for that. */ -export async function switchChatSession( +export async function activateSessionData( context: FlowChatContext, sessionId: string ): Promise { try { const session = context.flowChatStore.getState().sessions.get(sessionId); - // Switch UI immediately so the user sees the new session without waiting for history load. - context.flowChatStore.switchSession(sessionId); - useWorkspaceSurfaceStore.getState().focusSession(sessionId); - touchSessionActivity( sessionId, session?.workspacePath, @@ -300,18 +289,26 @@ export async function switchChatSession( }); if (canHydrateSession(session)) { - // Load history in the background — do not block the UI. void hydrateHistoricalSession(context, sessionId, true); } } catch (error) { - log.error('Failed to switch chat session', { sessionId, error }); - notificationService.error('Failed to switch session', { - duration: 3000 - }); + log.error('Failed to activate session data', { sessionId, error }); throw error; } } +/** + * @deprecated Use navigationController.openSession for UI switching. + * Kept for internal callers that only need data activation after navigation committed. + */ +export async function switchChatSession( + context: FlowChatContext, + sessionId: string +): Promise { + context.flowChatStore.switchSession(sessionId); + await activateSessionData(context, sessionId); +} + /** * Delete session (cascading delete Terminal) */ @@ -336,6 +333,92 @@ export async function deleteChatSession( } } +export async function retargetEmptyChatSessionWorkspace( + context: FlowChatContext, + sessionId: string, + workspace: Pick, + preferredDescriptor?: SessionDescriptor +): Promise { + const workspacePath = workspace.rootPath.trim(); + if (!workspacePath) { + throw new Error('Workspace path is required to retarget a chat session'); + } + + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + if (session.isTransient) { + throw new Error('Transient sessions cannot be retargeted'); + } + if (session.dialogTurns.length > 0) { + throw new Error('Only empty sessions can be retargeted to another workspace'); + } + if ( + canHydrateSession(session) || + session.loadPhase === 'hydrating' || + context.pendingHistoryLoads.has(sessionId) + ) { + throw new Error('Session history is still restoring, please retry once loading finishes'); + } + if (context.processingManager.getSessionStatuses(sessionId).length > 0) { + throw new Error('Session is busy and cannot be retargeted'); + } + + const descriptor = + preferredDescriptor?.storageScope === 'workspace' + ? preferredDescriptor + : session.descriptor.storageScope === 'workspace' + ? session.descriptor + : getDefaultSessionDescriptor(); + + if (session.storageScope === 'agentic_os' || descriptor.storageScope !== 'workspace') { + throw new Error('Only workspace-scoped sessions can be retargeted'); + } + + const previousWorkspacePath = session.workspacePath; + const previousStorageScope = session.storageScope; + const workspaceChanged = !sessionMatchesWorkspace(session, workspace); + + if (workspaceChanged) { + try { + await agentAPI.updateSessionWorkspace({ sessionId, workspacePath }); + } catch (error: any) { + const message = typeof error?.message === 'string' ? error.message : String(error); + if (!message.includes('Session not found') && !message.includes('Not found')) { + throw error; + } + } + } + + context.flowChatStore.retargetEmptySessionWorkspace( + sessionId, + workspace, + descriptor, + 'workspace' + ); + + if (workspaceChanged && previousWorkspacePath) { + try { + await sessionAPI.deleteSession(sessionId, previousWorkspacePath, previousStorageScope); + } catch (error) { + log.debug('Failed to delete empty session metadata from previous workspace', { + sessionId, + workspacePath: previousWorkspacePath, + error, + }); + } + } + + await ensureBackendSession(context, sessionId); + await updateSessionMetadata(context, sessionId); + + const { openSession: openSessionNav } = await import('@/app/navigation/navigationController'); + await openSessionNav(sessionId); + + return sessionId; +} + export async function renameChatSessionTitle( context: FlowChatContext, sessionId: string, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 0047b93b..da0f51ba 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -23,7 +23,7 @@ import type { ProgressToolEvent, StartedToolEvent, } from '../EventBatcher'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; const log = createLogger('ToolEventModule'); const pendingTerminalSessionIds = new Map(); @@ -770,7 +770,7 @@ function handleConfirmationNeeded( }, } as any); - const focusedSessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const focusedSessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); if (sessionId !== focusedSessionId) { const attentionKind = toolEvent.tool_name === 'AskUserQuestion' ? 'ask_user' : 'tool_confirm'; store.setSessionNeedsAttention(sessionId, attentionKind); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index 85ab2225..2fa8b7b3 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -39,7 +39,9 @@ export { getModelMaxTokens, createChatSession, switchChatSession, + activateSessionData, deleteChatSession, + retargetEmptyChatSessionWorkspace, renameChatSessionTitle, forkChatSession, } from './SessionModule'; diff --git a/src/web-ui/src/flow_chat/services/openAgenticOsSession.ts b/src/web-ui/src/flow_chat/services/openAgenticOsSession.ts index c2273a4a..44ad400c 100644 --- a/src/web-ui/src/flow_chat/services/openAgenticOsSession.ts +++ b/src/web-ui/src/flow_chat/services/openAgenticOsSession.ts @@ -1,9 +1,9 @@ -import { openWorkspaceHome } from '@/app/navigation/workspaceNavigation'; +import { openHome } from '@/app/navigation/navigationController'; /** * Focuses the latest Agentic OS session, or creates one if missing. * Mirrors the nav "Agentic OS" entry behavior. */ -export async function openAgenticOsSession(): Promise { - return openWorkspaceHome(); +export async function openAgenticOsSession(): Promise { + return openHome(); } diff --git a/src/web-ui/src/flow_chat/services/storeSync.ts b/src/web-ui/src/flow_chat/services/storeSync.ts index c579ceca..98d6e71b 100644 --- a/src/web-ui/src/flow_chat/services/storeSync.ts +++ b/src/web-ui/src/flow_chat/services/storeSync.ts @@ -7,7 +7,7 @@ import { flowChatStore } from '../store/FlowChatStore'; import { useModernFlowChatStore } from '../store/modernFlowChatStore'; import { createLogger } from '@/shared/utils/logger'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; import type { Session } from '../types/flow-chat'; const log = createLogger('StoreSync'); @@ -70,7 +70,7 @@ export function startAutoSync(): () => void { }; const getFocusedSnapshot = () => { - const sessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const sessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); const session = sessionId ? flowChatStore.getState().sessions.get(sessionId) ?? null : null; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 32ca58b1..9458ffd6 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -648,6 +648,66 @@ export class FlowChatStore { }); } + public retargetEmptySessionWorkspace( + sessionId: string, + workspace: Pick, + descriptor: SessionDescriptor, + storageScope: SessionStorageScope = 'workspace' + ): boolean { + const workspacePath = workspace.rootPath.trim(); + if (!workspacePath) return false; + + const normalizedDescriptor = normalizeSessionDescriptor(descriptor); + const backendAgentType = getBackendAgentType(normalizedDescriptor); + let didUpdate = false; + + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session || session.dialogTurns.length > 0 || session.isTransient) return prev; + + if ( + sameSessionDescriptor(session.descriptor, normalizedDescriptor) && + session.config.agentType === backendAgentType && + session.workspacePath === workspacePath && + session.workspaceId === workspace.id && + session.storageScope === storageScope && + session.config.workspacePath === workspacePath && + session.config.workspaceId === workspace.id && + session.config.storageScope === storageScope + ) { + return prev; + } + + const updatedSession: Session = { + ...session, + descriptor: normalizedDescriptor, + config: { + ...session.config, + agentType: backendAgentType, + workspacePath, + workspaceId: workspace.id, + storageScope, + }, + workspacePath, + workspaceId: workspace.id, + storageScope, + loadPhase: 'live', + lastActiveAt: Date.now(), + }; + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, updatedSession); + didUpdate = true; + + return { + ...prev, + sessions: newSessions, + }; + }); + + return didUpdate; + } + /** * Update the active inner agent for sessions that support agent switching. * @param sessionId Session ID diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 75dcaa2f..9b3577cd 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -14,7 +14,10 @@ import type { Session } from '../types/flow-chat'; import type { SessionDescriptor } from '../domain/sessionDescriptor'; import { flowChatStore } from './FlowChatStore'; import { useFlowChatStoreSelector } from '../hooks/useFlowChatStoreSelector'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { + selectFocusedSessionId, + useWorkspaceSurfaceStore, +} from '@/app/navigation/workspaceSurfaceStore'; import { clearProjectionScheduler, getProjectionVersion, @@ -86,7 +89,7 @@ function getInitialModernState(): Pick< 'activeSession' | 'virtualItems' | 'visibleTurnInfo' > { const legacyState = flowChatStore.getState(); - const focusedSessionId = useWorkspaceSurfaceStore.getState().focusedSessionId; + const focusedSessionId = selectFocusedSessionId(useWorkspaceSurfaceStore.getState()); const activeSession = focusedSessionId ? legacyState.sessions.get(focusedSessionId) ?? null : null; @@ -150,16 +153,14 @@ export const useActiveSessionMeta = () => export function useScopedSession(sessionId?: string | null): Session | null { const sessions = useFlowChatStoreSelector(state => state.sessions); - const focusedSessionId = useWorkspaceSurfaceStore(state => state.focusedSessionId); const requestedSessionId = sessionId?.trim() ?? ''; return useMemo(() => { - const targetSessionId = requestedSessionId || focusedSessionId || ''; - if (!targetSessionId) { + if (!requestedSessionId) { return null; } - return sessions.get(targetSessionId) ?? null; - }, [focusedSessionId, requestedSessionId, sessions]); + return sessions.get(requestedSessionId) ?? null; + }, [requestedSessionId, sessions]); } export const useVisibleTurnInfo = () => diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 6b8b348b..6a667944 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -407,6 +407,8 @@ export interface SessionConfig { creationDeduplicationKey?: string; /** Metadata persisted with the session, used by profile-owned app panels. */ customMetadata?: SessionCustomMetadata; + /** When false, createChatSession skips surface navigation (caller opens the session). */ + navigate?: boolean; } export interface QueuedMessage { diff --git a/src/web-ui/src/infrastructure/self-control/SelfControlService.test.ts b/src/web-ui/src/infrastructure/self-control/SelfControlService.test.ts index 0c5f8a95..ecfeb8ec 100644 --- a/src/web-ui/src/infrastructure/self-control/SelfControlService.test.ts +++ b/src/web-ui/src/infrastructure/self-control/SelfControlService.test.ts @@ -33,9 +33,9 @@ vi.mock('@/app/navigation/workspaceSurfaceStore', () => ({ getState: () => ({ activeSurface: { kind: 'agentic-os-home', - agenticOsSessionId: null, scope: { kind: 'system', storageScope: 'agentic_os', label: 'Agentic OS' }, }, + currentOsSessionId: null, }), }, })); diff --git a/src/web-ui/src/locales/en-US/flow-chat/welcome.json b/src/web-ui/src/locales/en-US/flow-chat/welcome.json index 47422a64..9c320aee 100644 --- a/src/web-ui/src/locales/en-US/flow-chat/welcome.json +++ b/src/web-ui/src/locales/en-US/flow-chat/welcome.json @@ -82,6 +82,9 @@ "workspace": "Workspace", "noWorkspace": "No workspace open", "openOtherProject": "Open other project...", + "workspaceSearchPlaceholder": "Search workspaces", + "clearWorkspaceSearch": "Clear workspace search", + "workspaceSearchEmpty": "No matching workspaces", "noWorkspaceHint": "No project selected yet", "openOne": "Open one", "toStart": "to get started.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat/welcome.json b/src/web-ui/src/locales/zh-CN/flow-chat/welcome.json index e2401f7a..4dcb45cb 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat/welcome.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat/welcome.json @@ -82,6 +82,9 @@ "workspace": "工作区", "noWorkspace": "未打开工作区", "openOtherProject": "打开其他项目...", + "workspaceSearchPlaceholder": "搜索工作区", + "clearWorkspaceSearch": "清空工作区搜索", + "workspaceSearchEmpty": "没有匹配的工作区", "noWorkspaceHint": "还没有选择项目", "openOne": "打开一个", "toStart": "开始吧。", diff --git a/src/web-ui/src/shared/services/sceneOpenTargetResolver.test.ts b/src/web-ui/src/shared/services/sceneOpenTargetResolver.test.ts index de21e7cb..60d7db17 100644 --- a/src/web-ui/src/shared/services/sceneOpenTargetResolver.test.ts +++ b/src/web-ui/src/shared/services/sceneOpenTargetResolver.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; -import type { WorkspaceSurface } from '@/app/navigation/workspaceSurfaceTypes'; +import { createAgenticOsHomeSurface } from '@/app/navigation/workspaceSurfaceTypes'; import { externalRuntimeScope, projectRuntimeScopeFromWorkspacePath, @@ -14,21 +14,16 @@ vi.mock('@/app/navigation/workspaceNavigation', () => ({ openWorkspaceScene: openWorkspaceSceneMock, })); -const homeSurface: WorkspaceSurface = { - kind: 'agentic-os-home', - agenticOsSessionId: null, - scope: systemRuntimeScope(), -}; +const homeSurface = createAgenticOsHomeSurface(); function resetStore() { openWorkspaceSceneMock.mockReset(); useWorkspaceSurfaceStore.setState({ activeSurface: homeSurface, previousSurface: null, + currentOsSessionId: null, sceneHistory: [], surfaceContext: null, - focusedSessionId: null, - composerTargetSessionId: null, }); } diff --git a/src/web-ui/src/tools/generative-widget/widgetInteraction.ts b/src/web-ui/src/tools/generative-widget/widgetInteraction.ts index 8c0793fe..7852dc4b 100644 --- a/src/web-ui/src/tools/generative-widget/widgetInteraction.ts +++ b/src/web-ui/src/tools/generative-widget/widgetInteraction.ts @@ -5,7 +5,7 @@ import { createLogger } from '@/shared/utils/logger'; import path from 'path-browserify'; import { fileTabManager } from '@/shared/services/FileTabManager'; import { notificationService } from '@/shared/notification-system'; -import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; +import { useWorkspaceSurfaceStore, selectFocusedSessionId } from '@/app/navigation/workspaceSurfaceStore'; const log = createLogger('widgetInteraction'); @@ -41,7 +41,7 @@ export interface WidgetInteractionDetail { function getActiveSessionId(): string | null { const surfaceState = useWorkspaceSurfaceStore.getState(); - return surfaceState.composerTargetSessionId ?? surfaceState.focusedSessionId; + return selectFocusedSessionId(surfaceState); } function getActiveWorkspacePath(): string | undefined {