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
42 changes: 7 additions & 35 deletions src/web-ui/src/app/scenes/SceneViewport.scss
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@
display: none;
overflow: hidden;

&--active,
&--outgoing {
&--visible {
display: flex;
flex-direction: row;
min-width: 0;
Expand All @@ -130,64 +129,37 @@
justify-content: center;
}

&--incoming,
&--outgoing {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}

&--incoming {
transition:
opacity 180ms cubic-bezier(0.23, 1, 0.32, 1),
transform 180ms cubic-bezier(0.23, 1, 0.32, 1);
opacity: 1;
transition: opacity 480ms cubic-bezier(0.23, 1, 0.32, 1);
}

&--outgoing {
z-index: 2;
pointer-events: none;
transition:
opacity 140ms cubic-bezier(0.23, 1, 0.32, 1),
transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
}

&__clip[data-scene-motion-phase='preparing'] {
.bitfun-scene-viewport__scene--incoming {
opacity: 0.72;
transform: translate3d(0, 6px, 0) scale(0.995);
opacity: 0.5;
transition: none;
}
}

&__clip[data-scene-motion-phase='running'] {
.bitfun-scene-viewport__scene--outgoing {
opacity: 0;
transform: translate3d(0, -4px, 0) scale(0.997);
}
}
}

@media (prefers-reduced-motion: reduce) {
.bitfun-scene-viewport__clip::after {
transition: none;
}

.bitfun-scene-viewport__scene--incoming,
.bitfun-scene-viewport__scene--outgoing {
transform: none;
transition: opacity 100ms cubic-bezier(0.23, 1, 0.32, 1);
.bitfun-scene-viewport__scene--incoming {
transition: none;
}

.bitfun-scene-viewport__clip[data-scene-motion-phase='preparing'] {
.bitfun-scene-viewport__scene--incoming {
opacity: 0.82;
transform: none;
}
}

.bitfun-scene-viewport__clip[data-scene-motion-phase='running'] {
.bitfun-scene-viewport__scene--outgoing {
transform: none;
opacity: 1;
}
}
}
153 changes: 153 additions & 0 deletions src/web-ui/src/app/scenes/SceneViewport.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// @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';

const sceneHarness = vi.hoisted(() => {
let resolveAgents: (() => void) | null = null;
let agentsAreReady = false;
const agentsReady = new Promise<void>((resolve) => {
resolveAgents = () => {
agentsAreReady = true;
resolve();
};
});

return {
state: {
openTabs: [{ id: 'session', openedAt: 0, lastUsed: 0 }],
activeTabId: 'session',
navigationMotion: 'instant',
navigationSequence: 0,
},
agentsReady,
agentsAreReady: () => agentsAreReady,
resolveAgents: () => resolveAgents?.(),
};
});

vi.mock('../hooks/useSceneManager', () => ({
useSceneManager: () => sceneHarness.state,
}));

vi.mock('../hooks/useDialogCompletionNotify', () => ({
useDialogCompletionNotify: () => undefined,
}));

vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}));

vi.mock('@/component-library', () => ({
DotMatrixLoader: () => <div data-testid="scene-loader" />,
}));

vi.mock('./session/SessionScene', () => ({
default: () => <div data-testid="session-scene-content" />,
}));

vi.mock('./settings/SettingsScene', () => ({
default: () => <div data-testid="settings-scene-content" />,
}));

vi.mock('./assistant/AssistantScene', () => ({
default: () => <div data-testid="assistant-scene-content" />,
}));

vi.mock('./agents/AgentsScene', () => ({
default: () => {
if (!sceneHarness.agentsAreReady()) {
throw sceneHarness.agentsReady;
}
return <div data-testid="agents-scene-content" />;
},
}));

import SceneViewport from './SceneViewport';

describe('SceneViewport transitions', () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => (
window.setTimeout(() => callback(performance.now()), 16)
));
vi.stubGlobal('cancelAnimationFrame', (handle: number) => window.clearTimeout(handle));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.useRealTimers();
});

function visibleScenes(): Element[] {
return Array.from(container.querySelectorAll('[data-testid="scene-viewport-scene"]'))
.filter(scene => !scene.hasAttribute('hidden'));
}

it('keeps one scene visible while a lazy pointer target becomes ready', async () => {
act(() => root.render(<SceneViewport />));
expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['session']);

sceneHarness.state = {
openTabs: [
{ id: 'session', openedAt: 0, lastUsed: 0 },
{ id: 'agents', openedAt: 1, lastUsed: 1 },
],
activeTabId: 'agents',
navigationMotion: 'pointer',
navigationSequence: 1,
};
await act(async () => {
root.render(<SceneViewport />);
await Promise.resolve();
});

expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['session']);

await act(async () => {
sceneHarness.resolveAgents();
await Promise.resolve();
await Promise.resolve();
});

expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['agents']);
expect(container.querySelector('[data-scene-id="session"]')?.hasAttribute('hidden')).toBe(true);
expect(container.querySelector('[data-scene-id="agents"]')?.classList.contains(
'bitfun-scene-viewport__scene--incoming',
)).toBe(true);

act(() => vi.advanceTimersByTime(32));
expect(container.querySelector('[data-scene-id="agents"]')?.classList.contains(
'bitfun-scene-viewport__scene--incoming',
)).toBe(true);

act(() => vi.advanceTimersByTime(479));
expect(container.querySelector('[data-scene-id="agents"]')?.classList.contains(
'bitfun-scene-viewport__scene--incoming',
)).toBe(true);

act(() => vi.advanceTimersByTime(1));
expect(container.querySelector('[data-scene-id="agents"]')?.classList.contains(
'bitfun-scene-viewport__scene--incoming',
)).toBe(false);

sceneHarness.state = {
...sceneHarness.state,
activeTabId: 'session',
navigationSequence: 2,
};
act(() => root.render(<SceneViewport />));

expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['session']);
expect(container.querySelector('[data-scene-id="agents"]')?.hasAttribute('hidden')).toBe(true);
});
});
39 changes: 25 additions & 14 deletions src/web-ui/src/app/scenes/SceneViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const WelcomeScene = lazy(() => import('./welcome/WelcomeScene'));
const MiniAppScene = lazy(() => import('./miniapps/MiniAppScene'));
const PanelViewScene = lazy(() => import('./panel-view/PanelViewScene'));

const SCENE_TRANSITION_RETENTION_MS = 200;
const SCENE_ENTRY_DURATION_MS = 480;
const EMPTY_SCENE_ID = '__empty-scene__' as const;
type RenderedSceneId = SceneTabId | typeof EMPTY_SCENE_ID;

Expand Down Expand Up @@ -106,15 +106,21 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
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.
// Derive the outgoing id during render as well as from state. Pointer
// navigation keeps that scene as the only visible surface until the target
// has resolved through Suspense, then swaps atomically to the incoming scene.
const activeSceneChanged = previousActiveTabIdRef.current !== activeRenderedSceneId;
const outgoingTabId = activeSceneChanged
? navigationMotion === 'pointer'
? previousActiveTabIdRef.current
: null
: transition?.outgoingTabId ?? null;
const pendingTransition: SceneTransition | null = activeSceneChanged
&& navigationMotion === 'pointer'
? {
outgoingTabId: previousActiveTabIdRef.current,
incomingTabId: activeRenderedSceneId,
phase: 'holding',
}
: activeSceneChanged
? null
: transition;
const outgoingTabId = pendingTransition?.outgoingTabId ?? null;
const renderedTabIds: RenderedSceneId[] = openTabs.length === 0
? [EMPTY_SCENE_ID]
: openTabs.map(tab => tab.id);
Expand Down Expand Up @@ -184,13 +190,13 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
if (transition?.phase !== 'running') return;

const completedTransition = transition;
const exitTimer = window.setTimeout(() => {
const entryTimer = window.setTimeout(() => {
setTransition(current => (
current === completedTransition ? null : current
));
}, SCENE_TRANSITION_RETENTION_MS);
}, SCENE_ENTRY_DURATION_MS);

return () => window.clearTimeout(exitTimer);
return () => window.clearTimeout(entryTimer);
}, [transition]);

return (
Expand All @@ -204,25 +210,30 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
<div
className="bitfun-scene-viewport__clip"
data-testid="scene-viewport-clip"
data-scene-motion-phase={transition?.phase}
data-scene-motion-phase={pendingTransition?.phase}
data-bf-scene="workbench"
data-bf-part="viewportClip"
>
{renderedTabIds.map(tabId => {
const isEmpty = tabId === EMPTY_SCENE_ID;
const isActive = tabId === activeRenderedSceneId;
const isOutgoing = !isActive && tabId === outgoingTabId;
const isIncoming = isActive && transition?.incomingTabId === tabId;
const isIncoming = isActive && pendingTransition?.incomingTabId === tabId;
const isVisible = pendingTransition?.phase === 'holding'
? isOutgoing
: isActive;
return (
<div
key={tabId}
className={[
'bitfun-scene-viewport__scene',
isEmpty && 'bitfun-scene-viewport__scene--empty',
isActive && 'bitfun-scene-viewport__scene--active',
isVisible && 'bitfun-scene-viewport__scene--visible',
isIncoming && 'bitfun-scene-viewport__scene--incoming',
isOutgoing && 'bitfun-scene-viewport__scene--outgoing',
].filter(Boolean).join(' ')}
hidden={!isVisible}
aria-hidden={!isActive}
{...(!isActive ? { inert: '' } : {})}
data-testid="scene-viewport-scene"
Expand Down