Skip to content

Commit f2c8e84

Browse files
committed
feat: lazy tokenizer loading and defensive asserts
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 edeaf10 commit f2c8e84

18 files changed

+1031
-182
lines changed

src/components/ChatMetaSidebar.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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 { ToolsTab } from "./RightSidebar/ToolsTab";
9+
import { VerticalTokenMeter } from "./RightSidebar/VerticalTokenMeter";
10+
import { calculateTokenMeterData } from "@/utils/tokens/tokenMeterUtils";
11+
12+
interface SidebarContainerProps {
13+
collapsed: boolean;
14+
}
15+
16+
const SidebarContainer = styled.div<SidebarContainerProps>`
17+
width: ${(props) => (props.collapsed ? "20px" : "300px")};
18+
background: #252526;
19+
border-left: 1px solid #3e3e42;
20+
display: flex;
21+
flex-direction: column;
22+
overflow: hidden;
23+
transition: width 0.2s ease;
24+
flex-shrink: 0;
25+
26+
/* Keep vertical bar always visible when collapsed */
27+
${(props) =>
28+
props.collapsed &&
29+
`
30+
position: sticky;
31+
right: 0;
32+
z-index: 10;
33+
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2);
34+
`}
35+
`;
36+
37+
const FullView = styled.div<{ visible: boolean }>`
38+
display: ${(props) => (props.visible ? "flex" : "none")};
39+
flex-direction: column;
40+
height: 100%;
41+
`;
42+
43+
const CollapsedView = styled.div<{ visible: boolean }>`
44+
display: ${(props) => (props.visible ? "flex" : "none")};
45+
height: 100%;
46+
`;
47+
48+
const TabBar = styled.div`
49+
display: flex;
50+
background: #2d2d2d;
51+
border-bottom: 1px solid #3e3e42;
52+
`;
53+
54+
interface TabButtonProps {
55+
active: boolean;
56+
}
57+
58+
const TabButton = styled.button<TabButtonProps>`
59+
flex: 1;
60+
padding: 10px 15px;
61+
background: ${(props) => (props.active ? "#252526" : "transparent")};
62+
color: ${(props) => (props.active ? "#ffffff" : "#888888")};
63+
border: none;
64+
border-bottom: 2px solid ${(props) => (props.active ? "#007acc" : "transparent")};
65+
cursor: pointer;
66+
font-family: var(--font-primary);
67+
font-size: 13px;
68+
font-weight: 500;
69+
transition: all 0.2s ease;
70+
71+
&:hover {
72+
background: ${(props) => (props.active ? "#252526" : "#2d2d2d")};
73+
color: ${(props) => (props.active ? "#ffffff" : "#cccccc")};
74+
}
75+
`;
76+
77+
const TabContent = styled.div`
78+
flex: 1;
79+
overflow-y: auto;
80+
padding: 15px;
81+
`;
82+
83+
type TabType = "costs" | "tools";
84+
85+
interface ChatMetaSidebarProps {
86+
workspaceId: string;
87+
chatAreaRef: React.RefObject<HTMLDivElement>;
88+
}
89+
90+
const ChatMetaSidebarComponent: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
91+
const [selectedTab, setSelectedTab] = usePersistedState<TabType>(
92+
`chat-meta-sidebar-tab:${workspaceId}`,
93+
"costs"
94+
);
95+
96+
const usage = useWorkspaceUsage(workspaceId);
97+
const [use1M] = use1MContext();
98+
const chatAreaSize = useResizeObserver(chatAreaRef);
99+
100+
const baseId = `chat-meta-${workspaceId}`;
101+
const costsTabId = `${baseId}-tab-costs`;
102+
const toolsTabId = `${baseId}-tab-tools`;
103+
const costsPanelId = `${baseId}-panel-costs`;
104+
const toolsPanelId = `${baseId}-panel-tools`;
105+
106+
const lastUsage = usage?.usageHistory[usage.usageHistory.length - 1];
107+
108+
// Memoize vertical meter data calculation to prevent unnecessary re-renders
109+
const verticalMeterData = React.useMemo(() => {
110+
// Get model from last usage
111+
const model = lastUsage?.model ?? "unknown";
112+
return lastUsage
113+
? calculateTokenMeterData(lastUsage, model, use1M, true)
114+
: { segments: [], totalTokens: 0, totalPercentage: 0 };
115+
}, [lastUsage, use1M]);
116+
117+
// Calculate if we should show collapsed view with hysteresis
118+
// Strategy: Observe ChatArea width directly (independent of sidebar width)
119+
// - ChatArea has min-width: 750px and flex: 1
120+
// - Use hysteresis to prevent oscillation:
121+
// * Collapse when chatAreaWidth <= 800px (tight space)
122+
// * Expand when chatAreaWidth >= 1100px (lots of space)
123+
// * Between 800-1100: maintain current state (dead zone)
124+
const COLLAPSE_THRESHOLD = 800; // Collapse below this
125+
const EXPAND_THRESHOLD = 1100; // Expand above this
126+
const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash
127+
128+
// Persist collapsed state globally (not per-workspace) since chat area width is shared
129+
// This prevents animation flash when switching workspaces - sidebar maintains its state
130+
const [showCollapsed, setShowCollapsed] = usePersistedState<boolean>(
131+
"chat-meta-sidebar:collapsed",
132+
false
133+
);
134+
135+
React.useEffect(() => {
136+
if (chatAreaWidth <= COLLAPSE_THRESHOLD) {
137+
setShowCollapsed(true);
138+
} else if (chatAreaWidth >= EXPAND_THRESHOLD) {
139+
setShowCollapsed(false);
140+
}
141+
// Between thresholds: maintain current state (no change)
142+
}, [chatAreaWidth, setShowCollapsed]);
143+
144+
return (
145+
<SidebarContainer
146+
collapsed={showCollapsed}
147+
role="complementary"
148+
aria-label="Workspace insights"
149+
>
150+
<FullView visible={!showCollapsed}>
151+
<TabBar role="tablist" aria-label="Metadata views">
152+
<TabButton
153+
active={selectedTab === "costs"}
154+
onClick={() => setSelectedTab("costs")}
155+
id={costsTabId}
156+
role="tab"
157+
type="button"
158+
aria-selected={selectedTab === "costs"}
159+
aria-controls={costsPanelId}
160+
>
161+
Costs
162+
</TabButton>
163+
<TabButton
164+
active={selectedTab === "tools"}
165+
onClick={() => setSelectedTab("tools")}
166+
id={toolsTabId}
167+
role="tab"
168+
type="button"
169+
aria-selected={selectedTab === "tools"}
170+
aria-controls={toolsPanelId}
171+
>
172+
Tools
173+
</TabButton>
174+
</TabBar>
175+
<TabContent>
176+
{selectedTab === "costs" && (
177+
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
178+
<CostsTab workspaceId={workspaceId} />
179+
</div>
180+
)}
181+
{selectedTab === "tools" && (
182+
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
183+
<ToolsTab />
184+
</div>
185+
)}
186+
</TabContent>
187+
</FullView>
188+
<CollapsedView visible={showCollapsed}>
189+
<VerticalTokenMeter data={verticalMeterData} />
190+
</CollapsedView>
191+
</SidebarContainer>
192+
);
193+
};
194+
195+
// Memoize to prevent re-renders when parent (AIView) re-renders during streaming
196+
// Only re-renders when workspaceId or chatAreaRef changes, or internal state updates
197+
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
/**

0 commit comments

Comments
 (0)