Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export default defineConfig([
"src/services/aiService.ts",
"src/utils/tools/tools.ts",
"src/utils/ai/providerFactory.ts",
"src/utils/main/tokenizer.ts",
],
rules: {
"no-restricted-syntax": "off",
Expand Down
121 changes: 121 additions & 0 deletions src/components/ChatMetaSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React from "react";
import styled from "@emotion/styled";
import { usePersistedState } from "@/hooks/usePersistedState";
import { useWorkspaceUsage } from "@/stores/WorkspaceStore";
import { use1MContext } from "@/hooks/use1MContext";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { CostsTab } from "./RightSidebar/CostsTab";
import { VerticalTokenMeter } from "./RightSidebar/VerticalTokenMeter";
import { calculateTokenMeterData } from "@/utils/tokens/tokenMeterUtils";

interface SidebarContainerProps {
collapsed: boolean;
}

const SidebarContainer = styled.div<SidebarContainerProps>`
width: ${(props) => (props.collapsed ? "20px" : "300px")};
background: #252526;
border-left: 1px solid #3e3e42;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.2s ease;
flex-shrink: 0;

/* Keep vertical bar always visible when collapsed */
${(props) =>
props.collapsed &&
`
position: sticky;
right: 0;
z-index: 10;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.2);
`}
`;

const FullView = styled.div<{ visible: boolean }>`
display: ${(props) => (props.visible ? "flex" : "none")};
flex-direction: column;
height: 100%;
`;

const CollapsedView = styled.div<{ visible: boolean }>`
display: ${(props) => (props.visible ? "flex" : "none")};
height: 100%;
`;

const ContentScroll = styled.div`
flex: 1;
overflow-y: auto;
padding: 15px;
`;

interface ChatMetaSidebarProps {
workspaceId: string;
chatAreaRef: React.RefObject<HTMLDivElement>;
}

const ChatMetaSidebarComponent: React.FC<ChatMetaSidebarProps> = ({ workspaceId, chatAreaRef }) => {
const usage = useWorkspaceUsage(workspaceId);
const [use1M] = use1MContext();
const chatAreaSize = useResizeObserver(chatAreaRef);

const lastUsage = usage?.usageHistory[usage.usageHistory.length - 1];

// Memoize vertical meter data calculation to prevent unnecessary re-renders
const verticalMeterData = React.useMemo(() => {
// Get model from last usage
const model = lastUsage?.model ?? "unknown";
return lastUsage
? calculateTokenMeterData(lastUsage, model, use1M, true)
: { segments: [], totalTokens: 0, totalPercentage: 0 };
}, [lastUsage, use1M]);

// Calculate if we should show collapsed view with hysteresis
// Strategy: Observe ChatArea width directly (independent of sidebar width)
// - ChatArea has min-width: 750px and flex: 1
// - Use hysteresis to prevent oscillation:
// * Collapse when chatAreaWidth <= 800px (tight space)
// * Expand when chatAreaWidth >= 1100px (lots of space)
// * Between 800-1100: maintain current state (dead zone)
const COLLAPSE_THRESHOLD = 800; // Collapse below this
const EXPAND_THRESHOLD = 1100; // Expand above this
const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash

// Persist collapsed state globally (not per-workspace) since chat area width is shared
// This prevents animation flash when switching workspaces - sidebar maintains its state
const [showCollapsed, setShowCollapsed] = usePersistedState<boolean>(
"chat-meta-sidebar:collapsed",
false
);

React.useEffect(() => {
if (chatAreaWidth <= COLLAPSE_THRESHOLD) {
setShowCollapsed(true);
} else if (chatAreaWidth >= EXPAND_THRESHOLD) {
setShowCollapsed(false);
}
// Between thresholds: maintain current state (no change)
}, [chatAreaWidth, setShowCollapsed]);

return (
<SidebarContainer
collapsed={showCollapsed}
role="complementary"
aria-label="Workspace insights"
>
<FullView visible={!showCollapsed}>
<ContentScroll role="region" aria-label="Cost breakdown">
<CostsTab workspaceId={workspaceId} />
</ContentScroll>
</FullView>
<CollapsedView visible={showCollapsed}>
<VerticalTokenMeter data={verticalMeterData} />
</CollapsedView>
</SidebarContainer>
);
};

// Memoize to prevent re-renders when parent (AIView) re-renders during streaming
// Only re-renders when workspaceId or chatAreaRef changes, or internal state updates
export const ChatMetaSidebar = React.memo(ChatMetaSidebarComponent);
2 changes: 1 addition & 1 deletion src/debug/agentSessionCli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bun

import assert from "node:assert/strict";
import assert from "@/utils/assert";
import * as fs from "fs/promises";
import * as path from "path";
import { parseArgs } from "util";
Expand Down
2 changes: 1 addition & 1 deletion src/debug/chatExtractors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import assert from "node:assert/strict";
import assert from "@/utils/assert";
import type { CmuxReasoningPart, CmuxTextPart, CmuxToolPart } from "@/types/message";

export function extractAssistantText(parts: unknown): string {
Expand Down
41 changes: 30 additions & 11 deletions src/main-desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ function createWindow() {
const windowWidth = Math.max(1200, Math.floor(screenWidth * 0.8));
const windowHeight = Math.max(800, Math.floor(screenHeight * 0.8));

console.log(`[${timestamp()}] [window] Creating BrowserWindow...`);
console.time("[window] BrowserWindow creation");

mainWindow = new BrowserWindow({
width: windowWidth,
height: windowHeight,
Expand All @@ -368,8 +371,13 @@ function createWindow() {
show: false, // Don't show until ready-to-show event
});

console.timeEnd("[window] BrowserWindow creation");

// Register IPC handlers with the main window
console.log(`[${timestamp()}] [window] Registering IPC handlers...`);
console.time("[window] IPC registration");
ipcMain.register(electronIpcMain, mainWindow);
console.timeEnd("[window] IPC registration");

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

// Show window once it's ready and close splash
console.time("main window startup");
mainWindow.once("ready-to-show", () => {
console.log(`[${timestamp()}] Main window ready to show`);
mainWindow?.show();
closeSplashScreen();
console.timeEnd("main window startup");
});

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

// Load from dev server in development, built files in production
// app.isPackaged is true when running from a built .app/.exe, false in development
console.log(`[${timestamp()}] [window] Loading content...`);
console.time("[window] Content load");
if ((isE2ETest && !forceDistLoad) || (!app.isPackaged && !forceDistLoad)) {
// Development mode: load from vite dev server
const devHost = process.env.CMUX_DEVSERVER_HOST ?? "127.0.0.1";
void mainWindow.loadURL(`http://${devHost}:${devServerPort}`);
const url = `http://${devHost}:${devServerPort}`;
console.log(`[${timestamp()}] [window] Loading from dev server: ${url}`);
void mainWindow.loadURL(url);
if (!isE2ETest) {
mainWindow.webContents.once("did-finish-load", () => {
mainWindow?.webContents.openDevTools();
});
}
} else {
// Production mode: load built files
void mainWindow.loadFile(path.join(__dirname, "index.html"));
const htmlPath = path.join(__dirname, "index.html");
console.log(`[${timestamp()}] [window] Loading from file: ${htmlPath}`);
void mainWindow.loadFile(htmlPath);
}

// Track when content finishes loading
mainWindow.webContents.once("did-finish-load", () => {
console.timeEnd("[window] Content load");
console.log(`[${timestamp()}] [window] Content finished loading`);

// NOTE: Tokenizer modules are NOT loaded at startup anymore!
// The Proxy in tokenizer.ts loads them on-demand when first accessed.
// This reduces startup time from ~8s to <1s.
// First token count will use approximation, accurate count caches in background.
});

mainWindow.on("closed", () => {
mainWindow = null;
});
Expand Down Expand Up @@ -492,15 +519,7 @@ if (gotTheLock) {
createWindow();
// Note: splash closes in ready-to-show event handler

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

Expand Down
16 changes: 15 additions & 1 deletion src/services/agentSession.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import assert from "node:assert/strict";
import assert from "@/utils/assert";
import { EventEmitter } from "events";
import * as path from "path";
import { createCmuxMessage } from "@/types/message";
Expand All @@ -13,6 +13,7 @@ import { createUnknownSendMessageError } from "@/services/utils/sendMessageError
import type { Result } from "@/types/result";
import { Ok, Err } from "@/types/result";
import { enforceThinkingPolicy } from "@/utils/thinking/policy";
import { loadTokenizerForModel } from "@/utils/main/tokenizer";

interface ImagePart {
url: string;
Expand Down Expand Up @@ -302,6 +303,19 @@ export class AgentSession {
modelString: string,
options?: SendMessageOptions
): Promise<Result<void, SendMessageError>> {
try {
assert(
typeof modelString === "string" && modelString.trim().length > 0,
"modelString must be a non-empty string"
);
await loadTokenizerForModel(modelString);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return Err(
createUnknownSendMessageError(`Failed to preload tokenizer for ${modelString}: ${reason}`)
);
}

const commitResult = await this.partialService.commitToHistory(this.workspaceId);
if (!commitResult.success) {
return Err(createUnknownSendMessageError(commitResult.error));
Expand Down
2 changes: 1 addition & 1 deletion src/services/ipcMain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import assert from "node:assert/strict";
import assert from "@/utils/assert";
import type { BrowserWindow, IpcMain as ElectronIpcMain } from "electron";
import { spawn, spawnSync } from "child_process";
import * as fsPromises from "fs/promises";
Expand Down
2 changes: 1 addition & 1 deletion src/services/utils/sendMessageError.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import assert from "node:assert/strict";
import assert from "@/utils/assert";
import type { SendMessageError } from "@/types/errors";

/**
Expand Down
45 changes: 41 additions & 4 deletions src/stores/WorkspaceConsumerManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from "@/utils/assert";
import type { WorkspaceConsumersState } from "./WorkspaceStore";
import { TokenStatsWorker } from "@/utils/tokens/TokenStatsWorker";
import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator";
Expand Down Expand Up @@ -48,11 +49,24 @@ export class WorkspaceConsumerManager {
// Callback to bump the store when calculation completes
private readonly onCalculationComplete: (workspaceId: string) => void;

// Track pending store notifications to avoid duplicate bumps within the same tick
private pendingNotifications = new Set<string>();

constructor(onCalculationComplete: (workspaceId: string) => void) {
this.tokenWorker = new TokenStatsWorker();
this.onCalculationComplete = onCalculationComplete;
}

onTokenizerReady(listener: () => void): () => void {
assert(typeof listener === "function", "Tokenizer ready listener must be a function");
return this.tokenWorker.onTokenizerReady(listener);
}

onTokenizerEncodingLoaded(listener: (encodingName: string) => void): () => void {
assert(typeof listener === "function", "Tokenizer encoding listener must be a function");
return this.tokenWorker.onEncodingLoaded(listener);
}

/**
* Get cached state without side effects.
* Returns null if no cache exists.
Expand Down Expand Up @@ -117,7 +131,7 @@ export class WorkspaceConsumerManager {

// Notify store if newly scheduled (triggers UI update)
if (isNewSchedule) {
this.onCalculationComplete(workspaceId);
this.notifyStoreAsync(workspaceId);
}

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

// Mark as calculating and notify store
this.onCalculationComplete(workspaceId);
this.notifyStoreAsync(workspaceId);

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

// Notify store to trigger re-render
this.onCalculationComplete(workspaceId);
this.notifyStoreAsync(workspaceId);
} catch (error) {
// Cancellations are expected during rapid events - don't cache, don't log
// This allows lazy trigger to retry on next access
Expand All @@ -186,7 +200,7 @@ export class WorkspaceConsumerManager {
totalTokens: 0,
isCalculating: false,
});
this.onCalculationComplete(workspaceId);
this.notifyStoreAsync(workspaceId);
} finally {
this.pendingCalcs.delete(workspaceId);

Expand All @@ -200,6 +214,26 @@ export class WorkspaceConsumerManager {
})();
}

private notifyStoreAsync(workspaceId: string): void {
if (this.pendingNotifications.has(workspaceId)) {
return;
}

this.pendingNotifications.add(workspaceId);

const schedule =
typeof queueMicrotask === "function"
? queueMicrotask
: (callback: () => void) => {
void Promise.resolve().then(callback);
};

schedule(() => {
this.pendingNotifications.delete(workspaceId);
this.onCalculationComplete(workspaceId);
});
}

/**
* Remove workspace state and cleanup timers.
*/
Expand All @@ -216,6 +250,7 @@ export class WorkspaceConsumerManager {
this.scheduledCalcs.delete(workspaceId);
this.pendingCalcs.delete(workspaceId);
this.needsRecalc.delete(workspaceId);
this.pendingNotifications.delete(workspaceId);
}

/**
Expand All @@ -235,5 +270,7 @@ export class WorkspaceConsumerManager {
this.cache.clear();
this.scheduledCalcs.clear();
this.pendingCalcs.clear();
this.needsRecalc.clear();
this.pendingNotifications.clear();
}
}
Loading