From 5aa6b2cc1b22cfb2c627ebd1a75f38c35b49afd4 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 30 May 2026 17:17:34 +0800 Subject: [PATCH 1/2] perf(startup): defer editor and AI initialization --- src/web-ui/src/app/App.tsx | 26 +-- .../components/panels/base/FlexiblePanel.tsx | 210 +++++++++++------- src/web-ui/src/app/scenes/shell/ShellNav.tsx | 2 +- .../startupPerformanceContract.test.ts | 101 +++++++++ .../components/Markdown/Markdown.tsx | 4 +- .../src/component-library/components/index.ts | 2 - .../src/flow_chat/components/ChatInput.tsx | 2 +- .../tool-cards/GenerativeWidgetToolCard.tsx | 2 +- .../integrations/MonacoThemeSync.test.ts | 56 +++++ .../theme/integrations/MonacoThemeSync.ts | 132 +++++++---- src/web-ui/src/main.tsx | 72 +----- .../NotificationContextMenuProvider.ts | 2 +- .../services/ActiveEditTargetService.ts | 2 +- .../editor/services/MonacoInitManager.ts | 68 +++++- .../src/tools/editor/services/ThemeManager.ts | 31 ++- .../components/FileSearchResults.tsx | 2 +- .../useGenerativeWidgetPromptMenu.ts | 2 +- .../src/tools/git/services/GitEventService.ts | 2 +- 18 files changed, 481 insertions(+), 237 deletions(-) create mode 100644 src/web-ui/src/app/startup/startupPerformanceContract.test.ts create mode 100644 src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.test.ts diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 925578058..0d1c1060a 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -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'; @@ -45,9 +44,6 @@ 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'); // Workspace loading state — drives splash exit timing @@ -148,6 +144,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]); @@ -295,23 +294,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(() => { diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index 9aa569784..012d74edf 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback, memo } from 'react'; import { Download, Copy, X, AlertCircle } from 'lucide-react'; import { MarkdownRenderer, IconButton } from '@/component-library'; -import { CodeEditor, MarkdownEditor, ImageViewer, DiffEditor } from '@/tools/editor'; import { useI18n } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; import { globalEventBus } from '@/infrastructure/event-bus'; @@ -46,8 +45,35 @@ const GitSettingsView = React.lazy(() => import('@/tools/git/components/GitSettingsView/GitSettingsView') ); -// Directly imported (not lazy-loaded) to avoid loading delay in frequently used Git panel -import { GitDiffEditor } from '@/tools/git/components/GitDiffEditor/GitDiffEditor'; +const CodeEditor = React.lazy(() => + import('@/tools/editor/components/CodeEditor').then(module => ({ + default: module.default, + })) +); + +const MarkdownEditor = React.lazy(() => + import('@/tools/editor/components/MarkdownEditor').then(module => ({ + default: module.default, + })) +); + +const ImageViewer = React.lazy(() => + import('@/tools/editor/components/ImageViewer').then(module => ({ + default: module.default, + })) +); + +const DiffEditor = React.lazy(() => + import('@/tools/editor/components/DiffEditor').then(module => ({ + default: module.default, + })) +); + +const GitDiffEditor = React.lazy(() => + import('@/tools/git/components/GitDiffEditor/GitDiffEditor').then(module => ({ + default: module.default, + })) +); const GitGraphView = React.lazy(() => import('@/tools/git/components/GitGraphView/GitGraphView').then(module => ({ @@ -216,6 +242,18 @@ const FlexiblePanel: React.FC = memo(({ URL.revokeObjectURL(url); }, [content]); + const renderEditorLoading = () => ( +
+ {t('select.loading')} +
+ ); + + const renderLazyEditor = (node: React.ReactNode) => ( + + {node} + + ); + const renderContent = () => { if (!content || content.type === 'empty') { return ( @@ -260,27 +298,29 @@ const FlexiblePanel: React.FC = memo(({ return (
{markdownFilePath || markdownInitialContent !== undefined ? ( - { - if (onDirtyStateChange) { - onDirtyStateChange(hasChanges); - } - }} - onSave={(_savedContent) => { - if (onDirtyStateChange) { - onDirtyStateChange(false); - } - }} - /> + renderLazyEditor( + { + if (onDirtyStateChange) { + onDirtyStateChange(hasChanges); + } + }} + onSave={(_savedContent) => { + if (onDirtyStateChange) { + onDirtyStateChange(false); + } + }} + /> + ) ) : (
@@ -306,17 +346,19 @@ const FlexiblePanel: React.FC = memo(({ return (
- + {renderLazyEditor( + + )}
); } @@ -326,12 +368,14 @@ const FlexiblePanel: React.FC = memo(({ return (
- + {renderLazyEditor( + + )}
); } @@ -344,18 +388,20 @@ const FlexiblePanel: React.FC = memo(({ return (
- + {renderLazyEditor( + + )}
); @@ -430,7 +476,7 @@ const FlexiblePanel: React.FC = memo(({ } }; - return ( + return renderLazyEditor( = memo(({ isActiveTab={isActive} onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(newContent, hasChanges) => { - if (onContentChange) { - onContentChange({ - ...content, - data: { - ...editorData, - content: newContent, - hasChanges - } - }); - } - - if (onDirtyStateChange) { - onDirtyStateChange(hasChanges); - } + if (onContentChange) { + onContentChange({ + ...content, + data: { + ...editorData, + content: newContent, + hasChanges + } + }); + } - void syncGenerativeWidgetToolResult(newContent, false); - }} - onSave={(content) => { - if (onInteraction) { - onInteraction('save', JSON.stringify({ filePath, content })); - } - - if (onDirtyStateChange) { - onDirtyStateChange(false); - } + if (onDirtyStateChange) { + onDirtyStateChange(hasChanges); + } - void syncGenerativeWidgetToolResult(content, true); - }} + void syncGenerativeWidgetToolResult(newContent, false); + }} + onSave={(content) => { + if (onInteraction) { + onInteraction('save', JSON.stringify({ filePath, content })); + } + + if (onDirtyStateChange) { + onDirtyStateChange(false); + } + + void syncGenerativeWidgetToolResult(content, true); + }} /> ); } @@ -492,7 +538,7 @@ const FlexiblePanel: React.FC = memo(({ const diffViewerKey = `diff-${diffFilePath || 'unknown'}-${originalCode.length}-${modifiedCode.length}`; if (diffRepositoryPath && diffFilePath) { - return ( + return renderLazyEditor( = memo(({ ); } - return ( + return renderLazyEditor( { + it('keeps editor and tool infrastructure out of the first startup module', () => { + const source = readSource('../../main.tsx'); + + expect(source).not.toMatch(/import\s+['"]monaco-editor\/min\/vs\/editor\/editor\.main\.css['"]/); + expect(source).not.toMatch(/from\s+['"]@monaco-editor\/react['"]/); + expect(source).not.toMatch(/from\s+['"]\.\/tools\/initializeTools['"]/); + expect(source).not.toMatch(/from\s+['"]\.\/shared\/context-menu-system['"]/); + + expect(source).toContain("import('./tools/initializeTools')"); + expect(source).toContain("import('./shared/context-menu-system')"); + }); + + it('starts non-critical work after the shell is interactive', () => { + const source = readSource('../../main.tsx'); + + expect(source).toContain("signalName: 'bitfun:interactive-shell-ready'"); + expect(source).not.toContain("signalName: 'bitfun:main-window-shown'"); + expect(source).toContain('fallbackTimeoutMs: 10000'); + }); + + it('does not initialize AI from the root app component', () => { + const source = readSource('../App.tsx'); + + expect(source).not.toMatch(/from\s+['"]\.\.\/infrastructure['"]/); + expect(source).not.toMatch(/useAIInitialization/); + expect(source).not.toMatch(/useCurrentModelConfig/); + expect(source).toContain('bitfun:interactive-shell-ready'); + }); + + it('loads Monaco styling and loader config only through editor initialization', () => { + const source = readSource('../../tools/editor/services/MonacoInitManager.ts'); + + expect(source).toContain("import('monaco-editor/min/vs/editor/editor.main.css')"); + expect(source).toContain('loader.config'); + expect(source).toContain('MonacoEnvironment'); + }); + + it('keeps editor panel implementations lazy from the session shell', () => { + const source = readSource('../components/panels/base/FlexiblePanel.tsx'); + const componentLibraryBarrel = readSource('../../component-library/components/index.ts'); + + expect(source).not.toMatch(/from\s+['"]@\/tools\/editor['"]/); + expect(source).not.toMatch(/from\s+['"]@\/tools\/git\/components\/GitDiffEditor\/GitDiffEditor['"]/); + expect(source).toContain("import('@/tools/editor/components/CodeEditor')"); + expect(source).toContain("import('@/tools/editor/components/DiffEditor')"); + expect(source).toContain("import('@/tools/git/components/GitDiffEditor/GitDiffEditor')"); + expect(source).toContain('renderLazyEditor('); + expect(componentLibraryBarrel).not.toMatch(/CodeEditor/); + }); + + it('keeps theme startup from importing the Monaco runtime', () => { + const source = readSource('../../infrastructure/theme/integrations/MonacoThemeSync.ts'); + + expect(source).not.toMatch(/import\s+\*\s+as\s+monaco\s+from\s+['"]monaco-editor['"]/); + expect(source).toMatch(/import\s+type\s+\*\s+as\s+Monaco\s+from\s+['"]monaco-editor['"]/); + expect(source).toContain('attachMonaco'); + }); + + it('does not import Monaco runtime from shared edit-target services', () => { + const source = readSource('../../tools/editor/services/ActiveEditTargetService.ts'); + + expect(source).not.toMatch(/import\s+\*\s+as\s+monaco\s+from\s+['"]monaco-editor['"]/); + expect(source).toMatch(/import\s+type\s+\*\s+as\s+monaco\s+from\s+['"]monaco-editor['"]/); + }); + + it('uses narrow context-menu imports from startup-visible modules', () => { + const sources = [ + '../../app/scenes/shell/ShellNav.tsx', + '../../component-library/components/Markdown/Markdown.tsx', + '../../flow_chat/tool-cards/GenerativeWidgetToolCard.tsx', + '../../tools/file-system/components/FileSearchResults.tsx', + '../../tools/generative-widget/useGenerativeWidgetPromptMenu.ts', + '../../shared/notification-system/providers/NotificationContextMenuProvider.ts', + ].map(readSource); + + for (const source of sources) { + expect(source).not.toMatch(/from\s+['"]@\/shared\/context-menu-system['"]/); + } + }); + + it('avoids the infrastructure barrel from startup-visible modules', () => { + const sources = [ + '../../flow_chat/components/ChatInput.tsx', + '../../tools/git/services/GitEventService.ts', + ].map(readSource); + + for (const source of sources) { + expect(source).not.toMatch(/from\s+['"]@\/infrastructure['"]/); + expect(source).not.toMatch(/from\s+['"]\.\.\/\.\.\/\.\.\/infrastructure['"]/); + } + }); +}); diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 8e508dbfa..57425ec05 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -20,7 +20,7 @@ import { Tooltip } from '../Tooltip'; import { globalAPI, systemAPI, workspaceAPI } from '../../../infrastructure/api'; import { getPrismLanguageFromAlias } from '@/infrastructure/language-detection'; import { useTheme } from '@/infrastructure/theme'; -import { contextMenuController } from '@/shared/context-menu-system'; +import { contextMenuController } from '@/shared/context-menu-system/core/ContextMenuController'; import { ContextType, type CustomContext, type MenuItem } from '@/shared/context-menu-system/types'; import { createLogger } from '@/shared/utils/logger'; import path from 'path-browserify'; @@ -239,7 +239,7 @@ function normalizeFileLikeHref(rawHref: string): string { } } - // Normalize paths like /C:/Users/... from URI forms to Windows absolute paths. + // Normalize URI-style Windows drive paths to native absolute paths. if (/^\/[A-Za-z]:[\\/]/.test(filePath)) { filePath = filePath.slice(1); } diff --git a/src/web-ui/src/component-library/components/index.ts b/src/web-ui/src/component-library/components/index.ts index 68ec01cf4..260a63fab 100644 --- a/src/web-ui/src/component-library/components/index.ts +++ b/src/web-ui/src/component-library/components/index.ts @@ -34,8 +34,6 @@ export * from './Card'; export * from './FilterPill'; export * from './ConfigPage'; -export * from './CodeEditor'; - export * from './StreamText'; export * from './TextStrokeEffect'; export * from './CubeLogo'; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 344240e1d..77e78e5d5 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -11,7 +11,7 @@ import { ContextDropZone, useContextStore } from '../../shared/context-system'; import { useActiveSessionState } from '@/flow_chat/hooks'; import { RichTextInput, type MentionState } from './RichTextInput'; import { FileMentionPicker } from './FileMentionPicker'; -import { globalEventBus } from '@/infrastructure'; +import { globalEventBus } from '@/infrastructure/event-bus'; import { useSessionDerivedState, useSessionStateMachine, diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx index dcce9523b..b910f0b69 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx @@ -11,7 +11,7 @@ import GenerativeWidgetFrame, { import GenerativeWidgetStaticRenderer from '@/tools/generative-widget/GenerativeWidgetStaticRenderer'; import { handleWidgetBridgeEvent } from '@/tools/generative-widget/widgetInteraction'; import { useGenerativeWidgetPromptMenu } from '@/tools/generative-widget/useGenerativeWidgetPromptMenu'; -import { useContextMenuStore } from '@/shared/context-menu-system'; +import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; import { captureElementToDownloadsPng } from '../utils/captureElementToDownloadsPng'; import { createLogger } from '@/shared/utils/logger'; import { notificationService } from '@/shared/notification-system'; diff --git a/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.test.ts b/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.test.ts new file mode 100644 index 000000000..225c70dab --- /dev/null +++ b/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { bitfunDarkTheme } from '../presets/dark-theme'; +import { MonacoThemeSync } from './MonacoThemeSync'; + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +function createMonacoStub() { + const defineTheme = vi.fn(); + const setTheme = vi.fn(); + const getEditors = vi.fn(() => []); + + return { + monaco: { + editor: { + defineTheme, + setTheme, + getEditors, + }, + }, + defineTheme, + setTheme, + }; +} + +describe('MonacoThemeSync deferred runtime behavior', () => { + it('keeps custom theme registrations until Monaco runtime is attached', () => { + const sync = new MonacoThemeSync(); + const queuedTheme = { + ...bitfunDarkTheme, + id: 'queued-theme', + name: 'Queued theme', + }; + const { monaco, defineTheme } = createMonacoStub(); + + sync.registerTheme(queuedTheme.id, queuedTheme); + expect(defineTheme).not.toHaveBeenCalled(); + + sync.attachMonaco(monaco as never); + + expect(defineTheme).toHaveBeenCalledWith( + queuedTheme.id, + expect.objectContaining({ + base: queuedTheme.monaco?.base, + inherit: queuedTheme.monaco?.inherit, + }), + ); + }); +}); diff --git a/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.ts b/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.ts index 938b027ee..91223fa6a 100644 --- a/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.ts +++ b/src/web-ui/src/infrastructure/theme/integrations/MonacoThemeSync.ts @@ -1,6 +1,6 @@ -import * as monaco from 'monaco-editor'; +import type * as Monaco from 'monaco-editor'; import { ThemeConfig } from '../types'; import { BitFunDarkTheme } from '@/tools/editor/themes/bitfun-dark.theme'; import { createLogger } from '@/shared/utils/logger'; @@ -10,7 +10,7 @@ const log = createLogger('MonacoThemeSync'); const SEMANTIC_HIGHLIGHTING_RULES = BitFunDarkTheme.rules; -function getBitfunLightMonacoTheme(): monaco.editor.IStandaloneThemeData { +function getBitfunLightMonacoTheme(): Monaco.editor.IStandaloneThemeData { return { base: 'vs', inherit: true, @@ -73,56 +73,92 @@ function convertColorsToHex(colors: Record): Record(); - async initialize(): Promise { if (this.initialized) { return; } - - - try { - monaco.editor.defineTheme('bitfun-dark', BitFunDarkTheme); - log.debug('BitFun Dark theme registered'); - this.initialized = true; - } catch (error) { - log.warn('Monaco Editor not loaded yet, will retry later', error); + + this.initialized = true; + if (this.monacoInstance) { + this.ensureBuiltinThemes(this.monacoInstance); } } - - syncTheme(theme: ThemeConfig): void { + syncTheme(theme: ThemeConfig): string { + this.pendingTheme = theme; + const targetThemeId = this.getTargetMonacoThemeId(theme); + + if (!this.monacoInstance) { + log.debug('Monaco runtime not loaded; theme sync deferred', { themeId: targetThemeId }); + return targetThemeId; + } + + this.applyTheme(this.monacoInstance, theme, targetThemeId); + return targetThemeId; + } + + attachMonaco(monacoInstance: typeof Monaco, theme?: ThemeConfig): string { + this.monacoInstance = monacoInstance; + this.initialized = true; + this.ensureBuiltinThemes(monacoInstance); + this.flushPendingRegisteredThemes(monacoInstance); + + const activeTheme = theme ?? this.pendingTheme; + if (!activeTheme) { + return this.currentThemeId ?? 'bitfun-dark'; + } + + const targetThemeId = this.getTargetMonacoThemeId(activeTheme); + this.applyTheme(monacoInstance, activeTheme, targetThemeId); + return targetThemeId; + } + + private ensureBuiltinThemes(monacoInstance: typeof Monaco): void { try { - let targetThemeId: string; + monacoInstance.editor.defineTheme('bitfun-dark', BitFunDarkTheme); + monacoInstance.editor.defineTheme('bitfun-light', getBitfunLightMonacoTheme()); + log.debug('BitFun Monaco themes registered'); + } catch (error) { + log.warn('Failed to register BitFun Monaco themes', error); + } + } - if (theme.monaco) { - targetThemeId = theme.id; - } else { - if (theme.type === 'dark') { - targetThemeId = 'bitfun-dark'; - } else { - targetThemeId = 'bitfun-light'; - } - } + private flushPendingRegisteredThemes(monacoInstance: typeof Monaco): void { + if (this.pendingRegisteredThemes.size === 0) { + return; + } + for (const [themeId, theme] of this.pendingRegisteredThemes) { + this.defineTheme(monacoInstance, themeId, theme); + } + this.pendingRegisteredThemes.clear(); + } + + private applyTheme( + monacoInstance: typeof Monaco, + theme: ThemeConfig, + targetThemeId: string, + ): void { + try { if (this.currentThemeId === targetThemeId) { return; } if (theme.monaco) { const monacoTheme = this.convertToMonacoTheme(theme); - monaco.editor.defineTheme(theme.id, monacoTheme); + monacoInstance.editor.defineTheme(theme.id, monacoTheme); log.debug('Custom theme registered', { themeId: theme.id, themeName: theme.name }); } else { - if (theme.type === 'light') { - monaco.editor.defineTheme('bitfun-light', getBitfunLightMonacoTheme()); - } log.debug('Using builtin theme', { themeId: targetThemeId }); } - monaco.editor.setTheme(targetThemeId); + monacoInstance.editor.setTheme(targetThemeId); - const editors = monaco.editor.getEditors(); + const editors = monacoInstance.editor.getEditors(); if (editors && editors.length > 0) { log.debug('Refreshing editor instances', { count: editors.length }); editors.forEach((editor, index) => { @@ -137,9 +173,11 @@ export class MonacoThemeSync { this.currentThemeId = targetThemeId; log.info('Theme switched successfully', { themeName: theme.name, themeId: targetThemeId }); - window.dispatchEvent(new CustomEvent('monaco-theme-changed', { - detail: { themeId: targetThemeId, theme } - })); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('monaco-theme-changed', { + detail: { themeId: targetThemeId, theme } + })); + } } catch (error) { log.error('Failed to sync theme', error); } @@ -166,16 +204,9 @@ export class MonacoThemeSync { * Use from the Monaco React wrapper `beforeMount` hook so themes exist on the loader's Monaco * before the editor is created (avoids falling back to the default light theme). */ - registerThemesForEditorInstance(monacoInstance: typeof monaco, theme: ThemeConfig): string { + registerThemesForEditorInstance(monacoInstance: typeof Monaco, theme: ThemeConfig): string { try { - monacoInstance.editor.defineTheme('bitfun-dark', BitFunDarkTheme); - monacoInstance.editor.defineTheme('bitfun-light', getBitfunLightMonacoTheme()); - - if (theme.monaco) { - monacoInstance.editor.defineTheme(theme.id, this.convertToMonacoTheme(theme)); - return theme.id; - } - return this.getTargetMonacoThemeId(theme); + return this.attachMonaco(monacoInstance, theme); } catch (error) { log.error('registerThemesForEditorInstance failed', error); return 'bitfun-dark'; @@ -183,7 +214,7 @@ export class MonacoThemeSync { } - private convertToMonacoTheme(theme: ThemeConfig): monaco.editor.IStandaloneThemeData { + private convertToMonacoTheme(theme: ThemeConfig): Monaco.editor.IStandaloneThemeData { const { monaco: monacoConfig, colors } = theme; if (!monacoConfig) { @@ -214,7 +245,7 @@ export class MonacoThemeSync { : SEMANTIC_HIGHLIGHTING_RULES; - const themeData: monaco.editor.IStandaloneThemeData = { + const themeData: Monaco.editor.IStandaloneThemeData = { base: monacoConfig.base, inherit: monacoConfig.inherit, rules: themeRules, @@ -333,13 +364,22 @@ export class MonacoThemeSync { registerTheme(themeId: string, theme: ThemeConfig): void { try { - const monacoTheme = this.convertToMonacoTheme(theme); - monaco.editor.defineTheme(themeId, monacoTheme); - log.debug('Theme registered', { themeId }); + if (!this.monacoInstance) { + this.pendingRegisteredThemes.set(themeId, theme); + log.debug('Monaco runtime not loaded; custom theme registration deferred', { themeId }); + return; + } + this.defineTheme(this.monacoInstance, themeId, theme); } catch (error) { log.error('Failed to register theme', { themeId, error }); } } + + private defineTheme(monacoInstance: typeof Monaco, themeId: string, theme: ThemeConfig): void { + const monacoTheme = this.convertToMonacoTheme(theme); + monacoInstance.editor.defineTheme(themeId, monacoTheme); + log.debug('Theme registered', { themeId }); + } } diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index ab9c4437c..a432d0bff 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -5,17 +5,9 @@ import AppErrorBoundary from "./app/components/AppErrorBoundary"; import { WorkspaceProvider } from "./infrastructure/contexts/WorkspaceProvider"; import "./app/styles/index.scss"; -// Manually import Monaco Editor CSS. -// This ensures the CSS loads correctly in Tauri production. -import 'monaco-editor/min/vs/editor/editor.main.css'; - // Font: Noto Sans SC is loaded via a tag in index.html. // File path: public/fonts/fonts.css, served as /fonts/fonts.css. -import { initializeAllTools } from "./tools/initializeTools"; -import { initContextMenuSystem } from "./shared/context-menu-system"; -import { loader } from '@monaco-editor/react'; -import { getMonacoPath, getMonacoWorkerPath, logMonacoResourceCheck } from './tools/editor/utils/monacoPathHelper'; import { bootstrapLogger, createLogger, initLogger } from './shared/utils/logger'; import { elapsedMs, logElapsed, measureAsyncAndLog, nowMs } from './shared/utils/timing'; import { startupTrace } from './shared/utils/startupTrace'; @@ -181,53 +173,6 @@ document.addEventListener( true ); -// Configure Monaco Editor loader - use local files (offline-ready). -const isDev = import.meta.env.DEV; -const monacoPath = getMonacoPath(); - -loader.config({ - paths: { - vs: monacoPath - } -}); - -// Debug: check resource availability in production. -if (!isDev) { - // Delay checks to avoid blocking startup. - setTimeout(() => { - logMonacoResourceCheck().catch(err => { - log.error('Monaco resource check failed', err); - }); - }, 2000); -} - -// Optimization: Monaco Editor worker mapping. -const MONACO_WORKER_MAP: Record = { - json: 'language/json/jsonWorker.js', - css: 'language/css/cssWorker.js', - scss: 'language/css/cssWorker.js', - less: 'language/css/cssWorker.js', - html: 'language/html/htmlWorker.js', - handlebars: 'language/html/htmlWorker.js', - razor: 'language/html/htmlWorker.js', - typescript: 'language/typescript/tsWorker.js', - javascript: 'language/typescript/tsWorker.js', -}; - -const DEFAULT_WORKER = 'base/worker/workerMain.js'; - -(window as any).MonacoEnvironment = { - getWorker(_workerId: string, label: string) { - const workerFile = MONACO_WORKER_MAP[label] || DEFAULT_WORKER; - const workerPath = getMonacoWorkerPath(workerFile); - - return new Worker(workerPath, { - type: 'classic', - name: `monaco-${label}-worker` - }); - } -}; - /** Logger, theme, and minimal deps — must finish before first React paint (F5 / webview reload does not re-run Tauri init script). */ async function initializeBeforeRender(): Promise { const phaseStartedAt = nowMs(); @@ -243,7 +188,6 @@ async function initializeBeforeRender(): Promise { data: { step: 'initializeFrontendLogLevelSync' }, }); - log.debug('Monaco loader configured', { vs: monacoPath, isDev }); log.info('Initializing BitFun'); await measureAsyncAndLog(log, 'Startup step completed', async () => { @@ -261,7 +205,7 @@ async function initializeBeforeRender(): Promise { }); } -/** Rest of startup runs after the shell is visible so refresh latency stays reasonable. */ +/** Rest of startup runs after the shell is interactive so first-screen latency stays reasonable. */ async function initializeAfterRender(): Promise { const phaseStartedAt = nowMs(); startupTrace.markPhase('after_render_start'); @@ -294,8 +238,12 @@ async function initializeAfterRender(): Promise { const { initRecommendationProviders } = await import('./flow_chat/components/smart-recommendations'); initRecommendationProviders(); })(), - initializeAllTools(), (async () => { + const { initializeAllTools } = await import('./tools/initializeTools'); + await initializeAllTools(); + })(), + (async () => { + const { initContextMenuSystem } = await import('./shared/context-menu-system'); initContextMenuSystem({ registerBuiltinCommands: true, registerBuiltinProviders: true, @@ -392,8 +340,8 @@ async function startApplication(): Promise { }); startupTrace.markPhase('non_critical_init_scheduled', { - signalName: 'bitfun:main-window-shown', - fallbackTimeoutMs: 2000, + signalName: 'bitfun:interactive-shell-ready', + fallbackTimeoutMs: 10000, frameCount: 1, }); scheduleAfterStartupSignal(async () => { @@ -411,8 +359,8 @@ async function startApplication(): Promise { }); } }, { - signalName: 'bitfun:main-window-shown', - fallbackTimeoutMs: 2000, + signalName: 'bitfun:interactive-shell-ready', + fallbackTimeoutMs: 10000, frameCount: 1, onError: error => { log.error('Failed to schedule post-render initialization', error); diff --git a/src/web-ui/src/shared/notification-system/providers/NotificationContextMenuProvider.ts b/src/web-ui/src/shared/notification-system/providers/NotificationContextMenuProvider.ts index bdf6938dd..1512b7fd6 100644 --- a/src/web-ui/src/shared/notification-system/providers/NotificationContextMenuProvider.ts +++ b/src/web-ui/src/shared/notification-system/providers/NotificationContextMenuProvider.ts @@ -1,6 +1,6 @@ -import { contextMenuRegistry } from '@/shared/context-menu-system'; +import { contextMenuRegistry } from '@/shared/context-menu-system/core/ContextMenuRegistry'; import type { MenuContext, MenuItem } from '@/shared/context-menu-system/types'; import { i18nService } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; diff --git a/src/web-ui/src/tools/editor/services/ActiveEditTargetService.ts b/src/web-ui/src/tools/editor/services/ActiveEditTargetService.ts index 5eadb5b49..14963113b 100644 --- a/src/web-ui/src/tools/editor/services/ActiveEditTargetService.ts +++ b/src/web-ui/src/tools/editor/services/ActiveEditTargetService.ts @@ -1,4 +1,4 @@ -import * as monaco from 'monaco-editor'; +import type * as monaco from 'monaco-editor'; import { createLogger } from '@/shared/utils/logger'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; diff --git a/src/web-ui/src/tools/editor/services/MonacoInitManager.ts b/src/web-ui/src/tools/editor/services/MonacoInitManager.ts index 4339a8775..268a5b213 100644 --- a/src/web-ui/src/tools/editor/services/MonacoInitManager.ts +++ b/src/web-ui/src/tools/editor/services/MonacoInitManager.ts @@ -12,17 +12,34 @@ import { loader } from '@monaco-editor/react'; import type * as Monaco from 'monaco-editor'; import { registerMermaidLanguage } from '../languages/mermaid.language'; import { registerTomlLanguage } from '../languages/toml.language'; +import { getMonacoPath, getMonacoWorkerPath, logMonacoResourceCheck } from '../utils/monacoPathHelper'; import { themeManager } from './ThemeManager'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('MonacoInitManager'); +const MONACO_WORKER_MAP: Record = { + json: 'language/json/jsonWorker.js', + css: 'language/css/cssWorker.js', + scss: 'language/css/cssWorker.js', + less: 'language/css/cssWorker.js', + html: 'language/html/htmlWorker.js', + handlebars: 'language/html/htmlWorker.js', + razor: 'language/html/htmlWorker.js', + typescript: 'language/typescript/tsWorker.js', + javascript: 'language/typescript/tsWorker.js', +}; + +const DEFAULT_WORKER = 'base/worker/workerMain.js'; + class MonacoInitManager { private static instance: MonacoInitManager; private initPromise: Promise | null = null; private monaco: typeof Monaco | null = null; private editorOpenerRegistered = false; + private loaderConfigured = false; + private resourceCheckScheduled = false; private constructor() {} @@ -51,7 +68,9 @@ class MonacoInitManager { private async doInitialize(): Promise { try { log.info('Initializing Monaco Editor'); - + + this.configureLoader(); + await import('monaco-editor/min/vs/editor/editor.main.css'); const monaco = await loader.init(); this.configureTypeScriptLanguage(monaco); @@ -71,6 +90,51 @@ class MonacoInitManager { throw error; } } + + private configureLoader(): void { + if (this.loaderConfigured) { + return; + } + + const monacoPath = getMonacoPath(); + loader.config({ + paths: { + vs: monacoPath, + }, + }); + + (window as any).MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + const workerFile = MONACO_WORKER_MAP[label] || DEFAULT_WORKER; + const workerPath = getMonacoWorkerPath(workerFile); + + return new Worker(workerPath, { + type: 'classic', + name: `monaco-${label}-worker`, + }); + }, + }; + + this.loaderConfigured = true; + log.debug('Monaco loader configured', { + vs: monacoPath, + isDev: import.meta.env.DEV, + }); + this.scheduleResourceCheck(); + } + + private scheduleResourceCheck(): void { + if (import.meta.env.DEV || this.resourceCheckScheduled) { + return; + } + + this.resourceCheckScheduled = true; + window.setTimeout(() => { + logMonacoResourceCheck().catch(err => { + log.error('Monaco resource check failed', err); + }); + }, 2000); + } private configureTypeScriptLanguage(monaco: typeof Monaco): void { try { @@ -302,6 +366,8 @@ class MonacoInitManager { this.initPromise = null; this.monaco = null; this.editorOpenerRegistered = false; + this.loaderConfigured = false; + this.resourceCheckScheduled = false; } } diff --git a/src/web-ui/src/tools/editor/services/ThemeManager.ts b/src/web-ui/src/tools/editor/services/ThemeManager.ts index 0f12a836a..36fc5624e 100644 --- a/src/web-ui/src/tools/editor/services/ThemeManager.ts +++ b/src/web-ui/src/tools/editor/services/ThemeManager.ts @@ -135,25 +135,32 @@ class ThemeManager { private async syncWithThemeService(): Promise { try { const { themeService } = await import('@/infrastructure/theme'); + const { monacoThemeSync } = await import('@/infrastructure/theme/integrations/MonacoThemeSync'); const currentTheme = themeService.getCurrentTheme(); if (currentTheme) { - const themeId = currentTheme.monaco - ? currentTheme.id - : (currentTheme.type === 'dark' ? this.getDefaultThemeId() : 'vs'); - - this.currentThemeId = themeId; - const { monacoThemeSync } = await import('@/infrastructure/theme/integrations/MonacoThemeSync'); - monacoThemeSync.syncTheme(currentTheme); + this.currentThemeId = monacoThemeSync.attachMonaco(monaco, currentTheme); + this.registeredThemes.add('bitfun-dark'); + this.registeredThemes.add('bitfun-light'); + if (currentTheme.monaco) { + this.registeredThemes.add(currentTheme.id); + } } themeService.on('theme:after-change', (event) => { if (event.theme) { - const newThemeId = event.theme.monaco - ? event.theme.id - : (event.theme.type === 'dark' ? this.getDefaultThemeId() : 'vs'); - - this.setTheme(newThemeId); + const previousThemeId = this.currentThemeId; + const newThemeId = monacoThemeSync.syncTheme(event.theme); + if (event.theme.monaco) { + this.registeredThemes.add(event.theme.id); + } + if (newThemeId !== previousThemeId) { + this.currentThemeId = newThemeId; + this.notifyListeners({ + previousThemeId, + currentThemeId: newThemeId, + }); + } } }); diff --git a/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx b/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx index af6717a6f..7558302a1 100644 --- a/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx +++ b/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx @@ -7,7 +7,7 @@ import type { import { useI18n } from '@/infrastructure/i18n'; import { i18nService } from '@/infrastructure/i18n'; import { notificationService } from '@/shared/notification-system'; -import { useContextMenuStore } from '@/shared/context-menu-system'; +import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; import { ContextType } from '@/shared/context-menu-system/types/context.types'; import type { MenuItem } from '@/shared/context-menu-system/types/menu.types'; import { addFileMentionToChat, type FileMentionTarget } from '@/shared/utils/chatContext'; diff --git a/src/web-ui/src/tools/generative-widget/useGenerativeWidgetPromptMenu.ts b/src/web-ui/src/tools/generative-widget/useGenerativeWidgetPromptMenu.ts index cc1d23944..6c01c6f49 100644 --- a/src/web-ui/src/tools/generative-widget/useGenerativeWidgetPromptMenu.ts +++ b/src/web-ui/src/tools/generative-widget/useGenerativeWidgetPromptMenu.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { globalEventBus } from '@/infrastructure/event-bus'; -import { useContextMenuStore } from '@/shared/context-menu-system'; +import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; import { ContextType } from '@/shared/context-menu-system/types/context.types'; import type { MenuItem } from '@/shared/context-menu-system/types/menu.types'; import { notificationService } from '@/shared/notification-system'; diff --git a/src/web-ui/src/tools/git/services/GitEventService.ts b/src/web-ui/src/tools/git/services/GitEventService.ts index 91c246c53..1882422f3 100644 --- a/src/web-ui/src/tools/git/services/GitEventService.ts +++ b/src/web-ui/src/tools/git/services/GitEventService.ts @@ -2,7 +2,7 @@ * Git event service - manages Git event pub/sub */ -import { globalEventBus } from '../../../infrastructure'; +import { globalEventBus } from '../../../infrastructure/event-bus'; import { GitEvent, GitEventType, From 20d6a235b4ee5136bb41469d571b49631d86296b Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 30 May 2026 18:48:34 +0800 Subject: [PATCH 2/2] perf(startup): measure editor delay and smooth loading splash --- src/web-ui/src/app/App.tsx | 39 ++- .../components/SplashScreen/SplashScreen.scss | 29 +- .../SplashScreen/SplashScreen.test.tsx | 103 +++++++ .../components/SplashScreen/SplashScreen.tsx | 38 ++- .../startupPerformanceContract.test.ts | 15 ++ src/web-ui/src/locales/en-US/common.json | 3 +- src/web-ui/src/locales/zh-CN/common.json | 3 +- src/web-ui/src/locales/zh-TW/common.json | 3 +- .../services/MonacoStartupWarmup.test.ts | 56 +++- .../editor/services/MonacoStartupWarmup.ts | 111 ++++++++ .../GitDiffEditor/GitDiffEditor.tsx | 2 +- .../editor-first-open-perf.spec.ts | 253 ++++++++++++++++++ 12 files changed, 641 insertions(+), 14 deletions(-) create mode 100644 src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx create mode 100644 tests/e2e/specs/performance/editor-first-open-perf.spec.ts diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 0d1c1060a..94bc6e780 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -45,6 +45,7 @@ const MIN_SPLASH_MS = 900; function App() { const { t } = useI18n('settings/basics'); + const { t: tCommon } = useI18n('common'); // Workspace loading state — drives splash exit timing const { loading: workspaceLoading } = useWorkspaceContext(); @@ -189,6 +190,38 @@ function App() { return () => startupSystemsHandle.cancel(); }, [interactiveShellReady]); + useEffect(() => { + if (!interactiveShellReady || splashVisible) { + return; + } + + let disposed = false; + let editorWarmupHandle: { promise: Promise; 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; @@ -418,7 +451,11 @@ function App() { {/* Startup splash — sits above everything, exits once workspace is ready */} {splashVisible && ( - + )} diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss b/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss index 5e2f00eb8..f951a5eb1 100644 --- a/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.scss @@ -37,8 +37,8 @@ position: relative; z-index: 1; display: flex; - flex-direction: column; align-items: center; + justify-content: center; } // ── Logo ────────────────────────────────────────────────────────────────────── @@ -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 @@ -86,6 +109,10 @@ animation: none; } + .splash-screen__message { + transition: none; + } + .splash-screen--exiting { animation: splash-bg-exit 0.15s ease-out both; } diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx b/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx new file mode 100644 index 000000000..68e5dd54e --- /dev/null +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.test.tsx @@ -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('
'); + 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( + {}} + 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( + {}} + 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( + {}} + 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); + }); +}); diff --git a/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx b/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx index ea06ba39f..10f22a3a9 100644 --- a/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx +++ b/src/web-ui/src/app/components/SplashScreen/SplashScreen.tsx @@ -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 = ({ isExiting, onExited }) => { +const SplashScreen: React.FC = ({ + 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; @@ -28,7 +51,7 @@ const SplashScreen: React.FC = ({ isExiting, onExited }) => { return ( ); diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index f45b86a5c..3b1ebce94 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -72,6 +72,21 @@ describe('startup performance contract', () => { expect(source).toMatch(/import\s+type\s+\*\s+as\s+monaco\s+from\s+['"]monaco-editor['"]/); }); + it('prewarms editor runtime only after the shell is interactive', () => { + const source = readSource('../App.tsx'); + + expect(source).toContain('interactiveShellReady'); + expect(source).toContain("import('@/tools/editor/services/MonacoStartupWarmup')"); + expect(source).toContain('scheduleMonacoStartupWarmup()'); + }); + + it('keeps Git diff editor from importing the broad editor barrel', () => { + const source = readSource('../../tools/git/components/GitDiffEditor/GitDiffEditor.tsx'); + + expect(source).not.toMatch(/from\s+['"]@\/tools\/editor['"]/); + expect(source).toContain("from '@/tools/editor/components/DiffEditor'"); + }); + it('uses narrow context-menu imports from startup-visible modules', () => { const sources = [ '../../app/scenes/shell/ShellNav.tsx', diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index a72dab57b..bdc57d443 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -904,7 +904,8 @@ "panelView": "Panel View" }, "loading": { - "scenes": "Loading scene" + "scenes": "Loading scene", + "workspace": "Loading workspace..." }, "welcomeScene": { "tabLabel": "Welcome", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index a37c47c68..ef23d2f4c 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -904,7 +904,8 @@ "panelView": "面板视图" }, "loading": { - "scenes": "正在加载场景" + "scenes": "正在加载场景", + "workspace": "正在加载工作区..." }, "welcomeScene": { "tabLabel": "欢迎使用", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index dc3c4aae2..fc359667e 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -904,7 +904,8 @@ "panelView": "面板視圖" }, "loading": { - "scenes": "正在加載場景" + "scenes": "正在加載場景", + "workspace": "正在加載工作區..." }, "welcomeScene": { "tabLabel": "歡迎使用", diff --git a/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts index 676ca9e8b..b7bd0f55c 100644 --- a/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts +++ b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.test.ts @@ -2,9 +2,23 @@ import { describe, expect, it, vi } from 'vitest'; import { scheduleMonacoStartupWarmup } from './MonacoStartupWarmup'; describe('scheduleMonacoStartupWarmup', () => { - it('schedules Monaco initialization as low-priority idle background work', async () => { - const initializeMonaco = vi.fn(async () => undefined); - const initializeThemeSync = vi.fn(async () => undefined); + it('schedules editor runtime warmup as low-priority idle background work', async () => { + const order: string[] = []; + const initializeMonaco = vi.fn(async () => { + order.push('monaco'); + }); + const initializeThemeSync = vi.fn(async () => { + order.push('theme'); + }); + const preloadEditorSurfaceStages = [ + { name: 'code_editor', run: vi.fn(async () => { order.push('code'); }) }, + { name: 'diff_editor', run: vi.fn(async () => { order.push('diff'); }) }, + { name: 'git_diff_editor', run: vi.fn(async () => { order.push('git'); }) }, + ]; + const waitForIdle = vi.fn(async () => { + order.push('idle'); + }); + const trace = { markPhase: vi.fn() }; const signal = { aborted: false } as AbortSignal; const schedule = vi.fn((task: (signal: AbortSignal) => Promise, options: unknown) => ({ promise: task(signal), @@ -16,21 +30,48 @@ describe('scheduleMonacoStartupWarmup', () => { scheduler: { schedule }, initializeMonaco, initializeThemeSync, + preloadEditorSurfaceStages, + waitForIdle, + trace, }); + expect(trace.markPhase).toHaveBeenCalledWith('editor_startup_warmup_scheduled', { + idle: true, + priority: 'low', + }); expect(schedule).toHaveBeenCalledWith(expect.any(Function), { idle: true, inFlightKey: 'startup:monaco-warmup', priority: 'low', }); await expect(handle.promise).resolves.toBeUndefined(); + expect(preloadEditorSurfaceStages[0].run).toHaveBeenCalledTimes(1); + expect(preloadEditorSurfaceStages[1].run).toHaveBeenCalledTimes(1); + expect(preloadEditorSurfaceStages[2].run).toHaveBeenCalledTimes(1); + expect(waitForIdle).toHaveBeenCalledTimes(4); expect(initializeMonaco).toHaveBeenCalledTimes(1); expect(initializeThemeSync).toHaveBeenCalledTimes(1); + expect(order).toEqual([ + 'code', + 'idle', + 'diff', + 'idle', + 'git', + 'idle', + 'monaco', + 'idle', + 'theme', + ]); + expect(trace.markPhase).toHaveBeenCalledWith('editor_startup_warmup_end'); }); - it('skips theme sync when the warmup task is cancelled before Monaco resolves', async () => { + it('skips editor warmup work when cancelled before execution', async () => { const initializeMonaco = vi.fn(async () => undefined); const initializeThemeSync = vi.fn(async () => undefined); + const preloadEditorSurfaceStages = [ + { name: 'code_editor', run: vi.fn(async () => undefined) }, + ]; + const waitForIdle = vi.fn(async () => undefined); const signal = { aborted: true } as AbortSignal; const schedule = vi.fn((task: (signal: AbortSignal) => Promise, options: unknown) => ({ promise: task(signal), @@ -42,10 +83,15 @@ describe('scheduleMonacoStartupWarmup', () => { scheduler: { schedule }, initializeMonaco, initializeThemeSync, + preloadEditorSurfaceStages, + waitForIdle, + trace: { markPhase: vi.fn() }, }); await expect(handle.promise).resolves.toBeUndefined(); - expect(initializeMonaco).toHaveBeenCalledTimes(1); + expect(preloadEditorSurfaceStages[0].run).not.toHaveBeenCalled(); + expect(waitForIdle).not.toHaveBeenCalled(); + expect(initializeMonaco).not.toHaveBeenCalled(); expect(initializeThemeSync).not.toHaveBeenCalled(); }); }); diff --git a/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts index aa6cf43b3..bc2219394 100644 --- a/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts +++ b/src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts @@ -3,6 +3,7 @@ import { type BackgroundTaskHandle, } from '@/shared/utils/backgroundTaskScheduler'; import { createLogger } from '@/shared/utils/logger'; +import { startupTrace } from '@/shared/utils/startupTrace'; const log = createLogger('MonacoStartupWarmup'); @@ -21,6 +22,16 @@ interface MonacoStartupWarmupOptions { scheduler?: SchedulerLike; initializeMonaco?: () => Promise; initializeThemeSync?: () => Promise; + preloadEditorSurfaceStages?: EditorWarmupStage[]; + waitForIdle?: (signal: AbortSignal) => Promise; + trace?: { + markPhase: (phase: string, data?: Record) => void; + }; +} + +interface EditorWarmupStage { + name: string; + run: () => Promise; } async function defaultInitializeMonaco(): Promise { @@ -33,25 +44,125 @@ async function defaultInitializeThemeSync(): Promise { await monacoThemeSync.initialize(); } +const defaultEditorSurfaceStages: EditorWarmupStage[] = [ + { + name: 'code_editor', + run: async () => { + await import('@/tools/editor/components/CodeEditor'); + }, + }, + { + name: 'diff_editor', + run: async () => { + await import('@/tools/editor/components/DiffEditor'); + }, + }, + { + name: 'git_diff_editor', + run: async () => { + await import('@/tools/git/components/GitDiffEditor/GitDiffEditor'); + }, + }, +]; + +function defaultWaitForIdle(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + + return new Promise(resolve => { + let settled = false; + let cancelScheduled: (() => void) | null = null; + + const finish = () => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener('abort', finish); + cancelScheduled?.(); + resolve(); + }; + + signal.addEventListener('abort', finish, { once: true }); + + const requestIdleCallback = (globalThis as { + requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number; + }).requestIdleCallback; + const cancelIdleCallback = (globalThis as { + cancelIdleCallback?: (handle: number) => void; + }).cancelIdleCallback; + + if (typeof requestIdleCallback === 'function') { + const idleHandle = requestIdleCallback(finish, { timeout: 1500 }); + cancelScheduled = () => cancelIdleCallback?.(idleHandle); + return; + } + + const timer = globalThis.setTimeout(finish, 16) as unknown as number; + cancelScheduled = () => globalThis.clearTimeout(timer); + }); +} + export function scheduleMonacoStartupWarmup( options: MonacoStartupWarmupOptions = {} ): BackgroundTaskHandle { const scheduler = options.scheduler ?? backgroundTaskScheduler; const initializeMonaco = options.initializeMonaco ?? defaultInitializeMonaco; const initializeThemeSync = options.initializeThemeSync ?? defaultInitializeThemeSync; + const preloadEditorSurfaceStages = options.preloadEditorSurfaceStages ?? defaultEditorSurfaceStages; + const waitForIdle = options.waitForIdle ?? defaultWaitForIdle; + const trace = options.trace ?? startupTrace; + + trace.markPhase('editor_startup_warmup_scheduled', { + idle: true, + priority: 'low', + }); return scheduler.schedule(async (signal) => { try { + if (signal.aborted) { + return; + } + trace.markPhase('editor_startup_warmup_start'); + for (const stage of preloadEditorSurfaceStages) { + trace.markPhase('editor_startup_warmup_stage_start', { stage: stage.name }); + await stage.run(); + if (signal.aborted) { + return; + } + trace.markPhase('editor_startup_warmup_stage_end', { stage: stage.name }); + await waitForIdle(signal); + if (signal.aborted) { + return; + } + } + if (signal.aborted) { + return; + } + trace.markPhase('editor_startup_warmup_surfaces_loaded'); await initializeMonaco(); if (signal.aborted) { return; } + trace.markPhase('editor_startup_warmup_monaco_ready'); + await waitForIdle(signal); + if (signal.aborted) { + return; + } await initializeThemeSync(); + if (signal.aborted) { + return; + } + trace.markPhase('editor_startup_warmup_end'); log.info('Monaco startup warmup completed'); } catch (error) { if (signal.aborted) { return; } + trace.markPhase('editor_startup_warmup_failed', { + error: error instanceof Error ? error.message : String(error), + }); log.warn('Monaco startup warmup failed', error); throw error; } diff --git a/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx b/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx index bd4982596..1c4d0eef0 100644 --- a/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx +++ b/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx @@ -5,7 +5,7 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { DiffEditor } from '@/tools/editor'; +import { DiffEditor } from '@/tools/editor/components/DiffEditor'; import { X } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; import { globalEventBus } from '@/infrastructure/event-bus'; diff --git a/tests/e2e/specs/performance/editor-first-open-perf.spec.ts b/tests/e2e/specs/performance/editor-first-open-perf.spec.ts new file mode 100644 index 000000000..9b5e0444e --- /dev/null +++ b/tests/e2e/specs/performance/editor-first-open-perf.spec.ts @@ -0,0 +1,253 @@ +import { $, browser, expect } from '@wdio/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { + readPerformanceNow, + readStartupTraceSnapshot, + waitForTracePhaseCount, +} from '../../helpers/performance-trace'; + +type EditorScenario = 'code-editor' | 'git-diff'; + +interface EditorOpenReport { + appMode: string; + scenario: EditorScenario; + traceId: string; + filePath: string; + waitedForEditorWarmup: boolean; + editorWarmupStartAtMs?: number; + editorWarmupEndAtMs?: number; + editorWarmupDurationMs?: number; + editorWarmupCompletedBeforeTrigger: boolean; + triggerAtMs: number; + editorReadyAtMs: number; + triggerToEditorReadyMs: number; + matchingEditorCount: number; +} + +function reportDir(): string { + return path.resolve(process.cwd(), 'reports', 'performance'); +} + +async function writeReport(name: string, data: unknown): Promise { + await fs.mkdir(reportDir(), { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + await fs.writeFile( + path.join(reportDir(), `${name}-${timestamp}.json`), + `${JSON.stringify(data, null, 2)}\n`, + 'utf8', + ); +} + +function getScenario(): EditorScenario { + const raw = process.env.BITFUN_E2E_EDITOR_SCENARIO; + return raw === 'git-diff' ? 'git-diff' : 'code-editor'; +} + +function getWorkspacePath(): string { + return process.env.E2E_TEST_WORKSPACE || path.resolve(process.cwd(), '..', '..'); +} + +function getEditorFilePath(workspacePath: string): string { + return process.env.BITFUN_E2E_EDITOR_FILE_PATH + || path.join(workspacePath, 'src', 'web-ui', 'src', 'main.tsx'); +} + +function shouldWaitForEditorWarmup(): boolean { + return process.env.BITFUN_E2E_WAIT_FOR_EDITOR_WARMUP === '1'; +} + +function fileNameOf(filePath: string): string { + return filePath.replace(/\\/g, '/').split('/').pop() || 'main.tsx'; +} + +function lastPhaseAt(snapshot: Awaited>, phase: string): number | undefined { + return snapshot.phases.events + .filter(event => event.phase === phase) + .at(-1)?.atMs; +} + +async function openSessionScene(): Promise { + await browser.execute(() => { + window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'session' } })); + window.dispatchEvent(new CustomEvent('expand-right-panel')); + }); + await $('[data-testid="app-main-content"]').waitForExist({ timeout: 10000 }); +} + +async function openGitScene(): Promise { + await browser.execute(() => { + window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'git' } })); + }); + await $('.bitfun-git-scene-working-copy__diff-area').waitForExist({ timeout: 15000 }); +} + +async function triggerCodeEditor(filePath: string, fileName: string): Promise { + return browser.execute((args) => { + const triggerAt = performance.now(); + const duplicateKey = `perf-code-editor:${args.filePath}:${triggerAt}`; + window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'session' } })); + window.dispatchEvent(new CustomEvent('expand-right-panel')); + window.dispatchEvent(new CustomEvent('agent-create-tab', { + detail: { + type: 'code-editor', + title: args.fileName, + data: { + filePath: args.filePath, + fileName: args.fileName, + language: 'typescript', + readOnly: true, + showLineNumbers: true, + showMinimap: false, + theme: 'vs-dark', + }, + metadata: { + filePath: args.filePath, + fileName: args.fileName, + duplicateCheckKey: duplicateKey, + }, + checkDuplicate: false, + duplicateCheckKey: duplicateKey, + replaceExisting: false, + }, + })); + return triggerAt; + }, { filePath, fileName }); +} + +async function triggerGitDiff( + workspacePath: string, + filePath: string, + fileName: string, +): Promise { + const content = await fs.readFile(filePath, 'utf8'); + const relativePath = path.relative(workspacePath, filePath).replace(/\\/g, '/'); + const modifiedContent = `${content}\n// perf measurement synthetic diff\n`; + + return browser.execute((args) => { + const triggerAt = performance.now(); + const duplicateKey = `perf-git-diff:${args.relativePath}:${triggerAt}`; + window.dispatchEvent(new CustomEvent('git-create-tab', { + detail: { + type: 'diff-code-editor', + title: `${args.fileName} - Git Diff`, + data: { + fileName: args.fileName, + filePath: args.relativePath, + language: 'typescript', + originalCode: args.originalContent, + modifiedCode: args.modifiedContent, + readOnly: true, + repositoryPath: args.workspacePath, + }, + metadata: { + filePath: args.relativePath, + repositoryPath: args.workspacePath, + duplicateCheckKey: duplicateKey, + }, + checkDuplicate: false, + duplicateCheckKey: duplicateKey, + replaceExisting: false, + }, + })); + return triggerAt; + }, { + workspacePath, + relativePath, + fileName, + originalContent: content, + modifiedContent, + }); +} + +async function waitForEditorReady(scenario: EditorScenario): Promise<{ atMs: number; count: number }> { + const selector = editorSelector(scenario); + + await browser.waitUntil(async () => { + const count = await countMatchingEditors(scenario); + return count > 0; + }, { + timeout: 30000, + interval: 50, + timeoutMsg: `Timed out waiting for ${scenario} Monaco editor`, + }); + + const result = await browser.execute((query) => ({ + atMs: performance.now(), + count: document.querySelectorAll(query).length, + }), selector); + return result as { atMs: number; count: number }; +} + +function editorSelector(scenario: EditorScenario): string { + return scenario === 'git-diff' + ? '.monaco-diff-editor, .monaco-editor' + : '.monaco-editor'; +} + +async function countMatchingEditors(scenario: EditorScenario): Promise { + const selector = editorSelector(scenario); + return browser.execute((query) => document.querySelectorAll(query).length, selector); +} + +describe('Editor first-open performance telemetry', () => { + before(async () => { + await waitForTracePhaseCount('interactive_shell_ready', 1, 30000); + if (shouldWaitForEditorWarmup()) { + await waitForTracePhaseCount('editor_startup_warmup_end', 1, 30000); + } + }); + + it('collects first-open timing for editor-heavy surfaces', async () => { + const scenario = getScenario(); + const workspacePath = getWorkspacePath(); + const filePath = getEditorFilePath(workspacePath); + const fileName = fileNameOf(filePath); + + if (scenario === 'git-diff') { + await openGitScene(); + } else { + await openSessionScene(); + } + const existingEditorCount = await countMatchingEditors(scenario); + if (existingEditorCount > 0) { + throw new Error(`Expected no existing ${scenario} editor before first-open measurement; found ${existingEditorCount}`); + } + + const snapshotBefore = await readStartupTraceSnapshot(); + const editorWarmupStartAtMs = lastPhaseAt(snapshotBefore, 'editor_startup_warmup_start'); + const editorWarmupEndAtMs = lastPhaseAt(snapshotBefore, 'editor_startup_warmup_end'); + const triggerAtMs = scenario === 'git-diff' + ? await triggerGitDiff(workspacePath, filePath, fileName) + : await triggerCodeEditor(filePath, fileName); + const ready = await waitForEditorReady(scenario); + const trace = await readStartupTraceSnapshot(); + + const report: EditorOpenReport = { + appMode: process.env.BITFUN_E2E_APP_MODE ?? 'auto', + scenario, + traceId: trace.traceId || snapshotBefore.traceId, + filePath, + waitedForEditorWarmup: shouldWaitForEditorWarmup(), + editorWarmupStartAtMs, + editorWarmupEndAtMs, + editorWarmupDurationMs: + typeof editorWarmupStartAtMs === 'number' && typeof editorWarmupEndAtMs === 'number' + ? editorWarmupEndAtMs - editorWarmupStartAtMs + : undefined, + editorWarmupCompletedBeforeTrigger: + typeof editorWarmupEndAtMs === 'number' && editorWarmupEndAtMs <= triggerAtMs, + triggerAtMs, + editorReadyAtMs: ready.atMs, + triggerToEditorReadyMs: ready.atMs - triggerAtMs, + matchingEditorCount: ready.count, + }; + + console.log('[Perf] editor-first-open', JSON.stringify(report)); + await writeReport(`editor-first-open-${scenario}`, report); + + expect(report.triggerToEditorReadyMs).toBeGreaterThan(0); + expect(report.matchingEditorCount).toBeGreaterThan(0); + expect(await readPerformanceNow()).toBeGreaterThan(report.triggerAtMs); + }); +});