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
65 changes: 42 additions & 23 deletions src/web-ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { useEffect, useCallback, useState, useRef } from 'react';
import { useShortcut } from '@/infrastructure/hooks/useShortcut';
import { useHasDismissibleLayer } from '@/infrastructure/hooks/useDismissibleLayer';
import { dismissibleLayerManager } from '@/infrastructure/services/DismissibleLayerManager';
import { ChatProvider, useAIInitialization } from '../infrastructure';
import { ChatProvider } from '../infrastructure/contexts/ChatProvider';
import { ViewModeProvider } from '../infrastructure/contexts/ViewModeProvider';
import { SSHRemoteProvider } from '../features/ssh-remote';
import AppLayout from './layout/AppLayout';
import { useCurrentModelConfig } from '../hooks/useModelConfigs';
import { ContextMenuRenderer } from '../shared/context-menu-system/components/ContextMenuRenderer';
import { NotificationContainer, NotificationCenter, notificationService } from '../shared/notification-system';
import { AnnouncementProvider } from '../shared/announcement-system';
Expand Down Expand Up @@ -45,10 +44,8 @@ const log = createLogger('App');
const MIN_SPLASH_MS = 900;

function App() {
// AI initialization
const { currentConfig } = useCurrentModelConfig();
const { isInitialized: aiInitialized, isInitializing: aiInitializing, error: aiError } = useAIInitialization(currentConfig);
const { t } = useI18n('settings/basics');
const { t: tCommon } = useI18n('common');

// Workspace loading state — drives splash exit timing
const { loading: workspaceLoading } = useWorkspaceContext();
Expand Down Expand Up @@ -148,6 +145,9 @@ function App() {
}
interactiveShellReadyRef.current = true;
startupTrace.markPhase('interactive_shell_ready');
window.dispatchEvent(new CustomEvent('bitfun:interactive-shell-ready', {
detail: { reason: 'workspace-ready' },
}));
setInteractiveShellReady(true);
}, [workspaceLoading]);

Expand Down Expand Up @@ -190,6 +190,38 @@ function App() {
return () => startupSystemsHandle.cancel();
}, [interactiveShellReady]);

useEffect(() => {
if (!interactiveShellReady || splashVisible) {
return;
}

let disposed = false;
let editorWarmupHandle: { promise: Promise<void>; cancel: () => void } | null = null;

void import('@/tools/editor/services/MonacoStartupWarmup')
.then(({ scheduleMonacoStartupWarmup }) => {
if (disposed) {
return;
}
editorWarmupHandle = scheduleMonacoStartupWarmup();
editorWarmupHandle.promise.catch(error => {
if (!disposed && !(error instanceof BackgroundTaskCancelledError)) {
log.warn('Editor startup warmup task failed', error);
}
});
})
.catch(error => {
if (!disposed) {
log.warn('Failed to schedule editor startup warmup', error);
}
});

return () => {
disposed = true;
editorWarmupHandle?.cancel();
};
}, [interactiveShellReady, splashVisible]);

useEffect(() => {
if (!isTauriRuntime() || !interactiveShellReady) return;

Expand Down Expand Up @@ -295,23 +327,6 @@ function App() {
};
}, []);

// Observe AI initialization state
useEffect(() => {
if (aiError) {
log.error('AI initialization failed', aiError);
} else if (aiInitialized) {
log.debug('AI client initialized successfully');
} else if (!aiInitializing && !currentConfig) {
log.warn('AI not initialized: waiting for model config');
} else if (!aiInitializing && currentConfig && !currentConfig.apiKey) {
log.warn('AI not initialized: missing API key');
} else if (!aiInitializing && currentConfig && !currentConfig.modelName) {
log.warn('AI not initialized: missing model name');
} else if (!aiInitializing && currentConfig && !currentConfig.baseUrl) {
log.warn('AI not initialized: missing base URL');
}
}, [aiInitialized, aiInitializing, aiError, currentConfig]);

// Block browser-native Ctrl+F (find bar) and Ctrl+R (hard reload).
// On macOS the equivalent modifiers are Cmd+F / Cmd+R.
useEffect(() => {
Expand Down Expand Up @@ -436,7 +451,11 @@ function App() {

{/* Startup splash — sits above everything, exits once workspace is ready */}
{splashVisible && (
<SplashScreen isExiting={splashExiting} onExited={handleSplashExited} />
<SplashScreen
isExiting={splashExiting}
onExited={handleSplashExited}
delayedMessage={workspaceLoading ? tCommon('loading.workspace') : undefined}
/>
)}
</ToolbarModeProvider>
</SSHRemoteProvider>
Expand Down
29 changes: 28 additions & 1 deletion src/web-ui/src/app/components/SplashScreen/SplashScreen.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

// ── Logo ──────────────────────────────────────────────────────────────────────
Expand All @@ -59,6 +59,29 @@
-webkit-user-select: none;
}

.splash-screen__message {
position: absolute;
top: calc(100% + #{$size-gap-4});
left: 50%;
min-width: 220px;
max-width: min(360px, calc(100vw - 48px));
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.4;
text-align: center;
opacity: 0;
transform: translate(-50%, -2px);
transition:
opacity $motion-base $easing-decelerate,
transform $motion-base $easing-decelerate;
pointer-events: none;
}

.splash-screen__message--visible {
opacity: 1;
transform: translate(-50%, 0);
}

// ── Keyframes ─────────────────────────────────────────────────────────────────

// Idle logo: soft opacity pulse with a slight breathing scale
Expand Down Expand Up @@ -86,6 +109,10 @@
animation: none;
}

.splash-screen__message {
transition: none;
}

.splash-screen--exiting {
animation: splash-bg-exit 0.15s ease-out both;
}
Expand Down
103 changes: 103 additions & 0 deletions src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import SplashScreen from './SplashScreen';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

describe('SplashScreen', () => {
let dom: JSDOM;
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.useFakeTimers();
dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>');
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
container = document.getElementById('root') as HTMLDivElement;
root = createRoot(container);
});

afterEach(() => {
act(() => {
root.unmount();
});
vi.useRealTimers();
dom.window.close();
});

it('reveals the workspace loading message only after the delay', () => {
act(() => {
root.render(
<SplashScreen
isExiting={false}
onExited={() => {}}
delayedMessage="Loading workspace..."
delayedMessageMs={1000}
/>
);
});

const message = container.querySelector('.splash-screen__message');
expect(message?.textContent).toBe('Loading workspace...');
expect(message?.classList.contains('splash-screen__message--visible')).toBe(false);

act(() => {
vi.advanceTimersByTime(999);
});
expect(message?.classList.contains('splash-screen__message--visible')).toBe(false);

act(() => {
vi.advanceTimersByTime(1);
});
expect(message?.classList.contains('splash-screen__message--visible')).toBe(true);
});

it('does not reveal the workspace loading message during the normal startup splash window by default', () => {
act(() => {
root.render(
<SplashScreen
isExiting={false}
onExited={() => {}}
delayedMessage="Loading workspace..."
/>
);
});

const message = container.querySelector('.splash-screen__message');
expect(message?.classList.contains('splash-screen__message--visible')).toBe(false);

act(() => {
vi.advanceTimersByTime(1799);
});
expect(message?.classList.contains('splash-screen__message--visible')).toBe(false);

act(() => {
vi.advanceTimersByTime(1);
});
expect(message?.classList.contains('splash-screen__message--visible')).toBe(true);
});

it('does not show the delayed message while exiting', () => {
act(() => {
root.render(
<SplashScreen
isExiting={true}
onExited={() => {}}
delayedMessage="Loading workspace..."
delayedMessageMs={1000}
/>
);
});

act(() => {
vi.advanceTimersByTime(1000);
});

const message = container.querySelector('.splash-screen__message');
expect(message?.classList.contains('splash-screen__message--visible')).toBe(false);
});
});
38 changes: 35 additions & 3 deletions src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,42 @@
* Exiting: logo scales up and fades; backdrop dissolves.
*/

import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useState } from 'react';
import './SplashScreen.scss';

const DEFAULT_LOADING_MESSAGE_DELAY_MS = 1800;

interface SplashScreenProps {
isExiting: boolean;
onExited: () => void;
delayedMessage?: string;
delayedMessageMs?: number;
}

const SplashScreen: React.FC<SplashScreenProps> = ({ isExiting, onExited }) => {
const SplashScreen: React.FC<SplashScreenProps> = ({
isExiting,
onExited,
delayedMessage,
delayedMessageMs = DEFAULT_LOADING_MESSAGE_DELAY_MS,
}) => {
const [showDelayedMessage, setShowDelayedMessage] = useState(false);
const handleExited = useCallback(() => {
onExited();
}, [onExited]);

useEffect(() => {
setShowDelayedMessage(false);

if (!delayedMessage || isExiting) {
return;
}

const timer = window.setTimeout(() => {
setShowDelayedMessage(true);
}, delayedMessageMs);
return () => window.clearTimeout(timer);
}, [delayedMessage, delayedMessageMs, isExiting]);

// Remove from DOM after exit animation completes (~650 ms).
useEffect(() => {
if (!isExiting) return;
Expand All @@ -28,7 +51,7 @@ const SplashScreen: React.FC<SplashScreenProps> = ({ isExiting, onExited }) => {
return (
<div
className={`splash-screen${isExiting ? ' splash-screen--exiting' : ''}`}
aria-hidden="true"
aria-hidden={!showDelayedMessage}
>
<div className="splash-screen__center">
<div className="splash-screen__logo-wrap">
Expand All @@ -39,6 +62,15 @@ const SplashScreen: React.FC<SplashScreenProps> = ({ isExiting, onExited }) => {
draggable={false}
/>
</div>
{delayedMessage && (
<div
className={`splash-screen__message${showDelayedMessage ? ' splash-screen__message--visible' : ''}`}
role="status"
aria-live="polite"
>
{delayedMessage}
</div>
)}
</div>
</div>
);
Expand Down
Loading
Loading