setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
- forceMobile={usesMobileLayout}
- />
+
+ setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
+ forceMobile={usesMobileLayout}
+ />
+
) : usesExpandedLayout ? (
// Expanded layout: sessions sidebar + chat side by side
diff --git a/packages/ui/src/lib/codemirror/languageByExtension.ts b/packages/ui/src/lib/codemirror/languageByExtension.ts
index 90e6b5d1..ae4ace64 100644
--- a/packages/ui/src/lib/codemirror/languageByExtension.ts
+++ b/packages/ui/src/lib/codemirror/languageByExtension.ts
@@ -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;
}
diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx
index 114a6716..f7af904b 100644
--- a/packages/ui/src/main.tsx
+++ b/packages/ui/src/main.tsx
@@ -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');
diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts
index feb805d3..4608381a 100644
--- a/packages/ui/src/sync/bootstrap.ts
+++ b/packages/ui/src/sync/bootstrap.ts
@@ -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)
+ })
}
diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts
index d178a1dc..b117f7f1 100644
--- a/packages/ui/src/sync/streaming.ts
+++ b/packages/ui/src/sync/streaming.ts
@@ -35,80 +35,80 @@ export const useStreamingStore = create()(() => ({
* 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()
- 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()
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
}
}
diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts
index 9f618d94..27560c95 100644
--- a/packages/web/vite.config.ts
+++ b/packages/web/vite.config.ts
@@ -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: {