Skip to content

Commit fc0d5c7

Browse files
authored
feat: lazy tokenizer loading and defensive asserts (#307)
Introduce shared assert helper for both main and renderer bundles. Refactor tokenizer loading to fetch base and encodings on demand. Add tokenizer readiness events so stores reschedule usage updates. Ensure cached counts ignore fallback approximations with new test.
1 parent cc43552 commit fc0d5c7

20 files changed

+1108
-187
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export default defineConfig([
317317
"src/services/aiService.ts",
318318
"src/utils/tools/tools.ts",
319319
"src/utils/ai/providerFactory.ts",
320+
"src/utils/main/tokenizer.ts",
320321
],
321322
rules: {
322323
"no-restricted-syntax": "off",

src/components/ChatMetaSidebar.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import { usePersistedState } from "@/hooks/usePersistedState";
4+
import { useWorkspaceUsage } from "@/stores/WorkspaceStore";
5+
import { use1MContext } from "@/hooks/use1MContext";
6+
import { useResizeObserver } from "@/hooks/useResizeObserver";
7+
import { CostsTab } from "./RightSidebar/CostsTab";
8+
import { VerticalTokenMeter } from "./RightSidebar/VerticalTokenMeter";
9+
import { calculateTokenMeterData } from "@/utils/tokens/tokenMeterUtils";
10+
11+
interface SidebarContainerProps {
12+
collapsed: boolean;
13+
}
14+
15+
const SidebarContainer = styled.div<SidebarContainerProps>`
16+
width: ${(props) => (props.collapsed ? "20px" : "300px")};
17+
background: #252526;
18+
border-left: 1px solid #3e3e42;
19+
display: flex;
20+
flex-direction: column;
21+
overflow: hidden;
22+
transition: width 0.2s ease;
23+
flex-shrink: 0;
24+
25+
/* Keep vertical bar always visible when collapsed */
26+
${(props) =>
27+
props.collapsed &&
28+
`
29+
position: sticky;
30+
right: 0;
31+
z-index: 10;
32+
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2);
33+
`}
34+
`;
35+
36+
const FullView = styled.div<{ visible: boolean }>`
37+
display: ${(props) => (props.visible ? "flex" : "none")};
38+
flex-direction: column;
39+
height: 100%;
40+
`;
41+
42+
const CollapsedView = styled.div<{ visible: boolean }>`
43+
display: ${(props) => (props.visible ? "flex" : "none")};
44+
height: 100%;
45+
`;
46+
47+
const ContentScroll = styled.div`
48+
flex: 1;
49+
overflow-y: auto;
50+
padding: 15px;
51+
`;
52+
53+
interface ChatMetaSidebarProps {
54+
workspaceId: string;
55+
chatAreaRef: React.RefObject<HTMLDivElement>;
56+
}
57+
58+
const ChatMetaSidebarComponent: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
59+
const usage = useWorkspaceUsage(workspaceId);
60+
const [use1M] = use1MContext();
61+
const chatAreaSize = useResizeObserver(chatAreaRef);
62+
63+
const lastUsage = usage?.usageHistory[usage.usageHistory.length - 1];
64+
65+
// Memoize vertical meter data calculation to prevent unnecessary re-renders
66+
const verticalMeterData = React.useMemo(() => {
67+
// Get model from last usage
68+
const model = lastUsage?.model ?? "unknown";
69+
return lastUsage
70+
? calculateTokenMeterData(lastUsage, model, use1M, true)
71+
: { segments: [], totalTokens: 0, totalPercentage: 0 };
72+
}, [lastUsage, use1M]);
73+
74+
// Calculate if we should show collapsed view with hysteresis
75+
// Strategy: Observe ChatArea width directly (independent of sidebar width)
76+
// - ChatArea has min-width: 750px and flex: 1
77+
// - Use hysteresis to prevent oscillation:
78+
// * Collapse when chatAreaWidth <= 800px (tight space)
79+
// * Expand when chatAreaWidth >= 1100px (lots of space)
80+
// * Between 800-1100: maintain current state (dead zone)
81+
const COLLAPSE_THRESHOLD = 800; // Collapse below this
82+
const EXPAND_THRESHOLD = 1100; // Expand above this
83+
const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash
84+
85+
// Persist collapsed state globally (not per-workspace) since chat area width is shared
86+
// This prevents animation flash when switching workspaces - sidebar maintains its state
87+
const [showCollapsed, setShowCollapsed] = usePersistedState<boolean>(
88+
"chat-meta-sidebar:collapsed",
89+
false
90+
);
91+
92+
React.useEffect(() => {
93+
if (chatAreaWidth <= COLLAPSE_THRESHOLD) {
94+
setShowCollapsed(true);
95+
} else if (chatAreaWidth >= EXPAND_THRESHOLD) {
96+
setShowCollapsed(false);
97+
}
98+
// Between thresholds: maintain current state (no change)
99+
}, [chatAreaWidth, setShowCollapsed]);
100+
101+
return (
102+
<SidebarContainer
103+
collapsed={showCollapsed}
104+
role="complementary"
105+
aria-label="Workspace insights"
106+
>
107+
<FullView visible={!showCollapsed}>
108+
<ContentScroll role="region" aria-label="Cost breakdown">
109+
<CostsTab workspaceId={workspaceId} />
110+
</ContentScroll>
111+
</FullView>
112+
<CollapsedView visible={showCollapsed}>
113+
<VerticalTokenMeter data={verticalMeterData} />
114+
</CollapsedView>
115+
</SidebarContainer>
116+
);
117+
};
118+
119+
// Memoize to prevent re-renders when parent (AIView) re-renders during streaming
120+
// Only re-renders when workspaceId or chatAreaRef changes, or internal state updates
121+
export const ChatMetaSidebar = React.memo(ChatMetaSidebarComponent);

src/debug/agentSessionCli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bun
22

3-
import assert from "node:assert/strict";
3+
import assert from "@/utils/assert";
44
import * as fs from "fs/promises";
55
import * as path from "path";
66
import { parseArgs } from "util";

src/debug/chatExtractors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import assert from "node:assert/strict";
1+
import assert from "@/utils/assert";
22
import type { CmuxReasoningPart, CmuxTextPart, CmuxToolPart } from "@/types/message";
33

44
export function extractAssistantText(parts: unknown): string {

src/main-desktop.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ function createWindow() {
353353
const windowWidth = Math.max(1200, Math.floor(screenWidth * 0.8));
354354
const windowHeight = Math.max(800, Math.floor(screenHeight * 0.8));
355355

356+
console.log(`[${timestamp()}] [window] Creating BrowserWindow...`);
357+
console.time("[window] BrowserWindow creation");
358+
356359
mainWindow = new BrowserWindow({
357360
width: windowWidth,
358361
height: windowHeight,
@@ -368,8 +371,13 @@ function createWindow() {
368371
show: false, // Don't show until ready-to-show event
369372
});
370373

374+
console.timeEnd("[window] BrowserWindow creation");
375+
371376
// Register IPC handlers with the main window
377+
console.log(`[${timestamp()}] [window] Registering IPC handlers...`);
378+
console.time("[window] IPC registration");
372379
ipcMain.register(electronIpcMain, mainWindow);
380+
console.timeEnd("[window] IPC registration");
373381

374382
// Register updater IPC handlers (available in both dev and prod)
375383
electronIpcMain.handle(IPC_CHANNELS.UPDATE_CHECK, () => {
@@ -415,10 +423,12 @@ function createWindow() {
415423
}
416424

417425
// Show window once it's ready and close splash
426+
console.time("main window startup");
418427
mainWindow.once("ready-to-show", () => {
419428
console.log(`[${timestamp()}] Main window ready to show`);
420429
mainWindow?.show();
421430
closeSplashScreen();
431+
console.timeEnd("main window startup");
422432
});
423433

424434
// Open all external links in default browser
@@ -439,20 +449,37 @@ function createWindow() {
439449

440450
// Load from dev server in development, built files in production
441451
// app.isPackaged is true when running from a built .app/.exe, false in development
452+
console.log(`[${timestamp()}] [window] Loading content...`);
453+
console.time("[window] Content load");
442454
if ((isE2ETest && !forceDistLoad) || (!app.isPackaged && !forceDistLoad)) {
443455
// Development mode: load from vite dev server
444456
const devHost = process.env.CMUX_DEVSERVER_HOST ?? "127.0.0.1";
445-
void mainWindow.loadURL(`http://${devHost}:${devServerPort}`);
457+
const url = `http://${devHost}:${devServerPort}`;
458+
console.log(`[${timestamp()}] [window] Loading from dev server: ${url}`);
459+
void mainWindow.loadURL(url);
446460
if (!isE2ETest) {
447461
mainWindow.webContents.once("did-finish-load", () => {
448462
mainWindow?.webContents.openDevTools();
449463
});
450464
}
451465
} else {
452466
// Production mode: load built files
453-
void mainWindow.loadFile(path.join(__dirname, "index.html"));
467+
const htmlPath = path.join(__dirname, "index.html");
468+
console.log(`[${timestamp()}] [window] Loading from file: ${htmlPath}`);
469+
void mainWindow.loadFile(htmlPath);
454470
}
455471

472+
// Track when content finishes loading
473+
mainWindow.webContents.once("did-finish-load", () => {
474+
console.timeEnd("[window] Content load");
475+
console.log(`[${timestamp()}] [window] Content finished loading`);
476+
477+
// NOTE: Tokenizer modules are NOT loaded at startup anymore!
478+
// The Proxy in tokenizer.ts loads them on-demand when first accessed.
479+
// This reduces startup time from ~8s to <1s.
480+
// First token count will use approximation, accurate count caches in background.
481+
});
482+
456483
mainWindow.on("closed", () => {
457484
mainWindow = null;
458485
});
@@ -492,15 +519,7 @@ if (gotTheLock) {
492519
createWindow();
493520
// Note: splash closes in ready-to-show event handler
494521

495-
// Start loading tokenizer modules in background after window is created
496-
// This ensures accurate token counts for first API calls (especially in e2e tests)
497-
// Loading happens asynchronously and won't block the UI
498-
if (loadTokenizerModulesFn) {
499-
void loadTokenizerModulesFn().then(() => {
500-
console.log(`[${timestamp()}] Tokenizer modules loaded`);
501-
});
502-
}
503-
// No need to auto-start workspaces anymore - they start on demand
522+
// Tokenizer modules load in background after did-finish-load event (see createWindow())
504523
} catch (error) {
505524
console.error(`[${timestamp()}] Startup failed:`, error);
506525

src/services/agentSession.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import assert from "node:assert/strict";
1+
import assert from "@/utils/assert";
22
import { EventEmitter } from "events";
33
import * as path from "path";
44
import { createCmuxMessage } from "@/types/message";
@@ -13,6 +13,7 @@ import { createUnknownSendMessageError } from "@/services/utils/sendMessageError
1313
import type { Result } from "@/types/result";
1414
import { Ok, Err } from "@/types/result";
1515
import { enforceThinkingPolicy } from "@/utils/thinking/policy";
16+
import { loadTokenizerForModel } from "@/utils/main/tokenizer";
1617

1718
interface ImagePart {
1819
url: string;
@@ -302,6 +303,19 @@ export class AgentSession {
302303
modelString: string,
303304
options?: SendMessageOptions
304305
): Promise<Result<void, SendMessageError>> {
306+
try {
307+
assert(
308+
typeof modelString === "string" && modelString.trim().length > 0,
309+
"modelString must be a non-empty string"
310+
);
311+
await loadTokenizerForModel(modelString);
312+
} catch (error) {
313+
const reason = error instanceof Error ? error.message : String(error);
314+
return Err(
315+
createUnknownSendMessageError(`Failed to preload tokenizer for ${modelString}: ${reason}`)
316+
);
317+
}
318+
305319
const commitResult = await this.partialService.commitToHistory(this.workspaceId);
306320
if (!commitResult.success) {
307321
return Err(createUnknownSendMessageError(commitResult.error));

src/services/ipcMain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import assert from "node:assert/strict";
1+
import assert from "@/utils/assert";
22
import type { BrowserWindow, IpcMain as ElectronIpcMain } from "electron";
33
import { spawn, spawnSync } from "child_process";
44
import * as fsPromises from "fs/promises";

src/services/utils/sendMessageError.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import assert from "node:assert/strict";
1+
import assert from "@/utils/assert";
22
import type { SendMessageError } from "@/types/errors";
33

44
/**

src/stores/WorkspaceConsumerManager.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import assert from "@/utils/assert";
12
import type { WorkspaceConsumersState } from "./WorkspaceStore";
23
import { TokenStatsWorker } from "@/utils/tokens/TokenStatsWorker";
34
import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator";
@@ -48,11 +49,24 @@ export class WorkspaceConsumerManager {
4849
// Callback to bump the store when calculation completes
4950
private readonly onCalculationComplete: (workspaceId: string) => void;
5051

52+
// Track pending store notifications to avoid duplicate bumps within the same tick
53+
private pendingNotifications = new Set<string>();
54+
5155
constructor(onCalculationComplete: (workspaceId: string) => void) {
5256
this.tokenWorker = new TokenStatsWorker();
5357
this.onCalculationComplete = onCalculationComplete;
5458
}
5559

60+
onTokenizerReady(listener: () => void): () => void {
61+
assert(typeof listener === "function", "Tokenizer ready listener must be a function");
62+
return this.tokenWorker.onTokenizerReady(listener);
63+
}
64+
65+
onTokenizerEncodingLoaded(listener: (encodingName: string) => void): () => void {
66+
assert(typeof listener === "function", "Tokenizer encoding listener must be a function");
67+
return this.tokenWorker.onEncodingLoaded(listener);
68+
}
69+
5670
/**
5771
* Get cached state without side effects.
5872
* Returns null if no cache exists.
@@ -117,7 +131,7 @@ export class WorkspaceConsumerManager {
117131

118132
// Notify store if newly scheduled (triggers UI update)
119133
if (isNewSchedule) {
120-
this.onCalculationComplete(workspaceId);
134+
this.notifyStoreAsync(workspaceId);
121135
}
122136

123137
// Set new timer (150ms - imperceptible to humans, batches rapid events)
@@ -143,7 +157,7 @@ export class WorkspaceConsumerManager {
143157
this.pendingCalcs.add(workspaceId);
144158

145159
// Mark as calculating and notify store
146-
this.onCalculationComplete(workspaceId);
160+
this.notifyStoreAsync(workspaceId);
147161

148162
// Run in next tick to avoid blocking caller
149163
void (async () => {
@@ -170,7 +184,7 @@ export class WorkspaceConsumerManager {
170184
});
171185

172186
// Notify store to trigger re-render
173-
this.onCalculationComplete(workspaceId);
187+
this.notifyStoreAsync(workspaceId);
174188
} catch (error) {
175189
// Cancellations are expected during rapid events - don't cache, don't log
176190
// This allows lazy trigger to retry on next access
@@ -186,7 +200,7 @@ export class WorkspaceConsumerManager {
186200
totalTokens: 0,
187201
isCalculating: false,
188202
});
189-
this.onCalculationComplete(workspaceId);
203+
this.notifyStoreAsync(workspaceId);
190204
} finally {
191205
this.pendingCalcs.delete(workspaceId);
192206

@@ -200,6 +214,26 @@ export class WorkspaceConsumerManager {
200214
})();
201215
}
202216

217+
private notifyStoreAsync(workspaceId: string): void {
218+
if (this.pendingNotifications.has(workspaceId)) {
219+
return;
220+
}
221+
222+
this.pendingNotifications.add(workspaceId);
223+
224+
const schedule =
225+
typeof queueMicrotask === "function"
226+
? queueMicrotask
227+
: (callback: () => void) => {
228+
void Promise.resolve().then(callback);
229+
};
230+
231+
schedule(() => {
232+
this.pendingNotifications.delete(workspaceId);
233+
this.onCalculationComplete(workspaceId);
234+
});
235+
}
236+
203237
/**
204238
* Remove workspace state and cleanup timers.
205239
*/
@@ -216,6 +250,7 @@ export class WorkspaceConsumerManager {
216250
this.scheduledCalcs.delete(workspaceId);
217251
this.pendingCalcs.delete(workspaceId);
218252
this.needsRecalc.delete(workspaceId);
253+
this.pendingNotifications.delete(workspaceId);
219254
}
220255

221256
/**
@@ -235,5 +270,7 @@ export class WorkspaceConsumerManager {
235270
this.cache.clear();
236271
this.scheduledCalcs.clear();
237272
this.pendingCalcs.clear();
273+
this.needsRecalc.clear();
274+
this.pendingNotifications.clear();
238275
}
239276
}

0 commit comments

Comments
 (0)