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:
Shyamalan Kannan
2026-04-23 12:31:42 +03:00
committed by GitHub
co-authored by Shyamalan Kannan
parent 522cebf127
commit ecb22e19c3
12 changed files with 1869 additions and 1809 deletions
+37 -20
View File
@@ -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;
}
+17 -9
View File
@@ -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');
+92 -63
View File
@@ -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)
})
}
+64 -64
View File
@@ -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
}
}
+1 -1
View File
@@ -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: {