diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 5b1891b3..351fa229 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -8,16 +8,11 @@ import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; // useEventStream removed — replaced by SyncProvider + SyncBridge -import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useMenuActions } from '@/hooks/useMenuActions'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; -import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; -import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; -import { usePwaManifestSync } from '@/hooks/usePwaManifestSync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; -import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; @@ -37,11 +32,6 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { SyncProvider, useSessions } from '@/sync/sync-context'; -import { useSync } from '@/sync/use-sync'; -import { setOptimisticRefs } from '@/sync/session-actions'; -import { useFontPreferences } from '@/hooks/useFontPreferences'; -import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions'; -import { loadMonoFont, loadUiFont } from '@/lib/fontLoader'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; @@ -57,20 +47,14 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; +import { SyncAppEffects } from '@/apps/AppEffects'; +import { useAppFontEffects } from '@/apps/useAppFontEffects'; // Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. const OnboardingScreen = lazyWithChunkRecovery(() => import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })), ); -const VSCodeLayoutLazy = lazyWithChunkRecovery(() => - import('@/components/layout/VSCodeLayout').then((m) => ({ default: m.VSCodeLayout })), -); - -const AgentManagerViewLazy = lazyWithChunkRecovery(() => - import('@/components/views/agent-manager').then((m) => ({ default: m.AgentManagerView })), -); - const AboutDialogWrapper: React.FC = () => { const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen); const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); @@ -178,35 +162,6 @@ const EmbeddedSessionSelectionGate: React.FC<{ return null; }; -const SyncOptimisticBridge: React.FC = () => { - const sync = useSync(); - const addRef = React.useRef(sync.optimistic.add); - const removeRef = React.useRef(sync.optimistic.remove); - addRef.current = sync.optimistic.add; - removeRef.current = sync.optimistic.remove; - - React.useEffect(() => { - setOptimisticRefs( - (input) => addRef.current(input), - (input) => removeRef.current(input), - ); - }, []); - - return null; -}; - -function SyncAppEffects({ embeddedBackgroundWorkEnabled }: { - embeddedBackgroundWorkEnabled: boolean; -}) { - usePwaManifestSync(); - useWindowControlsOverlayLayout(); - useSessionAutoCleanup(embeddedBackgroundWorkEnabled); - useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled); - useKeyboardShortcuts(); - - return ; -} - function App({ apis }: AppProps) { const initializeApp = useConfigStore((s) => s.initializeApp); const isInitialized = useConfigStore((s) => s.isInitialized); @@ -221,7 +176,6 @@ function App({ apis }: AppProps) { const setDirectory = useDirectoryStore((state) => state.setDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); const [showMemoryDebug, setShowMemoryDebug] = React.useState(false); - const { uiFont, monoFont } = useFontPreferences(); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true); @@ -281,27 +235,7 @@ function App({ apis }: AppProps) { void refreshGitHubAuthStatus(apis.github, { force: true }); }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); - React.useEffect(() => { - if (typeof document === 'undefined') { - return; - } - const root = document.documentElement; - const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack; - const monoStack = CODE_FONT_OPTION_MAP[monoFont]?.stack ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack; - void loadUiFont(uiFont); - void loadMonoFont(monoFont); - - root.style.setProperty('--font-sans', uiStack); - root.style.setProperty('--font-heading', uiStack); - root.style.setProperty('--font-family-sans', uiStack); - root.style.setProperty('--font-mono', monoStack); - root.style.setProperty('--font-family-mono', monoStack); - root.style.setProperty('--ui-regular-font-weight', '400'); - - if (document.body) { - document.body.style.fontFamily = uiStack; - } - }, [uiFont, monoFont]); + useAppFontEffects(); const bootOutcomeKnown = bootInjectionStatus === 'valid'; const bootViewIsMain = bootView?.screen === 'main'; @@ -848,54 +782,6 @@ function App({ apis }: AppProps) { ); } - // VS Code runtime - simplified layout without git/terminal views - if (isVSCodeRuntime) { - // Check if this is the Agent Manager panel - const panelType = typeof window !== 'undefined' - ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__ - : 'chat'; - - if (panelType === 'agentManager') { - return ( - - - - -
- - }> - - - -
-
-
-
-
- ); - } - - return ( - - - - - -
- - }> - - - -
-
-
-
-
-
- ); - } - // Always mount the full provider tree to avoid remounts when isInitialized // flips from false → true. FireworksProvider and VoiceProvider are lightweight // shells; their heavy children are only activated when actually needed. diff --git a/packages/ui/src/apps/AppEffects.tsx b/packages/ui/src/apps/AppEffects.tsx new file mode 100644 index 00000000..e0db7d61 --- /dev/null +++ b/packages/ui/src/apps/AppEffects.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; +import { usePwaManifestSync } from '@/hooks/usePwaManifestSync'; +import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; +import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; +import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout'; +import { setOptimisticRefs } from '@/sync/session-actions'; +import { useSync } from '@/sync/use-sync'; + +const SyncOptimisticBridge: React.FC = () => { + const sync = useSync(); + const addRef = React.useRef(sync.optimistic.add); + const removeRef = React.useRef(sync.optimistic.remove); + addRef.current = sync.optimistic.add; + removeRef.current = sync.optimistic.remove; + + React.useEffect(() => { + setOptimisticRefs( + (input) => addRef.current(input), + (input) => removeRef.current(input), + ); + }, []); + + return null; +}; + +export function SyncAppEffects({ embeddedBackgroundWorkEnabled }: { + embeddedBackgroundWorkEnabled: boolean; +}) { + usePwaManifestSync(); + useWindowControlsOverlayLayout(); + useSessionAutoCleanup(embeddedBackgroundWorkEnabled); + useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled); + useKeyboardShortcuts(); + + return ; +} diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx new file mode 100644 index 00000000..16b7e74e --- /dev/null +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { AgentManagerView } from '@/components/views/agent-manager'; +import { FireworksProvider } from '@/contexts/FireworksContext'; +import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; +import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { Toaster } from '@/components/ui/sonner'; +import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; +import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; +import { useRouter } from '@/hooks/useRouter'; +import { useWindowTitle } from '@/hooks/useWindowTitle'; +import { opencodeClient } from '@/lib/opencode/client'; +import type { RuntimeAPIs } from '@/lib/api/types'; +import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { SyncProvider } from '@/sync/sync-context'; +import { SyncAppEffects } from './AppEffects'; +import { useAppFontEffects } from './useAppFontEffects'; + +type VSCodePanelType = 'chat' | 'agentManager'; + +declare global { + interface Window { + __OPENCHAMBER_PANEL_TYPE__?: VSCodePanelType; + } +} + +type VSCodeAppProps = { + apis: RuntimeAPIs; +}; + +export function VSCodeApp({ apis }: VSCodeAppProps) { + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const error = useSessionUIStore((state) => state.error); + const clearError = useSessionUIStore((state) => state.clearError); + const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled); + const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); + const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); + const panelType = typeof window !== 'undefined' + ? window.__OPENCHAMBER_PANEL_TYPE__ + : 'chat'; + + React.useEffect(() => { + registerRuntimeAPIs(apis); + return () => registerRuntimeAPIs(null); + }, [apis]); + + useAppFontEffects(); + usePushVisibilityBeacon({ enabled: true }); + useWindowTitle(); + useRouter(); + + React.useEffect(() => { + document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled); + return () => { + document.documentElement.classList.remove('wide-chat-layout'); + }; + }, [wideChatLayoutEnabled]); + + React.useEffect(() => { + void refreshGitHubAuthStatus(apis.github, { force: true }); + }, [apis.github, refreshGitHubAuthStatus]); + + React.useEffect(() => { + let cancelled = false; + + const run = async () => { + const res = await fetch('/health', { method: 'GET' }).catch(() => null); + if (!res || !res.ok || cancelled) return; + const data = (await res.json().catch(() => null)) as null | { + planModeExperimentalEnabled?: unknown; + }; + if (!data || cancelled) return; + const raw = data.planModeExperimentalEnabled; + const enabled = raw === true || raw === 1 || raw === '1' || raw === 'true'; + setPlanModeEnabled(enabled); + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [setPlanModeEnabled]); + + React.useEffect(() => { + if (!error) { + return; + } + + const timeout = window.setTimeout(() => clearError(), 5000); + return () => window.clearTimeout(timeout); + }, [clearError, error]); + + if (panelType === 'agentManager') { + return ( + + + + +
+ + + +
+
+
+
+
+ ); + } + + return ( + + + + + +
+ + + +
+
+
+
+
+
+ ); +} diff --git a/packages/ui/src/apps/renderVSCodeApp.tsx b/packages/ui/src/apps/renderVSCodeApp.tsx new file mode 100644 index 00000000..bcec6c2e --- /dev/null +++ b/packages/ui/src/apps/renderVSCodeApp.tsx @@ -0,0 +1,58 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import '@/styles/fonts'; +import '@/index.css'; +import '@/lib/debug'; +import { SessionAuthGate } from '@/components/auth/SessionAuthGate'; +import { ThemeProvider } from '@/components/providers/ThemeProvider'; +import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext'; +import type { RuntimeAPIs } from '@/lib/api/types'; +import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave'; +import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence'; +import { initializeLocale, I18nProvider } from '@/lib/i18n'; +import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence'; +import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; +import { startTypographyWatcher } from '@/lib/typographyWatcher'; +import { VSCodeApp } from './VSCodeApp'; + +const initializeSharedPreferences = () => { + initializeLocale(); + + void initializeAppearancePreferences().then(() => { + void Promise.all([ + syncDesktopSettings(), + applyPersistedDirectoryPreferences(), + ]).catch((err) => { + console.error('[vscode-main] settings init failed:', err); + }); + + startAppearanceAutoSave(); + startModelPrefsAutoSave(); + startTypographyWatcher(); + }).catch((err) => { + console.error('[vscode-main] appearance init failed:', err); + }); +}; + +export function renderVSCodeApp(apis: RuntimeAPIs) { + initializeSharedPreferences(); + + const rootElement = document.getElementById('root'); + if (!rootElement) { + throw new Error('Root element not found'); + } + + createRoot(rootElement).render( + + + + + + + + + + + , + ); +} diff --git a/packages/ui/src/apps/useAppFontEffects.ts b/packages/ui/src/apps/useAppFontEffects.ts new file mode 100644 index 00000000..61e1194e --- /dev/null +++ b/packages/ui/src/apps/useAppFontEffects.ts @@ -0,0 +1,31 @@ +import React from 'react'; +import { useFontPreferences } from '@/hooks/useFontPreferences'; +import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions'; +import { loadMonoFont, loadUiFont } from '@/lib/fontLoader'; + +export function useAppFontEffects() { + const { uiFont, monoFont } = useFontPreferences(); + + React.useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + const root = document.documentElement; + const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack; + const monoStack = CODE_FONT_OPTION_MAP[monoFont]?.stack ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack; + void loadUiFont(uiFont); + void loadMonoFont(monoFont); + + root.style.setProperty('--font-sans', uiStack); + root.style.setProperty('--font-heading', uiStack); + root.style.setProperty('--font-family-sans', uiStack); + root.style.setProperty('--font-mono', monoStack); + root.style.setProperty('--font-family-mono', monoStack); + root.style.setProperty('--ui-regular-font-weight', '400'); + + if (document.body) { + document.body.style.fontFamily = uiStack; + } + }, [uiFont, monoFont]); +} diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 8a121bb1..7be6ee43 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -60,7 +60,7 @@ const bootstrapConnectionStatus = () => { bootstrapConnectionStatus(); -// Expose panel type globally for App.tsx to conditionally render +// Expose panel type globally for the VS Code app root to conditionally render. window.__OPENCHAMBER_PANEL_TYPE__ = (window.__VSCODE_CONFIG__?.panelType as PanelType) || 'chat'; const handleConnectionMessage = (event: MessageEvent) => { @@ -1242,8 +1242,9 @@ onCommand('activeEditorFile', (payload) => { }); }); -import('@/main') - .then(async () => { +import('@openchamber/ui/apps/renderVSCodeApp') + .then(async ({ renderVSCodeApp }) => { + renderVSCodeApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createVSCodeAPIs()); await waitForUiMount(); uiMounted = true; maybeHideLoadingOverlay();