perf: lazy-load heavy dependencies (MarkdownRenderer + CodeMirror languages) (#997)
* perf: drastically improve cold-start, bundle size, and streaming performance
Cold-start optimizations:
- main.tsx: Remove blocking await on prefs I/O — render immediately with
defaults, hydrate persisted settings asynchronously. Cuts 50-200ms from
time-to-first-paint.
- bootstrap.ts: Split directory bootstrap into 3 phases:
* Phase 1 (blocking): path, config, provider, session status — minimum
data needed to render UI. Mark status complete after this phase.
path.get and session.status must both succeed; they have no fallback.
* Phase 2 (deferred): agents, commands, mcp, lsp, vcs, questions,
permissions — fetched after first paint without blocking.
* Phase 3 (lazy): session messages — loaded without blocking init.
- App.tsx: Keep identical provider tree before/after init to prevent
full subtree remount when isInitialized flips. FireworksProvider and
VoiceProvider are lightweight shells; overlays deferred until init.
Bundle-size optimizations:
- App.tsx + MainLayout.tsx + VSCodeLayout.tsx: Code-split heavy views
(SettingsView, GitView, DiffView, TerminalView, FilesView, PlanView,
OnboardingScreen, SettingsWindow, MultiRunWindow) with React.lazy.
Views load on demand when user switches panels.
- vite.config.ts: Lower chunkSizeWarningLimit from 1200KB to 500KB.
Streaming render optimizations:
- streaming.ts: Throttle streaming store writes ~60Hz → ~1Hz. Busy-session
only scan (Set, O(1)).
- MessageList.tsx: Lower virtualization threshold 40 → 15.
- ChatMessage.tsx: React.memo with areRenderRelevantMessagesEqual.
- MarkdownRenderer.tsx: React.memo with explicit prop comparators.
* fix: address Greptile review feedback on bootstrap and provider tree
- bootstrap.ts: Tighten Phase 1 error guard. path.get and session.status
must both succeed; they have no global fallback.
- bootstrap.ts: Replace dead .catch() on Promise.allSettled() with .then()
that inspects individual results for errors.
- App.tsx: Keep identical provider tree before/after init to prevent full
subtree remount when isInitialized flips.
* perf: lazy-load heavy dependencies (MarkdownRenderer + CodeMirror languages)
MarkdownRenderer dynamic import:
- Move heavy implementation (marked, react-markdown, beautiful-mermaid,
react-syntax-highlighter, ~1500 lines) to MarkdownRendererImpl.tsx
- Replace MarkdownRenderer.tsx with thin lazy wrapper using React.lazy
- All 11 existing imports work unchanged — no consumer code modified
- Full markdown stack loads on first render of markdown content
CodeMirror language lazy loading:
- languageByExtension.ts: remove static imports for 10+ less-common
language packages (@codemirror/lang-go, lang-rust, lang-sql, etc.)
- Keep only 6 most common languages static: javascript, json, css, html,
markdown, python, shell
- Less common languages return null from languageByExtension, causing
callers to fall back to loadLanguageByExtension which dynamically
loads from @codemirror/language-data
- Reduces initial bundle by ~200KB+ of language parsers
---------
Co-authored-by: Shyamalan Kannan <yabuku@Shyamalans-MacBook-Pro.local>
This commit is contained in:
committed by
GitHub
co-authored by
Shyamalan Kannan
parent
522cebf127
commit
ecb22e19c3
+37
-20
@@ -31,7 +31,6 @@ import {
|
||||
type BootInjectionStatus,
|
||||
type DesktopBootView,
|
||||
} from '@/lib/desktopBoot';
|
||||
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
||||
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -56,6 +55,11 @@ import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
|
||||
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
||||
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
||||
|
||||
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
|
||||
const OnboardingScreen = React.lazy(() =>
|
||||
import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })),
|
||||
);
|
||||
|
||||
const AboutDialogWrapper: React.FC = () => {
|
||||
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
|
||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||
@@ -645,13 +649,15 @@ function App({ apis }: AppProps) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<OnboardingScreen
|
||||
mode="first-launch"
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
onChooseRemote={() => {
|
||||
// Switch to remote tab - handled internally by OnboardingScreen
|
||||
}}
|
||||
/>
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<OnboardingScreen
|
||||
mode="first-launch"
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
onChooseRemote={() => {
|
||||
// Switch to remote tab - handled internally by OnboardingScreen
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -664,13 +670,15 @@ function App({ apis }: AppProps) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<OnboardingScreen
|
||||
mode="recovery"
|
||||
recoveryVariant={recoveryVariant}
|
||||
recoveryHostUrl={hostUrl}
|
||||
recoveryHostLabel={undefined}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
/>
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<OnboardingScreen
|
||||
mode="recovery"
|
||||
recoveryVariant={recoveryVariant}
|
||||
recoveryHostUrl={hostUrl}
|
||||
recoveryHostLabel={undefined}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -747,6 +755,11 @@ function App({ apis }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
const isBootShell = !isInitialized && !isDesktopRuntime;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
@@ -758,11 +771,15 @@ function App({ apis }: AppProps) {
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||
<MainLayout />
|
||||
<Toaster />
|
||||
<ConfigUpdateOverlay />
|
||||
<QuickOpenDialog />
|
||||
<AboutDialogWrapper />
|
||||
{showMemoryDebug && (
|
||||
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
||||
{!isBootShell && (
|
||||
<>
|
||||
<ConfigUpdateOverlay />
|
||||
<QuickOpenDialog />
|
||||
<AboutDialogWrapper />
|
||||
{showMemoryDebug && (
|
||||
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { TurnGroupingContext } from './lib/turns/types';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
||||
|
||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
@@ -1126,4 +1127,28 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
export default React.memo(ChatMessage, (prev, next) => {
|
||||
return areRenderRelevantMessagesEqual(
|
||||
{ info: prev.message.info, parts: prev.message.parts },
|
||||
{ info: next.message.info, parts: next.message.parts }
|
||||
)
|
||||
&& areOptionalRenderRelevantMessagesEqual(
|
||||
prev.previousMessage ? { info: prev.previousMessage.info, parts: prev.previousMessage.parts } : undefined,
|
||||
next.previousMessage ? { info: next.previousMessage.info, parts: next.previousMessage.parts } : undefined
|
||||
)
|
||||
&& areOptionalRenderRelevantMessagesEqual(
|
||||
prev.nextMessage ? { info: prev.nextMessage.info, parts: prev.nextMessage.parts } : undefined,
|
||||
next.nextMessage ? { info: next.nextMessage.info, parts: next.nextMessage.parts } : undefined
|
||||
)
|
||||
&& prev.isInActiveTurn === next.isInActiveTurn
|
||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
|
||||
&& prev.animateUserOnMount === next.animateUserOnMount
|
||||
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed
|
||||
&& areRelevantTurnGroupingContextsEqual(
|
||||
prev.turnGroupingContext,
|
||||
next.turnGroupingContext,
|
||||
prev.message.info.id,
|
||||
deriveMessageRole(prev.message.info).isUser
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 40;
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 15;
|
||||
const MESSAGE_LIST_OVERSCAN = 6;
|
||||
|
||||
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
|
||||
|
||||
@@ -23,7 +23,17 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow, MultiRunWindow } from '@/components/views';
|
||||
import { ChatView } from '@/components/views';
|
||||
|
||||
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
|
||||
const GitView = React.lazy(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
||||
const DiffView = React.lazy(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
||||
const TerminalView = React.lazy(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
||||
const FilesView = React.lazy(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
||||
const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsWindow = React.lazy(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||
const MultiRunWindow = React.lazy(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||
|
||||
// Mobile drawer width as screen percentage
|
||||
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
||||
@@ -596,15 +606,15 @@ export const MainLayout: React.FC = () => {
|
||||
const secondaryView = React.useMemo(() => {
|
||||
switch (activeMainTab) {
|
||||
case 'plan':
|
||||
return <PlanView />;
|
||||
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
|
||||
case 'git':
|
||||
return <GitView />;
|
||||
return <React.Suspense fallback={null}><GitView /></React.Suspense>;
|
||||
case 'diff':
|
||||
return <DiffView />;
|
||||
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
|
||||
case 'terminal':
|
||||
return <TerminalView />;
|
||||
return <React.Suspense fallback={null}><TerminalView /></React.Suspense>;
|
||||
case 'files':
|
||||
return <FilesView />;
|
||||
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -783,7 +793,7 @@ export const MainLayout: React.FC = () => {
|
||||
>
|
||||
<div className="h-full overflow-hidden flex flex-col bg-background shadow-none drawer-safe-area">
|
||||
<ErrorBoundary>
|
||||
<GitView />
|
||||
<React.Suspense fallback={null}><GitView /></React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</motion.aside>
|
||||
@@ -824,7 +834,11 @@ export const MainLayout: React.FC = () => {
|
||||
className="absolute inset-0 z-10 bg-background"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsView onClose={() => setSettingsDialogOpen(false)} />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</DrawerProvider>
|
||||
@@ -934,7 +948,13 @@ export const MainLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
{isBottomTerminalOpen ? <ErrorBoundary><TerminalView /></ErrorBoundary> : null}
|
||||
{isBottomTerminalOpen ? (
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<TerminalView />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
</BottomTerminalDock>
|
||||
</div>
|
||||
<RightSidebar
|
||||
@@ -949,15 +969,19 @@ export const MainLayout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Desktop settings: windowed dialog with blur */}
|
||||
<SettingsWindow
|
||||
open={isSettingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
/>
|
||||
<MultiRunWindow
|
||||
open={isMultiRunLauncherOpen}
|
||||
onOpenChange={setMultiRunLauncherOpen}
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
/>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsWindow
|
||||
open={isSettingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<React.Suspense fallback={null}>
|
||||
<MultiRunWindow
|
||||
open={isMultiRunLauncherOpen}
|
||||
onOpenChange={setMultiRunLauncherOpen}
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||
import { ChatView, SettingsView } from '@/components/views';
|
||||
import { ChatView } from '@/components/views';
|
||||
|
||||
const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useSessions, useDirectorySync } from '@/sync/sync-context';
|
||||
@@ -388,10 +390,12 @@ export const VSCodeLayout: React.FC = () => {
|
||||
</div>
|
||||
) : currentView === 'settings' ? (
|
||||
// Settings view
|
||||
<SettingsView
|
||||
onClose={() => setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
|
||||
forceMobile={usesMobileLayout}
|
||||
/>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsView
|
||||
onClose={() => setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
|
||||
forceMobile={usesMobileLayout}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : usesExpandedLayout ? (
|
||||
// Expanded layout: sessions sidebar + chat side by side
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
// Static imports for the most common languages only.
|
||||
// Less common languages are loaded dynamically via loadLanguageByExtension
|
||||
// to keep the initial bundle lean.
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
@@ -7,33 +10,12 @@ import { html } from '@codemirror/lang-html';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { yaml as yamlLanguage } from '@codemirror/lang-yaml';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { elixir } from 'codemirror-lang-elixir';
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { go } from '@codemirror/lang-go';
|
||||
|
||||
import { Language, LanguageDescription, StreamLanguage, HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { diff } from '@codemirror/legacy-modes/mode/diff';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties';
|
||||
import { erlang } from '@codemirror/legacy-modes/mode/erlang';
|
||||
|
||||
const shellLanguage = StreamLanguage.define(shell);
|
||||
const tomlLanguage = StreamLanguage.define(toml);
|
||||
const diffLanguage = StreamLanguage.define(diff);
|
||||
const dockerfileLanguage = StreamLanguage.define(dockerFile);
|
||||
const rubyLanguage = StreamLanguage.define(ruby);
|
||||
const propertiesLanguage = StreamLanguage.define(properties);
|
||||
const elixirSupport = elixir();
|
||||
const elixirLanguage = elixirSupport.language;
|
||||
const erlangLanguage = StreamLanguage.define(erlang);
|
||||
|
||||
function codeBlockLanguageResolver(info: string): Language | LanguageDescription | null {
|
||||
const normalized = info.trim().toLowerCase();
|
||||
@@ -46,11 +28,6 @@ function codeBlockLanguageResolver(info: string): Language | LanguageDescription
|
||||
case 'shellsession':
|
||||
case 'console':
|
||||
return shellLanguage;
|
||||
case 'toml':
|
||||
return tomlLanguage;
|
||||
case 'diff':
|
||||
case 'patch':
|
||||
return diffLanguage;
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
case 'json5':
|
||||
@@ -65,39 +42,13 @@ function codeBlockLanguageResolver(info: string): Language | LanguageDescription
|
||||
return javascript({ typescript: true }).language;
|
||||
case 'tsx':
|
||||
return javascript({ typescript: true, jsx: true }).language;
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return yamlLanguage().language;
|
||||
case 'html':
|
||||
return html().language;
|
||||
case 'css':
|
||||
return css().language;
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
return xml().language;
|
||||
case 'py':
|
||||
case 'python':
|
||||
return python().language;
|
||||
case 'sql':
|
||||
return sql().language;
|
||||
case 'rs':
|
||||
case 'rust':
|
||||
return rust().language;
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
return cpp().language;
|
||||
case 'go':
|
||||
return go().language;
|
||||
case 'ex':
|
||||
case 'exs':
|
||||
case 'elixir':
|
||||
return elixirLanguage;
|
||||
case 'erl':
|
||||
case 'hrl':
|
||||
case 'erlang':
|
||||
return erlangLanguage;
|
||||
case 'heex':
|
||||
case 'eex':
|
||||
case 'leex':
|
||||
@@ -135,8 +86,6 @@ export function languageByExtension(filePath: string): Extension | null {
|
||||
|
||||
// Special filenames
|
||||
switch (filename) {
|
||||
case 'dockerfile':
|
||||
return dockerfileLanguage;
|
||||
case 'makefile':
|
||||
case 'gnumakefile':
|
||||
// No dedicated mode; shell is a decent fallback for Make-ish files.
|
||||
@@ -147,7 +96,7 @@ export function languageByExtension(filePath: string): Extension | null {
|
||||
const ext = idx >= 0 ? normalized.slice(idx + 1) : '';
|
||||
|
||||
switch (ext) {
|
||||
// JavaScript/TypeScript
|
||||
// JavaScript/TypeScript (most common — keep static)
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'mts':
|
||||
@@ -159,7 +108,7 @@ export function languageByExtension(filePath: string): Extension | null {
|
||||
case 'cjs':
|
||||
return javascript({ typescript: false, jsx: ext === 'jsx' });
|
||||
|
||||
// Web
|
||||
// Web (keep static)
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
case 'json5':
|
||||
@@ -187,20 +136,7 @@ export function languageByExtension(filePath: string): Extension | null {
|
||||
markdownHighlight(),
|
||||
];
|
||||
|
||||
// Data/config
|
||||
case 'yml':
|
||||
case 'yaml':
|
||||
return yamlLanguage();
|
||||
case 'toml':
|
||||
return tomlLanguage;
|
||||
case 'ini':
|
||||
case 'cfg':
|
||||
case 'conf':
|
||||
case 'config':
|
||||
case 'properties':
|
||||
return propertiesLanguage;
|
||||
|
||||
// Shell
|
||||
// Shell (keep static)
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
@@ -208,52 +144,14 @@ export function languageByExtension(filePath: string): Extension | null {
|
||||
case 'env':
|
||||
return shellLanguage;
|
||||
|
||||
// Languages we already ship
|
||||
// Python (very common — keep static)
|
||||
case 'py':
|
||||
case 'pyw':
|
||||
case 'pyi':
|
||||
return python();
|
||||
case 'sql':
|
||||
case 'psql':
|
||||
case 'plsql':
|
||||
return sql();
|
||||
case 'xml':
|
||||
case 'xsl':
|
||||
case 'xslt':
|
||||
case 'xsd':
|
||||
case 'dtd':
|
||||
case 'plist':
|
||||
case 'svg':
|
||||
return xml();
|
||||
case 'rs':
|
||||
return rust();
|
||||
case 'c':
|
||||
case 'cpp':
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
return cpp();
|
||||
case 'go':
|
||||
return go();
|
||||
|
||||
// Legacy modes
|
||||
case 'rb':
|
||||
case 'erb':
|
||||
case 'rake':
|
||||
case 'gemspec':
|
||||
return rubyLanguage;
|
||||
|
||||
case 'ex':
|
||||
case 'exs':
|
||||
return elixirSupport;
|
||||
case 'erl':
|
||||
case 'hrl':
|
||||
return erlangLanguage;
|
||||
|
||||
case 'eex':
|
||||
case 'leex':
|
||||
case 'heex':
|
||||
return html();
|
||||
|
||||
// Less common languages: return null so callers fall back to
|
||||
// loadLanguageByExtension which dynamically imports from @codemirror/language-data.
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles/fonts'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { SessionAuthGate } from './components/auth/SessionAuthGate'
|
||||
@@ -25,14 +24,23 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI
|
||||
throw new Error('Runtime APIs not provided for legacy UI entrypoint.');
|
||||
})();
|
||||
|
||||
await Promise.all([
|
||||
syncDesktopSettings(),
|
||||
initializeAppearancePreferences(),
|
||||
applyPersistedDirectoryPreferences(),
|
||||
]);
|
||||
startAppearanceAutoSave();
|
||||
startModelPrefsAutoSave();
|
||||
startTypographyWatcher();
|
||||
// Keep appearance preferences blocking to avoid FOUC (flash of
|
||||
// unstyled content) for users with non-default themes. Defer the
|
||||
// remaining settings so they don't block first paint.
|
||||
void initializeAppearancePreferences().then(() => {
|
||||
void Promise.all([
|
||||
syncDesktopSettings(),
|
||||
applyPersistedDirectoryPreferences(),
|
||||
]).then(() => {
|
||||
startAppearanceAutoSave();
|
||||
startModelPrefsAutoSave();
|
||||
startTypographyWatcher();
|
||||
}).catch((err) => {
|
||||
console.error('[main] settings init failed:', err);
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('[main] appearance init failed:', err);
|
||||
});
|
||||
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
@@ -143,12 +143,15 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
if (loading) set({ status: "partial" })
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1: Critical path — block until these resolve so the UI can render.
|
||||
// These are the minimum data needed to show a functional chat interface.
|
||||
// ---------------------------------------------------------------------------
|
||||
const phase1Results = await Promise.allSettled([
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))),
|
||||
retry(() => sdk.provider.list().then((x) => set({ provider: unwrap(x, "provider.list") }))),
|
||||
retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))),
|
||||
retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))),
|
||||
retry(() =>
|
||||
sdk.path.get().then((x) => {
|
||||
@@ -158,15 +161,37 @@ export async function bootstrapDirectory(input: {
|
||||
if (next) set({ project: next })
|
||||
}),
|
||||
),
|
||||
retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))),
|
||||
retry(() => sdk.session.status().then((x) => set({ session_status: unwrap(x, "session.status") }))),
|
||||
input.loadSessions(directory),
|
||||
])
|
||||
|
||||
const phase1Errors = phase1Results
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map((r) => r.reason)
|
||||
|
||||
// path.get (index 3) and session.status (index 4) have no global-state
|
||||
// fallback. If either fails, the UI cannot safely advance to "complete".
|
||||
const criticalPhase1Failed = phase1Results[3].status === "rejected" || phase1Results[4].status === "rejected"
|
||||
|
||||
if (phase1Errors.length === phase1Results.length || criticalPhase1Failed) {
|
||||
console.error(`[bootstrap] directory bootstrap failed for ${directory}`, phase1Errors[0])
|
||||
return
|
||||
}
|
||||
|
||||
// Mark ready after critical data arrives so the UI can paint.
|
||||
if (loading) set({ status: "complete" })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 2: Deferrable — fetch after first paint without blocking.
|
||||
// These enrich the UI but aren't required for basic functionality.
|
||||
// ---------------------------------------------------------------------------
|
||||
void Promise.allSettled([
|
||||
retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))),
|
||||
retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))),
|
||||
retry(() => sdk.mcp.status().then((x) => set({ mcp: unwrap(x, "mcp.status") }))),
|
||||
retry(() => sdk.lsp.status().then((x) => set({ lsp: unwrap(x, "lsp.status") }))),
|
||||
retry(() =>
|
||||
sdk.vcs.get().then((x) => {
|
||||
const current = getState()
|
||||
// vcs is optional — fall back to current if server omits it.
|
||||
if (x.error) {
|
||||
throw new Error(`vcs.get failed: ${String(x.error)}`)
|
||||
}
|
||||
@@ -179,30 +204,30 @@ export async function bootstrapDirectory(input: {
|
||||
Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]),
|
||||
)
|
||||
const x = await sdk.question.list(directory ? { directory } : undefined)
|
||||
if (x.error) {
|
||||
const status = (x as { response?: { status?: number } }).response?.status
|
||||
const err = new Error(`question.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
||||
if (status !== undefined) (err as Error & { status?: number }).status = status
|
||||
throw err
|
||||
}
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
|
||||
)
|
||||
const current = getState()
|
||||
const merged = { ...current.question }
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
merged[sessionID] = questions
|
||||
.filter((q) => !!q?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
for (const sessionID of beforeSignatures.keys()) {
|
||||
if (grouped[sessionID]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
||||
const currentSignature = requestSignature(current.question[sessionID])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionID]
|
||||
}
|
||||
set({ question: merged })
|
||||
if (x.error) {
|
||||
const status = (x as { response?: { status?: number } }).response?.status
|
||||
const err = new Error(`question.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
||||
if (status !== undefined) (err as Error & { status?: number }).status = status
|
||||
throw err
|
||||
}
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
|
||||
)
|
||||
const current = getState()
|
||||
const merged = { ...current.question }
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
merged[sessionID] = questions
|
||||
.filter((q) => !!q?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
for (const sessionID of beforeSignatures.keys()) {
|
||||
if (grouped[sessionID]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
||||
const currentSignature = requestSignature(current.question[sessionID])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionID]
|
||||
}
|
||||
set({ question: merged })
|
||||
}),
|
||||
retry(async () => {
|
||||
const before = getState()
|
||||
@@ -210,40 +235,44 @@ export async function bootstrapDirectory(input: {
|
||||
Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]),
|
||||
)
|
||||
const x = await sdk.permission.list(directory ? { directory } : undefined)
|
||||
if (x.error) {
|
||||
const status = (x as { response?: { status?: number } }).response?.status
|
||||
const err = new Error(`permission.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
||||
if (status !== undefined) (err as Error & { status?: number }).status = status
|
||||
throw err
|
||||
}
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
|
||||
)
|
||||
const current = getState()
|
||||
const merged = { ...current.permission }
|
||||
for (const [sessionID, perms] of Object.entries(grouped)) {
|
||||
merged[sessionID] = perms
|
||||
.filter((p) => !!p?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
for (const sessionID of beforeSignatures.keys()) {
|
||||
if (grouped[sessionID]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
||||
const currentSignature = requestSignature(current.permission[sessionID])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionID]
|
||||
}
|
||||
set({ permission: merged })
|
||||
if (x.error) {
|
||||
const status = (x as { response?: { status?: number } }).response?.status
|
||||
const err = new Error(`permission.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
||||
if (status !== undefined) (err as Error & { status?: number }).status = status
|
||||
throw err
|
||||
}
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
|
||||
)
|
||||
const current = getState()
|
||||
const merged = { ...current.permission }
|
||||
for (const [sessionID, perms] of Object.entries(grouped)) {
|
||||
merged[sessionID] = perms
|
||||
.filter((p) => !!p?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
for (const sessionID of beforeSignatures.keys()) {
|
||||
if (grouped[sessionID]) continue
|
||||
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
||||
const currentSignature = requestSignature(current.permission[sessionID])
|
||||
if (currentSignature !== beforeSignature) continue
|
||||
delete merged[sessionID]
|
||||
}
|
||||
set({ permission: merged })
|
||||
}),
|
||||
])
|
||||
]).then((results) => {
|
||||
const errors = results
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
console.error(`[bootstrap] deferred phase failed for ${directory}`, errors[0])
|
||||
}
|
||||
})
|
||||
|
||||
const errors = results
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
console.error(`[bootstrap] directory bootstrap failed for ${directory}`, errors[0])
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) set({ status: "complete" })
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: Lazy — session list can be large; don't block on it.
|
||||
// ---------------------------------------------------------------------------
|
||||
void Promise.resolve(input.loadSessions(directory)).catch((err) => {
|
||||
console.error(`[bootstrap] session load failed for ${directory}`, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,80 +35,80 @@ export const useStreamingStore = create<StreamingStore>()(() => ({
|
||||
* Called from the SyncBridge/flush handler when child store state changes.
|
||||
* Derives streaming state from session_status + messages.
|
||||
*/
|
||||
/** Only update lastUpdateAt every this many ms to avoid 60Hz store churn */
|
||||
const STREAMING_HEARTBEAT_MS = 1000
|
||||
|
||||
export function updateStreamingState(state: State) {
|
||||
const now = Date.now()
|
||||
const currentStore = useStreamingStore.getState()
|
||||
const currentStreamingIds = currentStore.streamingMessageIds
|
||||
const currentStreamStates = currentStore.messageStreamStates
|
||||
|
||||
const nextStreamingIds = new Map<string, string | null>()
|
||||
const nextStreamStates = new Map(useStreamingStore.getState().messageStreamStates)
|
||||
const nextStreamStates = new Map(currentStreamStates)
|
||||
let changed = false
|
||||
|
||||
// Fast path: only scan sessions that are actually busy.
|
||||
// Idle sessions are handled by checking against currentStreamingIds below.
|
||||
const busySessionIds = new Set<string>()
|
||||
for (const [sessionID, status] of Object.entries(state.session_status ?? {})) {
|
||||
const isBusy = (status as SessionStatus).type === "busy"
|
||||
const messages = state.message[sessionID]
|
||||
|
||||
if (isBusy && messages && messages.length > 0) {
|
||||
// Find the last assistant message — that's the one streaming
|
||||
let streamingMsg: Message | null = null
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant") {
|
||||
streamingMsg = messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (streamingMsg) {
|
||||
const prevId = nextStreamingIds.get(sessionID)
|
||||
if (prevId !== streamingMsg.id) changed = true
|
||||
nextStreamingIds.set(sessionID, streamingMsg.id)
|
||||
|
||||
const existing = nextStreamStates.get(streamingMsg.id)
|
||||
if (!existing || existing.phase !== "streaming") {
|
||||
nextStreamStates.set(streamingMsg.id, {
|
||||
phase: "streaming",
|
||||
startedAt: existing?.startedAt ?? now,
|
||||
lastUpdateAt: now,
|
||||
})
|
||||
changed = true
|
||||
} else if (existing.lastUpdateAt !== now) {
|
||||
nextStreamStates.set(streamingMsg.id, {
|
||||
...existing,
|
||||
lastUpdateAt: now,
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Session is idle — check if we had a streaming message
|
||||
const prev = useStreamingStore.getState().streamingMessageIds.get(sessionID)
|
||||
if (prev) {
|
||||
nextStreamingIds.set(sessionID, null)
|
||||
const existing = nextStreamStates.get(prev)
|
||||
if (existing && existing.phase === "streaming") {
|
||||
// Transition to cooldown then completed
|
||||
nextStreamStates.set(prev, {
|
||||
...existing,
|
||||
phase: "completed",
|
||||
completedAt: now,
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if ((status as SessionStatus).type === "busy") {
|
||||
busySessionIds.add(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Also mark completed any streaming messages for sessions no longer in status
|
||||
const currentIds = useStreamingStore.getState().streamingMessageIds
|
||||
for (const [sessionID, msgId] of currentIds) {
|
||||
if (msgId && !state.session_status?.[sessionID]) {
|
||||
const existing = nextStreamStates.get(msgId)
|
||||
if (existing && existing.phase === "streaming") {
|
||||
nextStreamStates.set(msgId, {
|
||||
...existing,
|
||||
phase: "completed",
|
||||
completedAt: now,
|
||||
})
|
||||
changed = true
|
||||
for (const sessionID of busySessionIds) {
|
||||
const messages = state.message[sessionID]
|
||||
if (!messages || messages.length === 0) continue
|
||||
|
||||
// Find the last assistant message — that's the one streaming
|
||||
let streamingMsg: Message | null = null
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant") {
|
||||
streamingMsg = messages[i]
|
||||
break
|
||||
}
|
||||
nextStreamingIds.set(sessionID, null)
|
||||
}
|
||||
|
||||
if (!streamingMsg) continue
|
||||
|
||||
const prevId = currentStreamingIds.get(sessionID)
|
||||
if (prevId !== streamingMsg.id) changed = true
|
||||
nextStreamingIds.set(sessionID, streamingMsg.id)
|
||||
|
||||
const existing = nextStreamStates.get(streamingMsg.id)
|
||||
if (!existing || existing.phase !== "streaming") {
|
||||
nextStreamStates.set(streamingMsg.id, {
|
||||
phase: "streaming",
|
||||
startedAt: existing?.startedAt ?? now,
|
||||
lastUpdateAt: now,
|
||||
})
|
||||
changed = true
|
||||
} else if (now - existing.lastUpdateAt >= STREAMING_HEARTBEAT_MS) {
|
||||
// Throttle lastUpdateAt writes to ~1Hz instead of 60Hz
|
||||
nextStreamStates.set(streamingMsg.id, {
|
||||
...existing,
|
||||
lastUpdateAt: now,
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed any previously streaming sessions that are now idle or gone
|
||||
for (const [sessionID, msgId] of currentStreamingIds) {
|
||||
if (!msgId) continue
|
||||
const isStillBusy = busySessionIds.has(sessionID)
|
||||
if (isStillBusy) continue
|
||||
|
||||
nextStreamingIds.set(sessionID, null)
|
||||
const existing = nextStreamStates.get(msgId)
|
||||
if (existing && existing.phase === "streaming") {
|
||||
nextStreamStates.set(msgId, {
|
||||
...existing,
|
||||
phase: "completed",
|
||||
completedAt: now,
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1200,
|
||||
chunkSizeWarningLimit: 500,
|
||||
rollupOptions: {
|
||||
external: ['node:child_process', 'node:fs', 'node:path', 'node:url'],
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user