From 58326ee75a867ea2a0d786bf8873be0762a8b520 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 15:34:46 +0800 Subject: [PATCH 1/3] fix(ui): smooth transitions and align settings layout --- .../src/app/components/NavPanel/NavPanel.scss | 9 - .../src/app/components/NavPanel/NavPanel.tsx | 36 ++- src/web-ui/src/app/scenes/SceneViewport.scss | 18 +- src/web-ui/src/app/scenes/SceneViewport.tsx | 122 +++++++- src/web-ui/src/app/scenes/nav-registry.ts | 20 +- .../src/app/scenes/settings/SettingsNav.tsx | 33 +- .../app/scenes/settings/SettingsScene.scss | 56 +++- .../scenes/settings/SettingsScene.test.tsx | 28 ++ .../src/app/scenes/settings/SettingsScene.tsx | 149 +++++---- .../components/KeyboardShortcutsTab.scss | 9 - .../settings/settingsContentRegistry.ts | 60 ++++ src/web-ui/src/app/stores/sceneStore.test.ts | 26 ++ src/web-ui/src/app/stores/sceneStore.ts | 66 ++-- src/web-ui/src/app/styles/index.scss | 2 +- src/web-ui/src/app/styles/motion.scss | 283 ++++++++++++++++++ .../components/Modal/Modal.scss | 41 ++- .../components/Modal/Modal.test.tsx | 89 ++++++ .../components/Modal/Modal.tsx | 37 ++- .../components/ExternalSourcesConfig.test.tsx | 27 ++ .../components/ExternalSourcesConfig.tsx | 9 +- .../config/components/ReviewConfig.test.tsx | 23 ++ .../config/components/ReviewConfig.tsx | 5 +- .../components/common/ConfigPageHeader.scss | 20 +- .../components/common/ConfigPageLayout.scss | 22 +- 24 files changed, 1031 insertions(+), 159 deletions(-) create mode 100644 src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts create mode 100644 src/web-ui/src/app/stores/sceneStore.test.ts create mode 100644 src/web-ui/src/app/styles/motion.scss create mode 100644 src/web-ui/src/component-library/components/Modal/Modal.test.tsx diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index cbfafff36f..4070f712ec 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -11,12 +11,6 @@ @use '../../../component-library/styles/btn-primary-tokens.scss' as btn-primary; $_nav-width: 240px; -// Scene-inner fade: used when switching between different SceneNav components -// while already in scene-nav mode (e.g. file-viewer → settings). -@keyframes bitfun-nav-panel-scene-inner-in { - from { opacity: 0; } - to { opacity: 1; } -} $_item-height: 32px; $_section-header-height: 24px; @@ -80,11 +74,8 @@ $_section-header-height: 24px; // ── SceneNav overlay ── &--scene { clip-path: inset(var(--clip-origin-top) 0 var(--clip-origin-bottom) 0); - // Wrapper re-keyed on sceneId change → plays the fade-in when the - // SceneNav content is swapped while the accordion row stays expanded. transition: clip-path $motion-base $easing-decelerate, opacity $motion-fast $easing-decelerate; - animation: bitfun-nav-panel-scene-inner-in $motion-fast $easing-decelerate; position: absolute; inset: 0; z-index: 1; diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.tsx b/src/web-ui/src/app/components/NavPanel/NavPanel.tsx index 20396292a9..a8a8e89b78 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.tsx @@ -14,10 +14,17 @@ * MainNav is always mounted so its state is preserved across transitions. */ -import React, { Suspense, useState, useEffect, useRef, useCallback } from 'react'; +import React, { + Suspense, + startTransition, + useState, + useEffect, + useRef, + useCallback, +} from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { useNavSceneStore } from '../../stores/navSceneStore'; -import { getSceneNav } from '../../scenes/nav-registry'; +import { getSceneNav, preloadSceneNav } from '../../scenes/nav-registry'; import type { SceneTabId } from '../SceneBar/types'; import MainNav from './MainNav'; import PersistentFooterActions from './components/PersistentFooterActions'; @@ -40,13 +47,28 @@ const NavPanel: React.FC = ({ className = '' }) => { const navSceneId = useNavSceneStore(s => s.navSceneId); const [mountedSceneId, setMountedSceneId] = useState(navSceneId); + const sceneRequestRef = useRef(0); useEffect(() => { - if (navSceneId) setMountedSceneId(navSceneId); + const requestId = ++sceneRequestRef.current; + if (!navSceneId) return; + + const commit = () => { + if (sceneRequestRef.current !== requestId) return; + // React keeps the currently painted navigation visible if the cached + // lazy component still suspends for a final promise microtask. + startTransition(() => setMountedSceneId(navSceneId)); + }; + void preloadSceneNav(navSceneId).then(commit, commit); }, [navSceneId]); const SceneNavComponent = mountedSceneId ? getSceneNav(mountedSceneId) : null; - const useSplitOpen = !!(showSceneNav && mountedSceneId && SPLIT_OPEN_SCENES.has(mountedSceneId)); + const hasMountedSceneNav = showSceneNav && mountedSceneId !== null; + const useSplitOpen = !!( + hasMountedSceneNav + && mountedSceneId + && SPLIT_OPEN_SCENES.has(mountedSceneId) + ); const contentRef = useRef(null); @@ -72,13 +94,13 @@ const NavPanel: React.FC = ({ className = '' }) => { const contentCls = [ 'bitfun-nav-panel__content', - showSceneNav && 'is-scene', + hasMountedSceneNav && 'is-scene', useSplitOpen && 'is-split-open', ].filter(Boolean).join(' '); const sceneCls = [ 'bitfun-nav-panel__layer bitfun-nav-panel__layer--scene', - showSceneNav && 'is-active', + hasMountedSceneNav && 'is-active', ].filter(Boolean).join(' '); return ( @@ -95,7 +117,7 @@ const NavPanel: React.FC = ({ className = '' }) => { {SceneNavComponent && (
-
+
diff --git a/src/web-ui/src/app/scenes/SceneViewport.scss b/src/web-ui/src/app/scenes/SceneViewport.scss index a9ed485f6e..fbaa4e1b24 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.scss +++ b/src/web-ui/src/app/scenes/SceneViewport.scss @@ -106,7 +106,8 @@ display: none; overflow: hidden; - &--active { + &--active, + &--outgoing { display: flex; flex-direction: row; min-width: 0; @@ -120,6 +121,15 @@ height: 100%; } } + + &--active { + z-index: 1; + } + + &--outgoing { + z-index: 2; + pointer-events: none; + } } } @@ -127,4 +137,10 @@ .bitfun-scene-viewport__clip::after { transition: none; } + + // Do not keep the previous scene painted during the exit grace period when + // the user has asked for motion to be reduced. + .bitfun-scene-viewport__scene--exiting { + display: none; + } } diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index d2292e270c..34bc98a2a0 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -8,7 +8,15 @@ * scene is explicitly opened. */ -import React, { Suspense, lazy } from 'react'; +import React, { + Suspense, + lazy, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import type { SceneTabId } from '../components/SceneBar/types'; import { useSceneManager } from '../hooks/useSceneManager'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; @@ -36,6 +44,37 @@ const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); const MiniAppScene = lazy(() => import('./miniapps/MiniAppScene')); const PanelViewScene = lazy(() => import('./panel-view/PanelViewScene')); +// Keep in sync with bitfun-motion-view-exit in app/styles/motion.scss. +const SCENE_EXIT_DURATION_MS = 140; + +interface SceneTransition { + outgoingTabId: SceneTabId; + incomingTabId: SceneTabId; + phase: 'holding' | 'exiting'; +} + +interface SceneReadyBoundaryProps { + sceneId: SceneTabId; + onReady: (sceneId: SceneTabId) => void; + children: React.ReactNode; +} + +/** + * This effect commits only after a lazy scene has resolved through Suspense. + * It lets the viewport hold the outgoing pixels until the incoming tree is + * actually paintable instead of exposing a fallback between the two scenes. + */ +const SceneReadyBoundary: React.FC = ({ + sceneId, + onReady, + children, +}) => { + useLayoutEffect(() => { + onReady(sceneId); + }, [onReady, sceneId]); + + return <>{children}; +}; interface SceneViewportProps { workspacePath?: string; @@ -45,8 +84,72 @@ interface SceneViewportProps { const SceneViewport: React.FC = ({ workspacePath, isEntering = false }) => { const { openTabs, activeTabId } = useSceneManager(); const { t } = useI18n('common'); + const [transition, setTransition] = useState(null); + const [readyVersion, setReadyVersion] = useState(0); + const readySceneIdsRef = useRef>(new Set()); + const previousActiveTabIdRef = useRef(activeTabId); useDialogCompletionNotify(); + const markSceneReady = useCallback((sceneId: SceneTabId) => { + if (readySceneIdsRef.current.has(sceneId)) return; + readySceneIdsRef.current.add(sceneId); + setReadyVersion(version => version + 1); + }, []); + + // Derive the outgoing id during render as well as from state. This keeps a + // just-closed active tab (notably the welcome tab) in the keyed React tree + // for its exit frame instead of unmounting and remounting it after layout. + const outgoingTabId = previousActiveTabIdRef.current !== activeTabId + ? previousActiveTabIdRef.current + : transition?.outgoingTabId ?? null; + const renderedTabIds = openTabs.map(tab => tab.id); + if (outgoingTabId && !renderedTabIds.includes(outgoingTabId)) { + renderedTabIds.push(outgoingTabId); + } + + useLayoutEffect(() => { + const previousActiveTabId = previousActiveTabIdRef.current; + previousActiveTabIdRef.current = activeTabId; + + if (!previousActiveTabId || previousActiveTabId === activeTabId) { + return; + } + + setTransition({ + outgoingTabId: previousActiveTabId, + incomingTabId: activeTabId, + phase: readySceneIdsRef.current.has(activeTabId) ? 'exiting' : 'holding', + }); + }, [activeTabId]); + + useLayoutEffect(() => { + if ( + transition?.phase !== 'holding' + || !readySceneIdsRef.current.has(transition.incomingTabId) + ) { + return; + } + + setTransition(current => ( + current?.incomingTabId === transition.incomingTabId + ? { ...current, phase: 'exiting' } + : current + )); + }, [readyVersion, transition]); + + useEffect(() => { + if (transition?.phase !== 'exiting') return; + + const completedTransition = transition; + const exitTimer = window.setTimeout(() => { + setTransition(current => ( + current === completedTransition ? null : current + )); + }, SCENE_EXIT_DURATION_MS); + + return () => window.clearTimeout(exitTimer); + }, [transition]); + // All tabs closed — show empty state if (openTabs.length === 0) { return ( @@ -64,19 +167,24 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering return (
- {openTabs.map(tab => { - const isActive = tab.id === activeTabId; + {renderedTabIds.map(tabId => { + const isActive = tabId === activeTabId; + const isOutgoing = !isActive && tabId === outgoingTabId; + const isExiting = isOutgoing && transition?.phase === 'exiting'; return (
= ({ workspacePath, isEntering ) : null } > - {renderScene(tab.id, workspacePath, isEntering, isActive)} + + {renderScene(tabId, workspacePath, isEntering, isActive)} +
); diff --git a/src/web-ui/src/app/scenes/nav-registry.ts b/src/web-ui/src/app/scenes/nav-registry.ts index 0d27e28456..0ac6975b03 100644 --- a/src/web-ui/src/app/scenes/nav-registry.ts +++ b/src/web-ui/src/app/scenes/nav-registry.ts @@ -14,13 +14,23 @@ import type { SceneTabId } from '../components/SceneBar/types'; type LazyNavComponent = ReturnType>; +const loadSettingsNav = () => import('./settings/SettingsNav'); +const loadFileViewerNav = () => import('./file-viewer/FileViewerNav'); +const loadShellNav = () => import('./shell/ShellNav'); + const SCENE_NAV_REGISTRY: Partial> = { - settings: lazy(() => import('./settings/SettingsNav')), - 'file-viewer': lazy(() => import('./file-viewer/FileViewerNav')), - shell: lazy(() => import('./shell/ShellNav')), + settings: lazy(loadSettingsNav), + 'file-viewer': lazy(loadFileViewerNav), + shell: lazy(loadShellNav), // terminal: lazy(() => import('./terminal/TerminalNav')), }; +const SCENE_NAV_LOADERS: Partial Promise>> = { + settings: loadSettingsNav, + 'file-viewer': loadFileViewerNav, + shell: loadShellNav, +}; + /** * Returns the lazy nav component registered for the given scene, * or `null` if the scene uses the default MainNav. @@ -28,3 +38,7 @@ const SCENE_NAV_REGISTRY: Partial> = { export function getSceneNav(sceneId: SceneTabId): LazyNavComponent | null { return SCENE_NAV_REGISTRY[sceneId] ?? null; } + +export async function preloadSceneNav(sceneId: SceneTabId): Promise { + await SCENE_NAV_LOADERS[sceneId]?.(); +} diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index d9be9216c1..2ed2835a2c 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -25,6 +25,7 @@ import { useSettingsStore } from './settingsStore'; import { SETTINGS_CATEGORIES } from './settingsConfig'; import type { ConfigTab } from './settingsConfig'; import { SETTINGS_TAB_SEARCH_CONTENT } from './settingsTabSearchContent'; +import { preloadSettingsTabContent } from './settingsContentRegistry'; import './SettingsNav.scss'; const SEARCH_DEBOUNCE_MS = 150; @@ -146,6 +147,7 @@ function useSettingsNav() { const searchInputRef = useRef(null); const [highlightedIndex, setHighlightedIndex] = useState(-1); const resultsRef = useRef(null); + const activationRequestRef = useRef(0); useEffect(() => { const id = window.setTimeout(() => { @@ -180,19 +182,34 @@ function useSettingsNav() { const activateTab = useCallback( (tab: ConfigTab) => { - setActiveTab(tab); - clearSearch(); + const requestId = ++activationRequestRef.current; + const commit = () => { + if (activationRequestRef.current !== requestId) return; + setActiveTab(tab); + clearSearch(); + }; + void preloadSettingsTabContent(tab).then(commit, commit); }, [setActiveTab, clearSearch] ); const handleTabClick = useCallback( (tab: ConfigTab) => { - setActiveTab(tab); + const requestId = ++activationRequestRef.current; + const commit = () => { + if (activationRequestRef.current === requestId) { + setActiveTab(tab); + } + }; + void preloadSettingsTabContent(tab).then(commit, commit); }, [setActiveTab] ); + const preloadTab = useCallback((tab: ConfigTab) => { + void preloadSettingsTabContent(tab).catch(() => {}); + }, []); + const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -253,6 +270,7 @@ function useSettingsNav() { t, activeTab, handleTabClick, + preloadTab, draftQuery, setDraftQuery, searchInputRef, @@ -274,6 +292,7 @@ const SettingsNav: React.FC = () => { t, activeTab, handleTabClick, + preloadTab, draftQuery, setDraftQuery, searchInputRef, @@ -355,7 +374,11 @@ const SettingsNav: React.FC = () => { .filter(Boolean) .join(' ')} onClick={() => activateTab(row.tabId)} - onMouseEnter={() => setHighlightedIndex(index)} + onMouseEnter={() => { + setHighlightedIndex(index); + preloadTab(row.tabId); + }} + onFocus={() => preloadTab(row.tabId)} > {highlightFirstMatch(line, displayQuery)} @@ -394,6 +417,8 @@ const SettingsNav: React.FC = () => { .filter(Boolean) .join(' ')} onClick={() => handleTabClick(tabDef.id)} + onPointerEnter={() => preloadTab(tabDef.id)} + onFocus={() => preloadTab(tabDef.id)} > {t(tabDef.labelKey, { defaultValue: tabDef.id })} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.scss b/src/web-ui/src/app/scenes/settings/SettingsScene.scss index edfe350d89..9a50a9fa36 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.scss +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.scss @@ -4,12 +4,23 @@ @keyframes settings-content-enter { from { - opacity: 0; - transform: translateY(6px); + transform: translate3d(0, 4px, 0); } + to { + transform: translate3d(0, 0, 0); + } +} + +@keyframes settings-content-exit { + from { opacity: 1; - transform: translateY(0); + transform: translate3d(0, 0, 0); + } + + to { + opacity: 0; + transform: translate3d(0, -2px, 0); } } @@ -50,17 +61,50 @@ margin-top: var(--size-gap-6); } - &__content-wrapper { + &__content-stack { flex: 1; + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + &__content-wrapper { + position: absolute; + inset: 0; display: flex; flex-direction: column; + min-width: 0; + min-height: 0; overflow: hidden; - animation: settings-content-enter 220ms cubic-bezier(0.25, 1, 0.5, 1) both; + + &--active { + z-index: 1; + } + + &--entering { + animation: settings-content-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: transform; + } + + &--outgoing { + z-index: 2; + pointer-events: none; + animation: settings-content-exit 180ms cubic-bezier(0.4, 0, 1, 1) both; + will-change: transform, opacity; + } } } @media (prefers-reduced-motion: reduce) { .bitfun-settings-scene__content-wrapper { - animation: none; + &--entering { + animation: none; + } + + &--outgoing { + display: none; + animation: none; + } } } diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx index 4780337149..8dd686464b 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx @@ -71,6 +71,7 @@ describe('SettingsScene lazy tab routing', () => { root.unmount(); }); container.remove(); + vi.useRealTimers(); }); async function renderActiveTab(tab: 'mcp-tools' | 'acp-agents' | 'external-sources') { @@ -97,4 +98,31 @@ describe('SettingsScene lazy tab routing', () => { expect(container.querySelector('[data-testid="external-sources-config"]')).not.toBeNull(); }); + + it('keeps the previous settings page mounted through the local transition', async () => { + vi.useFakeTimers(); + await act(async () => { + root.render(); + }); + + await act(async () => { + useSettingsStore.setState({ activeTab: 'appearance' }); + await Promise.resolve(); + }); + + const activePanel = container.querySelector('[data-settings-panel-active="true"]'); + const outgoingPanel = container.querySelector('.bitfun-settings-scene__content-wrapper--outgoing'); + expect(activePanel?.getAttribute('data-settings-panel')).toBe('appearance'); + expect(outgoingPanel?.getAttribute('data-settings-panel')).toBe('basics'); + + act(() => { + vi.advanceTimersByTime(179); + }); + expect(container.querySelector('[data-settings-panel="basics"]')).not.toBeNull(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(container.querySelector('[data-settings-panel="basics"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 5baf451f15..ba24571942 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -6,32 +6,35 @@ * driven by settingsStore.activeTab. */ -import React, { lazy, Suspense, useEffect } from 'react'; +import React, { + Suspense, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { useSettingsStore } from './settingsStore'; +import type { ConfigTab } from './settingsConfig'; +import { + AcpAgentsConfig, + AIModelConfig, + AppearanceConfig, + ArchivedSessionsConfig, + BasicsConfig, + EditorConfig, + ExternalSourcesConfig, + KeyboardShortcutsTab, + McpToolsConfig, + MemoriesConfig, + QuickActionsConfig, + ReviewConfig, + SessionPermissionsConfig, + SessionPersonalizationConfig, +} from './settingsContentRegistry'; import './SettingsScene.scss'; -const AIModelConfig = lazy(() => import('../../../infrastructure/config/components/AIModelConfig')); -const McpToolsConfig = lazy(() => import('../../../infrastructure/config/components/McpToolsConfig')); -const AcpAgentsConfig = lazy(() => import('../../../infrastructure/config/components/AcpAgentsConfig')); -const ExternalSourcesConfig = lazy(() => import('../../../infrastructure/config/components/ExternalSourcesConfig')); -const EditorConfig = lazy(() => import('../../../infrastructure/config/components/EditorConfig')); -const BasicsConfig = lazy(() => import('../../../infrastructure/config/components/BasicsConfig')); -const AppearanceConfig = lazy(() => import('../../../infrastructure/config/components/AppearanceConfig')); -const ReviewConfig = lazy(() => import('../../../infrastructure/config/components/ReviewConfig')); -const MemoriesConfig = lazy(() => import('../../../infrastructure/config/components/MemoriesConfig')); -const QuickActionsConfig = lazy(() => import('../../../infrastructure/config/components/QuickActionsConfig')); -const ArchivedSessionsConfig = lazy(() => import('./components/ArchivedSessionsConfig')); -const KeyboardShortcutsTab = lazy(() => import('./components/KeyboardShortcutsTab')); -const SessionPersonalizationConfig = lazy(() => - import('../../../infrastructure/config/components/SessionConfig').then((module) => ({ - default: module.SessionPersonalizationConfig, - })) -); -const SessionPermissionsConfig = lazy(() => - import('../../../infrastructure/config/components/SessionConfig').then((module) => ({ - default: module.SessionPermissionsConfig, - })) -); +// Keep in sync with settings-content-exit in SettingsScene.scss. +const SETTINGS_CONTENT_EXIT_DURATION_MS = 180; function SettingsSceneLoading() { return ( @@ -44,12 +47,34 @@ function SettingsSceneLoading() { ); } +function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { + switch (tab) { + case 'basics': return BasicsConfig; + case 'appearance': return AppearanceConfig; + case 'models': return AIModelConfig; + case 'archived-sessions': return ArchivedSessionsConfig; + case 'session-personalization': return SessionPersonalizationConfig; + case 'session-permissions': return SessionPermissionsConfig; + case 'quick-actions': return QuickActionsConfig; + case 'review': return ReviewConfig; + case 'memories': return MemoriesConfig; + case 'mcp-tools': return McpToolsConfig; + case 'external-sources': return ExternalSourcesConfig; + case 'acp-agents': return AcpAgentsConfig; + case 'editor': return EditorConfig; + case 'keyboard': return KeyboardShortcutsTab; + default: return null; + } +} + const SettingsScene: React.FC = () => { const activeTab = useSettingsStore(s => s.activeTab); const setActiveTab = useSettingsStore(s => s.setActiveTab); - const resolvedTab: typeof activeTab = + const resolvedTab: ConfigTab = (activeTab as string) === 'session-config' ? 'session-personalization' : activeTab; + const [outgoingTab, setOutgoingTab] = useState(null); + const previousTabRef = useRef(resolvedTab); useEffect(() => { /** Legacy merged session settings tab removed in favor of two panels. */ @@ -58,38 +83,60 @@ const SettingsScene: React.FC = () => { } }, [activeTab, setActiveTab]); - let Content: React.ComponentType | null = null; + // Derive the previous tab during render so React keeps its keyed subtree + // mounted in the same commit that introduces the incoming page. + const renderedOutgoingTab = previousTabRef.current !== resolvedTab + ? previousTabRef.current + : outgoingTab; + + useLayoutEffect(() => { + const previousTab = previousTabRef.current; + previousTabRef.current = resolvedTab; + if (previousTab === resolvedTab) return; - switch (resolvedTab) { - case 'basics': Content = BasicsConfig; break; - case 'appearance': Content = AppearanceConfig; break; - case 'models': Content = AIModelConfig; break; - case 'archived-sessions': Content = ArchivedSessionsConfig; break; - case 'session-personalization': Content = SessionPersonalizationConfig; break; - case 'session-permissions': Content = SessionPermissionsConfig; break; - case 'quick-actions': Content = QuickActionsConfig; break; - case 'review': Content = ReviewConfig; break; - case 'memories': Content = MemoriesConfig; break; - case 'mcp-tools': Content = McpToolsConfig; break; - case 'external-sources': Content = ExternalSourcesConfig; break; - case 'acp-agents': Content = AcpAgentsConfig; break; - case 'editor': Content = EditorConfig; break; - case 'keyboard': Content = KeyboardShortcutsTab; break; + setOutgoingTab(previousTab); + const exitTimer = window.setTimeout(() => { + setOutgoingTab(current => current === previousTab ? null : current); + }, SETTINGS_CONTENT_EXIT_DURATION_MS); + + return () => window.clearTimeout(exitTimer); + }, [resolvedTab]); + + const renderedTabs: ConfigTab[] = [resolvedTab]; + if (renderedOutgoingTab && renderedOutgoingTab !== resolvedTab) { + renderedTabs.push(renderedOutgoingTab); } return (
- {Content && ( -
- }> - - -
- )} +
+ {renderedTabs.map(tab => { + const Content = resolveSettingsContent(tab); + if (!Content) return null; + + const isActive = tab === resolvedTab; + const isOutgoing = !isActive && tab === renderedOutgoingTab; + return ( +
+ : null}> + + +
+ ); + })} +
); }; diff --git a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss index f8f9a23459..eadcb83989 100644 --- a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss +++ b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.scss @@ -207,12 +207,3 @@ 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } - -/* Page subtitle: keep primary title dominant */ -.kb-shortcuts-page-header.bitfun-config-page-header { - .bitfun-config-page-header__subtitle { - font-size: var(--font-size-xs); - color: var(--color-text-muted); - opacity: 0.88; - } -} diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts new file mode 100644 index 0000000000..b6648371bb --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -0,0 +1,60 @@ +import { lazy } from 'react'; +import type { ConfigTab } from './settingsConfig'; + +const loadAIModelConfig = () => import('../../../infrastructure/config/components/AIModelConfig'); +const loadMcpToolsConfig = () => import('../../../infrastructure/config/components/McpToolsConfig'); +const loadAcpAgentsConfig = () => import('../../../infrastructure/config/components/AcpAgentsConfig'); +const loadExternalSourcesConfig = () => import('../../../infrastructure/config/components/ExternalSourcesConfig'); +const loadEditorConfig = () => import('../../../infrastructure/config/components/EditorConfig'); +const loadBasicsConfig = () => import('../../../infrastructure/config/components/BasicsConfig'); +const loadAppearanceConfig = () => import('../../../infrastructure/config/components/AppearanceConfig'); +const loadReviewConfig = () => import('../../../infrastructure/config/components/ReviewConfig'); +const loadMemoriesConfig = () => import('../../../infrastructure/config/components/MemoriesConfig'); +const loadQuickActionsConfig = () => import('../../../infrastructure/config/components/QuickActionsConfig'); +const loadArchivedSessionsConfig = () => import('./components/ArchivedSessionsConfig'); +const loadKeyboardShortcutsTab = () => import('./components/KeyboardShortcutsTab'); +const loadSessionConfig = () => import('../../../infrastructure/config/components/SessionConfig'); + +export const AIModelConfig = lazy(loadAIModelConfig); +export const McpToolsConfig = lazy(loadMcpToolsConfig); +export const AcpAgentsConfig = lazy(loadAcpAgentsConfig); +export const ExternalSourcesConfig = lazy(loadExternalSourcesConfig); +export const EditorConfig = lazy(loadEditorConfig); +export const BasicsConfig = lazy(loadBasicsConfig); +export const AppearanceConfig = lazy(loadAppearanceConfig); +export const ReviewConfig = lazy(loadReviewConfig); +export const MemoriesConfig = lazy(loadMemoriesConfig); +export const QuickActionsConfig = lazy(loadQuickActionsConfig); +export const ArchivedSessionsConfig = lazy(loadArchivedSessionsConfig); +export const KeyboardShortcutsTab = lazy(loadKeyboardShortcutsTab); +export const SessionPersonalizationConfig = lazy(() => + loadSessionConfig().then((module) => ({ + default: module.SessionPersonalizationConfig, + })) +); +export const SessionPermissionsConfig = lazy(() => + loadSessionConfig().then((module) => ({ + default: module.SessionPermissionsConfig, + })) +); + +const SETTINGS_CONTENT_LOADERS: Partial Promise>> = { + basics: loadBasicsConfig, + appearance: loadAppearanceConfig, + models: loadAIModelConfig, + 'archived-sessions': loadArchivedSessionsConfig, + 'session-personalization': loadSessionConfig, + 'session-permissions': loadSessionConfig, + 'quick-actions': loadQuickActionsConfig, + review: loadReviewConfig, + memories: loadMemoriesConfig, + 'mcp-tools': loadMcpToolsConfig, + 'external-sources': loadExternalSourcesConfig, + 'acp-agents': loadAcpAgentsConfig, + editor: loadEditorConfig, + keyboard: loadKeyboardShortcutsTab, +}; + +export async function preloadSettingsTabContent(tab: ConfigTab): Promise { + await SETTINGS_CONTENT_LOADERS[tab]?.(); +} diff --git a/src/web-ui/src/app/stores/sceneStore.test.ts b/src/web-ui/src/app/stores/sceneStore.test.ts new file mode 100644 index 0000000000..2723c551d8 --- /dev/null +++ b/src/web-ui/src/app/stores/sceneStore.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useSceneStore } from './sceneStore'; + +describe('sceneStore transition snapshots', () => { + beforeEach(() => { + useSceneStore.getState().resetForPeerSwitch(); + }); + + it('publishes the first scene switch atomically without a blank active scene', () => { + const snapshots: Array<{ activeTabId: string; openTabIds: string[] }> = []; + const unsubscribe = useSceneStore.subscribe(state => { + snapshots.push({ + activeTabId: state.activeTabId, + openTabIds: state.openTabs.map(tab => tab.id), + }); + }); + + useSceneStore.getState().openScene('settings'); + unsubscribe(); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0].activeTabId).toBe('settings'); + expect(snapshots[0].openTabIds).toContain('settings'); + expect(snapshots[0].openTabIds).not.toContain('welcome'); + }); +}); diff --git a/src/web-ui/src/app/stores/sceneStore.ts b/src/web-ui/src/app/stores/sceneStore.ts index f382794a5f..6453048126 100644 --- a/src/web-ui/src/app/stores/sceneStore.ts +++ b/src/web-ui/src/app/stores/sceneStore.ts @@ -131,28 +131,8 @@ export const useSceneStore = create((set, get) => ({ navCursor: 0, openScene: (id) => { - // Auto-close welcome tab when any other scene is explicitly opened - if (id !== WELCOME_SCENE_ID) { - const state = get(); - if (state.openTabs.some(t => t.id === WELCOME_SCENE_ID)) { - const tabsWithoutWelcome = state.openTabs.filter(t => t.id !== WELCOME_SCENE_ID); - const histWithoutWelcome = state.navHistory.filter(h => h !== WELCOME_SCENE_ID); - - // If the first opened scene is not session, companion-open session alongside it - let companionTabs = tabsWithoutWelcome; - if (id !== AGENT_SCENE_ID && !tabsWithoutWelcome.some(t => t.id === AGENT_SCENE_ID)) { - companionTabs = [buildSceneTab(AGENT_SCENE_ID, 0), ...tabsWithoutWelcome]; - } - - set({ - openTabs: ensureAgentFirst(companionTabs), - navHistory: histWithoutWelcome, - navCursor: Math.max(0, histWithoutWelcome.length - 1), - }); - } - } - - const { openTabs, activeTabId, navHistory, navCursor } = get(); + const state = get(); + const { activeTabId } = state; // Already active — re-sync left nav in case user navigated back to MainNav if (id === activeTabId) { @@ -164,24 +144,44 @@ export const useSceneStore = create((set, get) => ({ return; } + const isAlreadyOpen = state.openTabs.some(tab => tab.id === id); + const def = getSceneDef(id); + const isMiniappTab = typeof id === 'string' && id.startsWith('miniapp:'); + if (!isAlreadyOpen && !def && !isMiniappTab) return; + + let openTabs = state.openTabs; + let navHistory = state.navHistory; + let navCursor = state.navCursor; + + // Compute welcome removal and the target activation as one store update. + // Publishing the intermediate "welcome is active but no longer mounted" + // snapshot gives React a blank viewport that looks like a full page refresh. + if (id !== WELCOME_SCENE_ID && openTabs.some(tab => tab.id === WELCOME_SCENE_ID)) { + openTabs = openTabs.filter(tab => tab.id !== WELCOME_SCENE_ID); + navHistory = navHistory.filter(historyId => historyId !== WELCOME_SCENE_ID); + navCursor = Math.max(0, navHistory.length - 1); + + // If the first opened scene is not session, companion-open session alongside it. + if (id !== AGENT_SCENE_ID && !openTabs.some(tab => tab.id === AGENT_SCENE_ID)) { + openTabs = [buildSceneTab(AGENT_SCENE_ID, 0), ...openTabs]; + } + } + const histUpdate = pushHistory(navHistory, navCursor, id); // Already open → just activate - if (openTabs.find(t => t.id === id)) { - set(state => ({ + if (openTabs.some(tab => tab.id === id)) { + const activatedAt = Date.now(); + set({ activeTabId: id, - openTabs: state.openTabs.map(t => - t.id === id ? { ...t, lastUsed: Date.now() } : t - ), + openTabs: ensureAgentFirst(openTabs.map(tab => + tab.id === id ? { ...tab, lastUsed: activatedAt } : tab + )), ...histUpdate, - })); + }); return; } - const def = getSceneDef(id); - const isMiniappTab = typeof id === 'string' && id.startsWith('miniapp:'); - if (!def && !isMiniappTab) return; - let next = [...openTabs]; // Eviction: over capacity → remove oldest replaceable tab (FIFO); fixed (e.g. agent) never evicted @@ -189,7 +189,7 @@ export const useSceneStore = create((set, get) => ({ const victim = selectOldestReplaceableTab(next); if (!victim) return; const evictedId = victim.id; - next = next.filter(t => t.id !== evictedId); + next = next.filter(tab => tab.id !== evictedId); const afterEvict = removeFromHistory( histUpdate.navHistory, histUpdate.navCursor, diff --git a/src/web-ui/src/app/styles/index.scss b/src/web-ui/src/app/styles/index.scss index 70358d6d7f..da8bd2c8c4 100644 --- a/src/web-ui/src/app/styles/index.scss +++ b/src/web-ui/src/app/styles/index.scss @@ -4,6 +4,7 @@ /* ========== @use rules must come first ========== */ @use './global.scss'; +@use './motion.scss'; @use '../layout/AppLayout.scss'; /* ========== Utility styles ========== */ @@ -19,4 +20,3 @@ /* ========== Component styles ========== */ @import './components/forms.css'; - diff --git a/src/web-ui/src/app/styles/motion.scss b/src/web-ui/src/app/styles/motion.scss new file mode 100644 index 0000000000..c343a4bd87 --- /dev/null +++ b/src/web-ui/src/app/styles/motion.scss @@ -0,0 +1,283 @@ +/** + * BitFun global motion baseline. + * + * Semantic controls and surfaces participate automatically. Product-specific + * motion can opt in without adding another keyframe: + * + * data-motion="interactive" press / hover feedback + * data-motion="view" page or panel entrance + * data-motion="popup" menu, popover, or floating surface entrance + * data-motion="overlay" backdrop entrance + * data-motion="none" disable motion for this subtree + * + * Keep this layer deliberately subtle. Component-owned entrance animation + * wins because fallback selectors use :where() and have zero specificity. + * Interactive transition timing is intentionally centralized here. + */ + +@keyframes bitfun-motion-view-enter { + from { + opacity: 0.92; + } + + to { + opacity: 1; + } +} + +@keyframes bitfun-motion-view-exit { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes bitfun-motion-popup-enter { + from { + opacity: 0; + translate: 0 -2px; + scale: 0.992; + } + + to { + opacity: 1; + translate: 0 0; + scale: 1; + } +} + +@keyframes bitfun-motion-dialog-enter { + from { + opacity: 0; + translate: 0 6px; + scale: 0.992; + } + + to { + opacity: 1; + translate: 0 0; + scale: 1; + } +} + +@keyframes bitfun-motion-overlay-enter { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + /** + * Buttons and button-like controls. + * + * Individual transform properties compose with component-owned `transform` + * (icons, drag handles, toggles) instead of replacing it. + */ + :where( + button, + input[type='button'], + input[type='submit'], + input[type='reset'], + summary, + [role='button'], + [role='checkbox'], + [role='radio'], + [role='switch'], + [data-motion='interactive'] + ):where( + :not(:disabled):not([aria-disabled='true']):not([data-motion='none']) + ) { + transform-origin: center; + transition: + translate var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + scale var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + transform var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + background-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + border-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + box-shadow var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + opacity var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)); + transition-property: + translate, + scale, + transform, + background-color, + border-color, + color, + box-shadow, + opacity !important; + } + + @media (hover: hover) and (pointer: fine) { + :where( + button, + input[type='button'], + input[type='submit'], + input[type='reset'], + summary, + [role='button'], + [role='checkbox'], + [role='radio'], + [role='switch'], + [data-motion='interactive'] + ):where( + :not(:disabled):not([aria-disabled='true']):not([data-motion='none']) + ):hover { + translate: 0 -1px; + } + } + + :where( + button, + input[type='button'], + input[type='submit'], + input[type='reset'], + summary, + [role='button'], + [role='checkbox'], + [role='radio'], + [role='switch'], + [data-motion='interactive'] + ):where( + :not(:disabled):not([aria-disabled='true']):not([data-motion='none']) + ):active { + translate: 0 0; + scale: 0.985; + } + + /** + * View entrance is explicit. Automatically fading every tab panel makes + * ordinary content replacement look like a page refresh, especially when + * the component already owns a transition or a Suspense fallback. + */ + :where([data-motion='view']):where(:not([data-motion='none'])) { + animation: bitfun-motion-view-enter 140ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + + :where( + [data-scene-transition='exit'], + [data-motion='view-exit'] + ):where(:not([data-motion='none'])) { + animation: bitfun-motion-view-exit 140ms cubic-bezier(0.4, 0, 1, 1) both; + } + + /** + * Floating surfaces. Existing component animations retain precedence; these + * selectors fill the gaps for current and future semantic popups. + */ + :where( + [role='menu'], + [role='listbox'], + [role='tooltip'], + [data-motion='popup'], + [class$='__dropdown'], + [class*='__dropdown '], + [class$='-dropdown'], + [class*='-dropdown '], + [class$='__popover'], + [class*='__popover '], + [class$='-popover'], + [class*='-popover '], + [class$='__context-menu'], + [class*='__context-menu '], + [class$='-context-menu'], + [class*='-context-menu '] + ):where(:not([data-motion='none'])) { + transform-origin: var(--bitfun-motion-origin, center top); + animation: bitfun-motion-popup-enter 160ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + + /** + * A dialog may itself be the full-screen overlay. Move only dialog surfaces; + * overlay/backdrop roots receive a fade below. + */ + :where( + [role='dialog'], + [role='alertdialog'], + [data-motion='dialog'] + ):where( + :not( + [class*='overlay'], + [class*='backdrop'], + [data-motion='overlay'], + [data-motion='none'] + ) + ) { + transform-origin: center; + animation: bitfun-motion-dialog-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + + :where( + [data-motion='overlay'], + [role='dialog'][class*='overlay'], + [role='dialog'][class*='backdrop'], + [role='alertdialog'][class*='overlay'], + [role='alertdialog'][class*='backdrop'], + body > [class$='-overlay'], + body > [class$='__overlay'], + body > [class$='-backdrop'], + body > [class$='__backdrop'] + ):where(:not([data-motion='none'])) { + animation: bitfun-motion-overlay-enter 160ms ease-out both; + } + + /** + * A small, explicit set of reusable interactive surfaces that are not native + * buttons. New surfaces should prefer semantic roles or data-motion. + */ + :where( + .v-card--interactive, + .bitfun-context-card--interactive, + .bitfun-nav-panel__section-header--interactive + ):where(:not([data-motion='none'])) { + transform-origin: center; + transition: + translate var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + scale var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + transform var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + background-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + border-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), + box-shadow var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)); + transition-property: + translate, + scale, + transform, + background-color, + border-color, + box-shadow !important; + } + + @media (hover: hover) and (pointer: fine) { + :where( + .v-card--interactive, + .bitfun-context-card--interactive, + .bitfun-nav-panel__section-header--interactive + ):where(:not([data-motion='none'])):hover { + translate: 0 -1px; + } + } + + :where( + .v-card--interactive, + .bitfun-context-card--interactive, + .bitfun-nav-panel__section-header--interactive + ):where(:not([data-motion='none'])):active { + translate: 0 0; + scale: 0.99; + } +} + +/* Explicit opt-out for embedded apps, canvases, or latency-sensitive surfaces. */ +:where([data-motion='none'], [data-motion='none'] *) { + animation: none !important; + transition: none !important; + translate: none !important; + scale: none !important; +} diff --git a/src/web-ui/src/component-library/components/Modal/Modal.scss b/src/web-ui/src/component-library/components/Modal/Modal.scss index 0573ccc794..824bf8d862 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.scss +++ b/src/web-ui/src/component-library/components/Modal/Modal.scss @@ -20,7 +20,7 @@ $modal-edge-gutter-sm: 10px; justify-content: center; padding: 24px; z-index: tokens.$z-modal; - animation: modal-overlay-fade 0.25s ease; + animation: modal-overlay-fade 0.2s ease-out both; &--bottom-left { align-items: flex-end; @@ -28,7 +28,7 @@ $modal-edge-gutter-sm: 10px; padding: 0 0 20px 20px; .modal { - animation: modal-dialog-enter-bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1); + animation: modal-dialog-enter-bottom 0.24s cubic-bezier(0.22, 1, 0.36, 1) both; } } @@ -38,7 +38,7 @@ $modal-edge-gutter-sm: 10px; padding: 0 20px 20px 0; .modal { - animation: modal-dialog-enter-bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1); + animation: modal-dialog-enter-bottom 0.24s cubic-bezier(0.22, 1, 0.36, 1) both; } } } @@ -48,6 +48,11 @@ $modal-edge-gutter-sm: 10px; to { opacity: 1; } } +@keyframes modal-overlay-exit { + from { opacity: 1; } + to { opacity: 0; } +} + .modal { position: relative; @@ -59,7 +64,7 @@ $modal-edge-gutter-sm: 10px; box-shadow: 0 16px 40px rgba(var(--color-static-black-rgb), 0.45); max-height: calc(100vh - 48px); overflow: hidden; - animation: modal-dialog-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1); + animation: modal-dialog-enter 0.24s cubic-bezier(0.22, 1, 0.36, 1) both; &--small { @@ -231,7 +236,7 @@ $modal-edge-gutter-sm: 10px; @keyframes modal-dialog-enter { from { opacity: 0; - transform: translateY(20px) scale(0.97); + transform: translateY(8px) scale(0.992); } to { opacity: 1; @@ -242,7 +247,7 @@ $modal-edge-gutter-sm: 10px; @keyframes modal-dialog-enter-bottom { from { opacity: 0; - transform: translateY(16px) scale(0.97); + transform: translateY(10px) scale(0.992); } to { opacity: 1; @@ -250,9 +255,31 @@ $modal-edge-gutter-sm: 10px; } } +@keyframes modal-dialog-exit { + from { + opacity: 1; + translate: 0 0; + scale: 1; + } + to { + opacity: 0; + translate: 0 5px; + scale: 0.992; + } +} + +.modal-overlay--exiting { + animation: modal-overlay-exit 0.18s ease-in both; + pointer-events: none; +} + +.modal.modal--exiting { + animation: modal-dialog-exit 0.18s cubic-bezier(0.4, 0, 1, 1) both; +} + .modal--draggable, .modal--resizable { - animation: modal-overlay-fade 0.2s ease; + animation: modal-overlay-fade 0.2s ease-out both; } diff --git a/src/web-ui/src/component-library/components/Modal/Modal.test.tsx b/src/web-ui/src/component-library/components/Modal/Modal.test.tsx new file mode 100644 index 0000000000..8aa36bef7c --- /dev/null +++ b/src/web-ui/src/component-library/components/Modal/Modal.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Modal } from './Modal'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +describe('Modal motion presence', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + it('keeps the dialog mounted until its exit animation completes', () => { + act(() => { + root.render( + + Content + , + ); + }); + + expect(document.body.querySelector('.modal')).not.toBeNull(); + + act(() => { + root.render( + + Content + , + ); + }); + + expect(document.body.querySelector('.modal-overlay--exiting')).not.toBeNull(); + expect(document.body.querySelector('.modal--exiting')).not.toBeNull(); + expect(document.body.querySelector('[role="dialog"]')?.getAttribute('aria-hidden')).toBe('true'); + + act(() => { + vi.advanceTimersByTime(179); + }); + expect(document.body.querySelector('.modal')).not.toBeNull(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(document.body.querySelector('.modal')).toBeNull(); + }); + + it('cancels the exit when the dialog reopens', () => { + const renderModal = (isOpen: boolean) => { + root.render( + + Content + , + ); + }; + + act(() => renderModal(true)); + act(() => renderModal(false)); + act(() => { + vi.advanceTimersByTime(80); + renderModal(true); + }); + act(() => { + vi.advanceTimersByTime(180); + }); + + expect(document.body.querySelector('.modal')).not.toBeNull(); + expect(document.body.querySelector('.modal--exiting')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/component-library/components/Modal/Modal.tsx b/src/web-ui/src/component-library/components/Modal/Modal.tsx index 14f01f18e4..b6b9cfdb3c 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.tsx +++ b/src/web-ui/src/component-library/components/Modal/Modal.tsx @@ -7,6 +7,9 @@ import { createPortal } from 'react-dom'; import { useI18n } from '@/infrastructure/i18n'; import './Modal.scss'; +// Keep in sync with modal-overlay-exit/modal-dialog-exit in Modal.scss. +const MODAL_EXIT_DURATION_MS = 180; + export interface ModalProps { isOpen: boolean; onClose: () => void; @@ -54,6 +57,7 @@ export const Modal: React.FC = ({ ariaLabelledBy, }) => { const { t } = useI18n('components'); + const [isPresent, setIsPresent] = useState(isOpen); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [isDragging, setIsDragging] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); @@ -65,9 +69,27 @@ export const Modal: React.FC = ({ const headerRef = useRef(null); const previousFocusRef = useRef(null); const generatedTitleId = useId(); - + const isExiting = !isOpen && isPresent; + useEffect(() => { if (isOpen) { + setIsPresent(true); + return; + } + + if (!isPresent) { + return; + } + + const exitTimer = window.setTimeout(() => { + setIsPresent(false); + }, MODAL_EXIT_DURATION_MS); + + return () => window.clearTimeout(exitTimer); + }, [isOpen, isPresent]); + + useEffect(() => { + if (isOpen || isPresent) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; @@ -76,7 +98,7 @@ export const Modal: React.FC = ({ return () => { document.body.style.overflow = ''; }; - }, [isOpen]); + }, [isOpen, isPresent]); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -202,11 +224,11 @@ export const Modal: React.FC = ({ height: modalHeight }); } - } else if (!isOpen) { + } else if (!isOpen && !isPresent) { setPosition(null); setDimensions(null); } - }, [isOpen, draggable, resizable]); + }, [isOpen, isPresent, draggable, resizable]); const handleResizeStart = useCallback((e: React.MouseEvent, direction: string) => { if (!resizable || !modalRef.current) return; @@ -297,7 +319,7 @@ export const Modal: React.FC = ({ } }, [isResizing, handleResizeMove, handleResizeEnd]); - if (!isOpen) return null; + if (!isOpen && !isPresent) return null; const appliedStyle = (draggable || resizable) && position ? { position: 'fixed' as const, @@ -313,16 +335,18 @@ export const Modal: React.FC = ({ className={[ 'modal-overlay', placement !== 'center' ? `modal-overlay--${placement}` : '', + isExiting ? 'modal-overlay--exiting' : '', overlayClassName ?? '', ] .filter(Boolean) .join(' ')} - onClick={closeOnOverlayClick ? onClose : undefined} + onClick={!isExiting && closeOnOverlayClick ? onClose : undefined} >
= ({ isDragging ? 'modal--dragging' : '', resizable ? 'modal--resizable' : '', isResizing ? 'modal--resizing' : '', + isExiting ? 'modal--exiting' : '', contentInset ? 'modal--content-inset' : '', showCloseButton ? 'modal--with-close' : '', ] diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 7c3b06cbd1..4316da1220 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -284,6 +284,33 @@ describe('ExternalSourcesConfig', () => { vi.clearAllMocks(); }); + it('keeps the shared page frame while the initial snapshot is loading', async () => { + let resolveSnapshot: ((value: typeof snapshot) => void) | undefined; + getSnapshotMock.mockImplementationOnce( + () => new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const page = container.querySelector( + '.bitfun-config-page-layout.bitfun-external-sources-config', + ); + expect(page?.querySelector('.bitfun-config-page-header__title')?.textContent).toBe('title'); + expect(page?.querySelector( + '.bitfun-config-page-content .bitfun-config-page-loading', + )?.textContent).toBe('loading'); + + await act(async () => { + resolveSnapshot?.(snapshot); + await Promise.resolve(); + }); + }); + it('keeps compatibility controls compact and applies the safe OpenCode defaults', async () => { const policySnapshot = { ...snapshot, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index f3ebc94e47..648c3e2c72 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -1093,7 +1093,14 @@ const ExternalSourcesConfig: React.FC = () => { ), [busyKey, hostCapabilities.canManageSources, policyCompatible, setEnabled, t]); if (loading && !snapshot) { - return ; + return ( + + + + + + + ); } const hostUnavailable = !snapshot && error?.code === 'host_unavailable'; diff --git a/src/web-ui/src/infrastructure/config/components/ReviewConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ReviewConfig.test.tsx index 36e9f103f5..2bd31c6f74 100644 --- a/src/web-ui/src/infrastructure/config/components/ReviewConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ReviewConfig.test.tsx @@ -28,6 +28,7 @@ const translateMock = vi.hoisted(() => (key: string, params?: Record { expect(container.textContent).not.toContain('orchestration controls'); }); + it('keeps the shared page header and content frame while loading', async () => { + let resolveLoad: ((team: ReturnType) => void) | undefined; + loadDefaultReviewTeamMock.mockImplementationOnce( + () => new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.querySelector('main > header')?.textContent).toContain('Review'); + expect(container.querySelector('main > div')?.textContent).toContain('Loading review settings'); + + await act(async () => { + resolveLoad?.(createReviewTeam()); + await Promise.resolve(); + }); + }); + it('shows an honest read-only boundary outside the desktop runtime', async () => { isTauriRuntimeMock.mockReturnValue(false); diff --git a/src/web-ui/src/infrastructure/config/components/ReviewConfig.tsx b/src/web-ui/src/infrastructure/config/components/ReviewConfig.tsx index 26c84b57c1..5cc686bdcf 100644 --- a/src/web-ui/src/infrastructure/config/components/ReviewConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ReviewConfig.tsx @@ -93,7 +93,10 @@ const ReviewConfig: React.FC = () => { if (loading) { return ( - + + + + ); } diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss b/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss index e81081ad42..b4e17451fb 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss @@ -1,17 +1,19 @@ @use '../../../../component-library/styles/tokens' as *; -@use './config-page-layout.tokens' as configLayout; .bitfun-config-page-header { flex-shrink: 0; - padding: 48px configLayout.$config-page-content-inline-padding 0; + padding: 48px 0 0; margin-bottom: var(--size-gap-6); } .bitfun-config-page-header__inner { - width: min(100%, configLayout.$config-page-content-max-width); + width: min( + calc(100% - (2 * var(--config-page-content-inline-padding))), + var(--config-page-content-max-width) + ); margin-inline: auto; display: flex; justify-content: space-between; @@ -94,8 +96,8 @@ height: 1px; background: var(--border-subtle); width: min( - calc(100% - (2 * #{configLayout.$config-page-content-inline-padding})), - #{configLayout.$config-page-content-max-width} + calc(100% - (2 * var(--config-page-content-inline-padding))), + var(--config-page-content-max-width) ); margin: var(--size-gap-4) auto; display: none; @@ -103,15 +105,15 @@ @container config-panel (max-width: 900px) { .bitfun-config-page-header { - padding-inline: var(--size-gap-4); + --config-page-content-inline-padding: var(--size-gap-4); } .bitfun-config-page-header__inner { - width: 100%; + width: calc(100% - (2 * var(--config-page-content-inline-padding))); } .bitfun-config-page-header-divider { - width: calc(100% - (2 * var(--size-gap-4))); + --config-page-content-inline-padding: var(--size-gap-4); } } @@ -158,5 +160,3 @@ } } - - diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss index 219f5ff0dd..9c64938513 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss @@ -11,10 +11,17 @@ height: 100%; overflow-y: auto; overflow-x: hidden; + /* + * Keep the content axis fixed when a page gains or loses a vertical scrollbar. + * Reserving both edges also keeps the 600px content column visually centered. + */ + scrollbar-gutter: stable both-edges; background: var(--color-bg-scene); color: var(--color-text-primary); font-family: var(--font-family-sans); /* Shared layout contract for settings pages in wide scenes. */ + --config-page-content-inline-padding: #{configLayout.$config-page-content-inline-padding}; + --config-page-content-max-width: #{configLayout.$config-page-content-max-width}; --config-page-content-bottom-padding: 48px; /* Vertical gap between top-level sections (ConfigPageSection blocks) inside ConfigPageContent. */ --config-page-section-gap: 40px; @@ -35,11 +42,18 @@ * the actual content, not at the flex-allocated truncated position. */ flex-shrink: 0; overflow: visible; - padding: 0 configLayout.$config-page-content-inline-padding; + /* + * Page-specific class names are attached to this public wrapper. Keep its box + * spacing neutral and let the private inner frame own the shared gutters. + */ + padding: 0; } .bitfun-config-page-content__inner { - width: min(100%, configLayout.$config-page-content-max-width); + width: min( + calc(100% - (2 * var(--config-page-content-inline-padding))), + var(--config-page-content-max-width) + ); margin-inline: auto; display: flex; flex-direction: column; @@ -187,11 +201,11 @@ .bitfun-config-page-content { --config-page-section-gap: var(--size-gap-8); - padding-inline: var(--size-gap-4); + --config-page-content-inline-padding: var(--size-gap-4); } .bitfun-config-page-content__inner { - width: 100%; + width: calc(100% - (2 * var(--config-page-content-inline-padding))); } } From 7ab7e39cbd875ce775b2ff4d3e3d399e2c603e0c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 15:42:33 +0800 Subject: [PATCH 2/3] fix(ui): honor theme variable contracts --- src/web-ui/src/app/styles/motion.scss | 30 +++++++++---------- .../components/common/ConfigPageHeader.scss | 18 +++++------ .../components/common/ConfigPageLayout.scss | 9 ++---- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/web-ui/src/app/styles/motion.scss b/src/web-ui/src/app/styles/motion.scss index c343a4bd87..fc2dd53807 100644 --- a/src/web-ui/src/app/styles/motion.scss +++ b/src/web-ui/src/app/styles/motion.scss @@ -96,14 +96,14 @@ ) { transform-origin: center; transition: - translate var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - scale var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - transform var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - background-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - border-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - box-shadow var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - opacity var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)); + translate var(--motion-fast) var(--easing-standard), + scale var(--motion-fast) var(--easing-standard), + transform var(--motion-fast) var(--easing-standard), + background-color var(--motion-fast) var(--easing-standard), + border-color var(--motion-fast) var(--easing-standard), + color var(--motion-fast) var(--easing-standard), + box-shadow var(--motion-fast) var(--easing-standard), + opacity var(--motion-fast) var(--easing-standard); transition-property: translate, scale, @@ -190,7 +190,7 @@ [class$='-context-menu'], [class*='-context-menu '] ):where(:not([data-motion='none'])) { - transform-origin: var(--bitfun-motion-origin, center top); + transform-origin: center top; animation: bitfun-motion-popup-enter 160ms cubic-bezier(0.22, 1, 0.36, 1) both; } @@ -239,12 +239,12 @@ ):where(:not([data-motion='none'])) { transform-origin: center; transition: - translate var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - scale var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - transform var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - background-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - border-color var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)), - box-shadow var(--motion-fast, 150ms) var(--easing-standard, cubic-bezier(0.4, 0, 0.2, 1)); + translate var(--motion-fast) var(--easing-standard), + scale var(--motion-fast) var(--easing-standard), + transform var(--motion-fast) var(--easing-standard), + background-color var(--motion-fast) var(--easing-standard), + border-color var(--motion-fast) var(--easing-standard), + box-shadow var(--motion-fast) var(--easing-standard); transition-property: translate, scale, diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss b/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss index b4e17451fb..1377a54a24 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageHeader.scss @@ -1,6 +1,7 @@ @use '../../../../component-library/styles/tokens' as *; +@use './config-page-layout.tokens' as configLayout; .bitfun-config-page-header { @@ -11,8 +12,8 @@ .bitfun-config-page-header__inner { width: min( - calc(100% - (2 * var(--config-page-content-inline-padding))), - var(--config-page-content-max-width) + calc(100% - (2 * #{configLayout.$config-page-content-inline-padding})), + #{configLayout.$config-page-content-max-width} ); margin-inline: auto; display: flex; @@ -96,24 +97,20 @@ height: 1px; background: var(--border-subtle); width: min( - calc(100% - (2 * var(--config-page-content-inline-padding))), - var(--config-page-content-max-width) + calc(100% - (2 * #{configLayout.$config-page-content-inline-padding})), + #{configLayout.$config-page-content-max-width} ); margin: var(--size-gap-4) auto; display: none; } @container config-panel (max-width: 900px) { - .bitfun-config-page-header { - --config-page-content-inline-padding: var(--size-gap-4); - } - .bitfun-config-page-header__inner { - width: calc(100% - (2 * var(--config-page-content-inline-padding))); + width: calc(100% - (2 * var(--size-gap-4))); } .bitfun-config-page-header-divider { - --config-page-content-inline-padding: var(--size-gap-4); + width: calc(100% - (2 * var(--size-gap-4))); } } @@ -159,4 +156,3 @@ margin: var(--size-gap-3) auto; } } - diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss index 9c64938513..39a90beb85 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss @@ -20,8 +20,6 @@ color: var(--color-text-primary); font-family: var(--font-family-sans); /* Shared layout contract for settings pages in wide scenes. */ - --config-page-content-inline-padding: #{configLayout.$config-page-content-inline-padding}; - --config-page-content-max-width: #{configLayout.$config-page-content-max-width}; --config-page-content-bottom-padding: 48px; /* Vertical gap between top-level sections (ConfigPageSection blocks) inside ConfigPageContent. */ --config-page-section-gap: 40px; @@ -51,8 +49,8 @@ .bitfun-config-page-content__inner { width: min( - calc(100% - (2 * var(--config-page-content-inline-padding))), - var(--config-page-content-max-width) + calc(100% - (2 * #{configLayout.$config-page-content-inline-padding})), + #{configLayout.$config-page-content-max-width} ); margin-inline: auto; display: flex; @@ -201,11 +199,10 @@ .bitfun-config-page-content { --config-page-section-gap: var(--size-gap-8); - --config-page-content-inline-padding: var(--size-gap-4); } .bitfun-config-page-content__inner { - width: calc(100% - (2 * var(--config-page-content-inline-padding))); + width: calc(100% - (2 * var(--size-gap-4))); } } From 7c0c5fef6b1328896537acbb352aedc8eddaf2e0 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 24 Jul 2026 15:47:47 +0800 Subject: [PATCH 3/3] test(settings): track lazy registry ownership --- .../startupPerformanceContract.test.ts | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 7022e9fb7a..e984cf0083 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -384,20 +384,37 @@ describe('startup performance contract', () => { }); it('keeps settings config panels lazy by active tab', () => { - const source = readSource('../scenes/settings/SettingsScene.tsx'); - - expect(source).not.toMatch(/import\s+AIModelConfig\s+from/); - expect(source).not.toMatch(/import\s+McpToolsConfig\s+from/); - expect(source).not.toMatch(/import\s+AcpAgentsConfig\s+from/); - expect(source).not.toMatch(/import\s+EditorConfig\s+from/); - expect(source).not.toMatch(/import\s+BasicsConfig\s+from/); - expect(source).not.toMatch(/import\s+AppearanceConfig\s+from/); - expect(source).not.toMatch(/import\s+ReviewConfig\s+from/); - expect(source).not.toMatch(/import\s+QuickActionsConfig\s+from/); - expect(source).toContain("lazy(() => import('../../../infrastructure/config/components/AIModelConfig'))"); - expect(source).toContain("lazy(() => import('../../../infrastructure/config/components/BasicsConfig'))"); - expect(source).toContain("lazy(() => import('./components/ArchivedSessionsConfig'))"); - expect(source).toContain(' {