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
124 changes: 121 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export default defineConfig([
"src/config.ts",
"src/debug/**/*.ts",
"src/git.ts",
"src/main.ts",
"src/main-desktop.ts",
"src/config.test.ts",
"src/services/gitService.ts",
"src/services/log.ts",
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,25 @@
"@storybook/test-runner": "^0.23.0",
"@testing-library/react": "^16.3.0",
"@types/bun": "^1.2.23",
"@types/cors": "^2.8.19",
"@types/diff": "^8.0.0",
"@types/escape-html": "^1.0.4",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/katex": "^0.16.7",
"@types/markdown-it": "^14.1.2",
"@types/minimist": "^1.2.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/write-file-atomic": "^4.0.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"@typescript/native-preview": "^7.0.0-dev.20251014.1",
"@vitejs/plugin-react": "^4.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^8.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"electron": "^38.2.1",
"electron-builder": "^24.6.0",
Expand All @@ -106,6 +110,7 @@
"eslint": "^9.36.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"express": "^5.1.0",
"jest": "^30.1.3",
"playwright": "^1.56.0",
"prettier": "^3.6.2",
Expand All @@ -116,7 +121,8 @@
"typescript-eslint": "^8.45.0",
"vite": "^4.4.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.6.0"
"vite-plugin-top-level-await": "^1.6.0",
"ws": "^8.18.3"
},
"build": {
"appId": "com.cmux.app",
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { WorkspaceSelection } from "./components/ProjectSidebar";
import type { FrontendWorkspaceMetadata } from "./types/workspace";
import { LeftSidebar } from "./components/LeftSidebar";
import NewWorkspaceModal from "./components/NewWorkspaceModal";
import { DirectorySelectModal } from "./components/DirectorySelectModal";
import { AIView } from "./components/AIView";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
Expand Down Expand Up @@ -870,6 +871,7 @@ function AppInner() {
onAdd={handleCreateWorkspace}
/>
)}
<DirectorySelectModal />
</AppContainer>
</>
);
Expand Down
279 changes: 279 additions & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/**
* Browser API client. Used when running cmux in server mode.
*/
import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants";
import type { IPCApi } from "@/types/ipc";

const API_BASE = window.location.origin;
const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://");

interface InvokeResponse<T> {
success: boolean;
data?: T;
error?: string;
}

// Helper function to invoke IPC handlers via HTTP
async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T> {
const response = await fetch(`${API_BASE}/ipc/${encodeURIComponent(channel)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ args }),
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const result = (await response.json()) as InvokeResponse<T>;

if (!result.success) {
throw new Error(result.error ?? "Unknown error");
}

return result.data as T;
}

// WebSocket connection manager
class WebSocketManager {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private messageHandlers = new Map<string, Set<(data: unknown) => void>>();
private channelWorkspaceIds = new Map<string, string>(); // Track workspaceId for each channel
private isConnecting = false;
private shouldReconnect = true;

connect(): void {
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
return;
}

this.isConnecting = true;
this.ws = new WebSocket(`${WS_BASE}/ws`);

this.ws.onopen = () => {
console.log("WebSocket connected");
this.isConnecting = false;

// Resubscribe to all channels with their workspace IDs
for (const channel of this.messageHandlers.keys()) {
const workspaceId = this.channelWorkspaceIds.get(channel);
this.subscribe(channel, workspaceId);
}
};

this.ws.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data as string) as { channel: string; args: unknown[] };
const { channel, args } = parsed;
const handlers = this.messageHandlers.get(channel);
if (handlers && args.length > 0) {
handlers.forEach((handler) => handler(args[0]));
}
} catch (error) {
console.error("Error handling WebSocket message:", error);
}
};

this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
this.isConnecting = false;
};

this.ws.onclose = () => {
console.log("WebSocket disconnected");
this.isConnecting = false;
this.ws = null;

// Attempt to reconnect after a delay
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
}
};
}

subscribe(channel: string, workspaceId?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
console.log(
`[WebSocketManager] Subscribing to workspace chat for workspaceId: ${workspaceId ?? "undefined"}`
);
this.ws.send(
JSON.stringify({
type: "subscribe",
channel: "workspace:chat",
workspaceId,
})
);
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
this.ws.send(
JSON.stringify({
type: "subscribe",
channel: "workspace:metadata",
})
);
}
}
}

unsubscribe(channel: string, workspaceId?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
if (channel.startsWith(IPC_CHANNELS.WORKSPACE_CHAT_PREFIX)) {
this.ws.send(
JSON.stringify({
type: "unsubscribe",
channel: "workspace:chat",
workspaceId,
})
);
} else if (channel === IPC_CHANNELS.WORKSPACE_METADATA) {
this.ws.send(
JSON.stringify({
type: "unsubscribe",
channel: "workspace:metadata",
})
);
}
}
}

on(channel: string, handler: (data: unknown) => void, workspaceId?: string): () => void {
if (!this.messageHandlers.has(channel)) {
this.messageHandlers.set(channel, new Set());
// Store workspaceId for this channel (needed for reconnection)
if (workspaceId) {
this.channelWorkspaceIds.set(channel, workspaceId);
}
this.connect();
this.subscribe(channel, workspaceId);
}

const handlers = this.messageHandlers.get(channel)!;
handlers.add(handler);

// Return unsubscribe function
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
this.messageHandlers.delete(channel);
this.channelWorkspaceIds.delete(channel);
this.unsubscribe(channel, workspaceId);
}
};
}

disconnect(): void {
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}

const wsManager = new WebSocketManager();

// Directory selection via custom event (for browser mode)
interface DirectorySelectEvent extends CustomEvent {
detail: {
resolve: (path: string | null) => void;
};
}

function requestDirectorySelection(): Promise<string | null> {
return new Promise((resolve) => {
const event = new CustomEvent("directory-select-request", {
detail: { resolve },
}) as DirectorySelectEvent;
window.dispatchEvent(event);
});
}

// Create the Web API implementation
const webApi: IPCApi = {
dialog: {
selectDirectory: requestDirectorySelection,
},
providers: {
setProviderConfig: (provider, keyPath, value) =>
invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value),
list: () => invokeIPC(IPC_CHANNELS.PROVIDERS_LIST),
},
projects: {
create: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_CREATE, projectPath),
remove: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_REMOVE, projectPath),
list: () => invokeIPC(IPC_CHANNELS.PROJECT_LIST),
listBranches: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath),
secrets: {
get: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_GET, projectPath),
update: (projectPath, secrets) =>
invokeIPC(IPC_CHANNELS.PROJECT_SECRETS_UPDATE, projectPath, secrets),
},
},
workspace: {
list: () => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST),
create: (projectPath, branchName, trunkBranch) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_CREATE, projectPath, branchName, trunkBranch),
remove: (workspaceId, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options),
rename: (workspaceId, newName) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName),
fork: (sourceWorkspaceId, newName) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName),
sendMessage: (workspaceId, message, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
resumeStream: (workspaceId, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
interruptStream: (workspaceId, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
truncateHistory: (workspaceId, percentage) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage),
replaceChatHistory: (workspaceId, summaryMessage) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_REPLACE_HISTORY, workspaceId, summaryMessage),
getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
executeBash: (workspaceId, script, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),

onChat: (workspaceId, callback) => {
const channel = getChatChannel(workspaceId);
return wsManager.on(channel, callback as (data: unknown) => void, workspaceId);
},

onMetadata: (callback) => {
return wsManager.on(IPC_CHANNELS.WORKSPACE_METADATA, callback as (data: unknown) => void);
},
},
window: {
setTitle: (title) => {
document.title = title;
return Promise.resolve();
},
},
update: {
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),
install: () => {
// Install is a one-way call that doesn't wait for response
void invokeIPC(IPC_CHANNELS.UPDATE_INSTALL);
},
onStatus: (callback) => {
return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void);
},
},
};

if (typeof window.api === "undefined") {
// @ts-expect-error - Assigning to window.api which is not in TypeScript types
window.api = webApi;
}

window.addEventListener("beforeunload", () => {
wsManager.disconnect();
});
Loading
Loading