Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions src/web-ui/src/app/components/NavPanel/NavPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 29 additions & 7 deletions src/web-ui/src/app/components/NavPanel/NavPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -40,13 +47,28 @@ const NavPanel: React.FC<NavPanelProps> = ({ className = '' }) => {
const navSceneId = useNavSceneStore(s => s.navSceneId);

const [mountedSceneId, setMountedSceneId] = useState<SceneTabId | null>(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<HTMLDivElement>(null);

Expand All @@ -72,13 +94,13 @@ const NavPanel: React.FC<NavPanelProps> = ({ 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 (
Expand All @@ -95,7 +117,7 @@ const NavPanel: React.FC<NavPanelProps> = ({ className = '' }) => {
{SceneNavComponent && (
<div className={sceneCls}>
<Suspense fallback={null}>
<div key={mountedSceneId} className="bitfun-nav-panel__scene-inner">
<div className="bitfun-nav-panel__scene-inner">
<SceneNavComponent />
</div>
</Suspense>
Expand Down
18 changes: 17 additions & 1 deletion src/web-ui/src/app/scenes/SceneViewport.scss
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@
display: none;
overflow: hidden;

&--active {
&--active,
&--outgoing {
display: flex;
flex-direction: row;
min-width: 0;
Expand All @@ -120,11 +121,26 @@
height: 100%;
}
}

&--active {
z-index: 1;
}

&--outgoing {
z-index: 2;
pointer-events: none;
}
}
}

@media (prefers-reduced-motion: reduce) {
.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;
}
}
122 changes: 116 additions & 6 deletions src/web-ui/src/app/scenes/SceneViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SceneReadyBoundaryProps> = ({
sceneId,
onReady,
children,
}) => {
useLayoutEffect(() => {
onReady(sceneId);
}, [onReady, sceneId]);

return <>{children}</>;
};

interface SceneViewportProps {
workspacePath?: string;
Expand All @@ -45,8 +84,72 @@ interface SceneViewportProps {
const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering = false }) => {
const { openTabs, activeTabId } = useSceneManager();
const { t } = useI18n('common');
const [transition, setTransition] = useState<SceneTransition | null>(null);
const [readyVersion, setReadyVersion] = useState(0);
const readySceneIdsRef = useRef<Set<SceneTabId>>(new Set());
const previousActiveTabIdRef = useRef<SceneTabId>(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 (
Expand All @@ -64,19 +167,24 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
return (
<div className="bitfun-scene-viewport" data-testid="scene-viewport">
<div className="bitfun-scene-viewport__clip" data-testid="scene-viewport-clip">
{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 (
<div
key={tab.id}
key={tabId}
className={[
'bitfun-scene-viewport__scene',
isActive && 'bitfun-scene-viewport__scene--active',
isOutgoing && 'bitfun-scene-viewport__scene--outgoing',
isExiting && 'bitfun-scene-viewport__scene--exiting',
].filter(Boolean).join(' ')}
aria-hidden={!isActive}
data-testid="scene-viewport-scene"
data-scene-id={tab.id}
data-scene-id={tabId}
data-scene-active={isActive ? 'true' : 'false'}
data-scene-transition={isExiting ? 'exit' : undefined}
>
<Suspense
fallback={
Expand All @@ -92,7 +200,9 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
) : null
}
>
{renderScene(tab.id, workspacePath, isEntering, isActive)}
<SceneReadyBoundary sceneId={tabId} onReady={markSceneReady}>
{renderScene(tabId, workspacePath, isEntering, isActive)}
</SceneReadyBoundary>
</Suspense>
</div>
);
Expand Down
20 changes: 17 additions & 3 deletions src/web-ui/src/app/scenes/nav-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,31 @@ import type { SceneTabId } from '../components/SceneBar/types';

type LazyNavComponent = ReturnType<typeof lazy<ComponentType>>;

const loadSettingsNav = () => import('./settings/SettingsNav');
const loadFileViewerNav = () => import('./file-viewer/FileViewerNav');
const loadShellNav = () => import('./shell/ShellNav');

const SCENE_NAV_REGISTRY: Partial<Record<SceneTabId, LazyNavComponent>> = {
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<Record<SceneTabId, () => Promise<unknown>>> = {
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.
*/
export function getSceneNav(sceneId: SceneTabId): LazyNavComponent | null {
return SCENE_NAV_REGISTRY[sceneId] ?? null;
}

export async function preloadSceneNav(sceneId: SceneTabId): Promise<void> {
await SCENE_NAV_LOADERS[sceneId]?.();
}
Loading