Skip to content

Commit fd4bfa9

Browse files
committed
refactor: Unify init hooks with chat stream
Simplifies init hook architecture by reusing existing chat stream infrastructure instead of creating parallel IPC system. Changes: - Add WorkspaceInitEvent union to WorkspaceChatMessage (3 event types) - Backend emits init events via AgentSession.emitChatEvent() - Frontend handles init events from chat subscription - Tests filter init events from chat channel - Remove metaEventBuffer, onMeta(), and WORKSPACE_STREAM_META Benefits: - ~80 net LoC reduction (removed ~150, added ~70) - 1 subscription per workspace instead of 2 - Automatic replay via existing history mechanism - Cleaner types (no redundant workspaceId field) - Single buffer for all workspace events Init events are ephemeral (not persisted) and flow through the same stream as caught-up, stream-error, and delete messages.
1 parent acb3548 commit fd4bfa9

File tree

9 files changed

+159
-184
lines changed

9 files changed

+159
-184
lines changed

bun.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
"crc-32": "^1.2.2",
1414
"diff": "^8.0.2",
1515
"disposablestack": "^1.1.7",
16-
"electron": "^38.2.1",
1716
"electron-updater": "^6.6.2",
1817
"express": "^5.1.0",
1918
"jsonc-parser": "^3.3.1",
@@ -61,6 +60,7 @@
6160
"cmdk": "^1.0.0",
6261
"concurrently": "^8.2.0",
6362
"dotenv": "^17.2.3",
63+
"electron": "^38.2.1",
6464
"electron-builder": "^24.6.0",
6565
"electron-devtools-installer": "^4.0.0",
6666
"electron-mock-ipc": "^0.3.12",

src/browser/api.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,6 @@ const webApi: IPCApi = {
249249
onMetadata: (callback) => {
250250
return wsManager.on(IPC_CHANNELS.WORKSPACE_METADATA, callback as (data: unknown) => void);
251251
},
252-
253-
onMeta: (workspaceId, callback) => {
254-
return wsManager.on(IPC_CHANNELS.WORKSPACE_STREAM_META, callback as (data: unknown) => void, workspaceId);
255-
},
256-
257252
},
258253
window: {
259254
setTitle: (title) => {

src/components/AIView.tsx

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import { useGitStatus } from "@/stores/GitStatusStore";
2929
import { TooltipWrapper, Tooltip } from "./Tooltip";
3030
import type { DisplayedMessage } from "@/types/message";
3131
import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds";
32-
import type { WorkspaceMetaEvent } from "@/types/workspace";
32+
import type { WorkspaceChatMessage } from "@/types/ipc";
33+
import { isInitStart, isInitOutput, isInitEnd } from "@/types/ipc";
3334

3435
const ViewContainer = styled.div`
3536
flex: 1;
@@ -275,46 +276,32 @@ const AIViewInner: React.FC<AIViewProps> = ({
275276
const [initExitCode, setInitExitCode] = useState<number | null>(null);
276277
const [showInit, setShowInit] = useState<boolean>(false);
277278

279+
// Subscribe to init hook events from chat stream
278280
useEffect(() => {
279-
setInitLines([]);
280-
setInitExitCode(null);
281-
setShowInit(false);
282-
const unsubscribe = window.api.workspace.onMeta(workspaceId, (data: WorkspaceMetaEvent) => {
283-
if (data.workspaceId !== workspaceId) return;
284-
switch (data.type) {
285-
case "workspace-init-start":
281+
const handleMessage = (msg: WorkspaceChatMessage) => {
282+
if (isInitStart(msg)) {
283+
setShowInit(true);
284+
setInitLines([]);
285+
setInitExitCode(null);
286+
} else if (isInitOutput(msg)) {
287+
setShowInit(true);
288+
const line = msg.isError ? `ERROR: ${msg.line}` : msg.line;
289+
setInitLines((prev) => [...prev, line.trimEnd()]);
290+
} else if (isInitEnd(msg)) {
291+
const code = msg.exitCode;
292+
setInitExitCode(code);
293+
if (code === 0) {
294+
setTimeout(() => setShowInit(false), 800);
295+
} else {
286296
setShowInit(true);
287-
break;
288-
case "workspace-init-output":
289-
setShowInit(true);
290-
setInitLines((prev) => [...prev, data.line.trimEnd()]);
291-
break;
292-
case "workspace-init-error":
293-
setShowInit(true);
294-
// Prefer line when present, else error
295-
{
296-
const line = data.line ?? (data.error ? `ERROR: ${data.error}` : null);
297-
if (line) {
298-
setInitLines((prev) => [...prev, line]);
299-
}
300-
}
301-
setInitExitCode((c) => c ?? -1);
302-
break;
303-
case "workspace-init-end":
304-
{
305-
const code = data.exitCode;
306-
setInitExitCode(code);
307-
if (code === 0) {
308-
setTimeout(() => setShowInit(false), 800);
309-
} else {
310-
setShowInit(true);
311-
}
312-
}
313-
break;
297+
}
314298
}
315-
});
316-
return () => unsubscribe?.();
299+
};
300+
301+
const unsubscribe = window.api.workspace.onChat(workspaceId, handleMessage);
302+
return unsubscribe;
317303
}, [workspaceId]);
304+
318305
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
319306
undefined
320307
);

src/constants/ipc-constants.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ export const IPC_CHANNELS = {
3434
WORKSPACE_GET_INFO: "workspace:getInfo",
3535
WORKSPACE_EXECUTE_BASH: "workspace:executeBash",
3636
WORKSPACE_OPEN_TERMINAL: "workspace:openTerminal",
37-
// Meta stream for non-chat workspace events (e.g., init hook output)
38-
// Renderer should subscribe and display for the active workspace context
39-
WORKSPACE_STREAM_META: "workspace:streamMeta",
4037

4138
// Window channels
4239
WINDOW_SET_TITLE: "window:setTitle",

src/preload.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
import { contextBridge, ipcRenderer } from "electron";
2222
import type { IPCApi, WorkspaceChatMessage, UpdateStatus } from "./types/ipc";
23-
import type { FrontendWorkspaceMetadata, WorkspaceMetaEvent } from "./types/workspace";
23+
import type { FrontendWorkspaceMetadata } from "./types/workspace";
2424
import type { ProjectConfig } from "./types/project";
2525
import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants";
2626

@@ -110,18 +110,6 @@ const api: IPCApi = {
110110
ipcRenderer.send(`workspace:metadata:unsubscribe`);
111111
};
112112
},
113-
onMeta: (workspaceId: string, callback: (data: WorkspaceMetaEvent) => void) => {
114-
const handler = (_event: unknown, data: WorkspaceMetaEvent) => {
115-
// Forward to consumer - consumer is responsible for filtering by workspaceId
116-
callback(data);
117-
};
118-
ipcRenderer.on(IPC_CHANNELS.WORKSPACE_STREAM_META, handler);
119-
ipcRenderer.send(`workspace:meta:subscribe`, workspaceId);
120-
return () => {
121-
ipcRenderer.removeListener(IPC_CHANNELS.WORKSPACE_STREAM_META, handler);
122-
ipcRenderer.send(`workspace:meta:unsubscribe`, workspaceId);
123-
};
124-
},
125113
},
126114
window: {
127115
setTitle: (title: string) => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_SET_TITLE, title),

src/services/agentSession.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,8 @@ export class AgentSession {
391391
this.aiService.on("error", errorHandler as never);
392392
}
393393

394-
private emitChatEvent(message: WorkspaceChatMessage): void {
394+
// Public method to emit chat events (used by init hooks and other workspace events)
395+
emitChatEvent(message: WorkspaceChatMessage): void {
395396
this.assertNotDisposed("emitChatEvent");
396397
this.emitter.emit("chat-event", {
397398
workspaceId: this.workspaceId,

src/services/ipcMain.ts

Lines changed: 38 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { createBashTool } from "@/services/tools/bash";
2828
import type { BashToolResult } from "@/types/tools";
2929
import { secretsToRecord } from "@/types/secrets";
3030
import { DisposableTempDir } from "@/services/tempDir";
31-
import type { WorkspaceMetaEvent } from "@/types/workspace";
3231

3332
/**
3433
* IpcMain - Manages all IPC handlers and service coordination
@@ -54,28 +53,14 @@ export class IpcMain {
5453
{ chat: () => void; metadata: () => void }
5554
>();
5655
private mainWindow: BrowserWindow | null = null;
57-
// Buffer of streaming meta events keyed by workspaceId. Used to replay to late subscribers.
58-
private readonly metaEventBuffer: Map<string, WorkspaceMetaEvent[]> = new Map<
59-
string,
60-
WorkspaceMetaEvent[]
61-
>();
62-
63-
// Helper to emit a workspace meta event to the renderer and buffer it for replay
64-
private emitWorkspaceMeta(event: WorkspaceMetaEvent): void {
65-
if (!event?.workspaceId) return;
66-
const arr = this.metaEventBuffer.get(event.workspaceId) ?? [];
67-
arr.push(event);
68-
this.metaEventBuffer.set(event.workspaceId, arr);
69-
this.mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_STREAM_META, event);
70-
}
7156

7257
// Run optional .cmux/init hook for a newly created workspace and stream its output
7358
private async runWorkspaceInitHook(params: {
7459
projectPath: string;
7560
worktreePath: string;
7661
workspaceId: string;
7762
}): Promise<void> {
78-
// Non-blocking fire-and-forget; errors are reported via meta stream
63+
// Non-blocking fire-and-forget; errors are reported via chat stream
7964
try {
8065
const hookPath = path.join(params.projectPath, ".cmux", "init");
8166
// Check if hook exists
@@ -87,12 +72,13 @@ export class IpcMain {
8772
return; // Nothing to do
8873
}
8974

90-
// Emit start event
91-
this.emitWorkspaceMeta({
92-
type: "workspace-init-start",
93-
workspaceId: params.workspaceId,
94-
timestamp: Date.now(),
75+
const session = this.getOrCreateSession(params.workspaceId);
76+
77+
// Emit start event via chat stream
78+
session.emitChatEvent({
79+
type: "init-start",
9580
hookPath,
81+
timestamp: Date.now(),
9682
});
9783

9884
// Spawn bash to run the hook in the new worktree directory
@@ -119,11 +105,11 @@ export class IpcMain {
119105
const partial = lines.pop() ?? "";
120106
for (const line of lines) {
121107
if (line.length === 0) continue;
122-
this.emitWorkspaceMeta({
123-
type: isErr ? "workspace-init-error" : "workspace-init-output",
124-
workspaceId: params.workspaceId,
125-
timestamp: Date.now(),
108+
session.emitChatEvent({
109+
type: "init-output",
126110
line,
111+
isError: isErr,
112+
timestamp: Date.now(),
127113
});
128114
}
129115
return partial;
@@ -142,56 +128,55 @@ export class IpcMain {
142128
child.on("close", (code: number | null) => {
143129
// Flush any remaining partials as lines
144130
if (outBuf.trim().length > 0) {
145-
this.emitWorkspaceMeta({
146-
type: "workspace-init-output",
147-
workspaceId: params.workspaceId,
148-
timestamp: Date.now(),
131+
session.emitChatEvent({
132+
type: "init-output",
149133
line: outBuf,
134+
isError: false,
135+
timestamp: Date.now(),
150136
});
151137
}
152138
if (errBuf.trim().length > 0) {
153-
this.emitWorkspaceMeta({
154-
type: "workspace-init-error",
155-
workspaceId: params.workspaceId,
156-
timestamp: Date.now(),
139+
session.emitChatEvent({
140+
type: "init-output",
157141
line: errBuf,
142+
isError: true,
143+
timestamp: Date.now(),
158144
});
159145
}
160146

161-
this.emitWorkspaceMeta({
162-
type: "workspace-init-end",
163-
workspaceId: params.workspaceId,
164-
timestamp: Date.now(),
147+
session.emitChatEvent({
148+
type: "init-end",
165149
exitCode: typeof code === "number" ? code : -1,
150+
timestamp: Date.now(),
166151
});
167152
});
168153

169154
child.on("error", (error: unknown) => {
170-
this.emitWorkspaceMeta({
171-
type: "workspace-init-error",
172-
workspaceId: params.workspaceId,
155+
session.emitChatEvent({
156+
type: "init-output",
157+
line: error instanceof Error ? error.message : String(error),
158+
isError: true,
173159
timestamp: Date.now(),
174-
error: error instanceof Error ? error.message : String(error),
175160
});
176-
this.emitWorkspaceMeta({
177-
type: "workspace-init-end",
178-
workspaceId: params.workspaceId,
179-
timestamp: Date.now(),
161+
// Also emit end with error code
162+
session.emitChatEvent({
163+
type: "init-end",
180164
exitCode: -1,
165+
timestamp: Date.now(),
181166
});
182167
});
183168
} catch (error) {
184-
this.emitWorkspaceMeta({
185-
type: "workspace-init-error",
186-
workspaceId: params.workspaceId,
169+
const session = this.getOrCreateSession(params.workspaceId);
170+
session.emitChatEvent({
171+
type: "init-output",
172+
line: error instanceof Error ? error.message : String(error),
173+
isError: true,
187174
timestamp: Date.now(),
188-
error: error instanceof Error ? error.message : String(error),
189175
});
190-
this.emitWorkspaceMeta({
191-
type: "workspace-init-end",
192-
workspaceId: params.workspaceId,
193-
timestamp: Date.now(),
176+
session.emitChatEvent({
177+
type: "init-end",
194178
exitCode: -1,
179+
timestamp: Date.now(),
195180
});
196181
}
197182
}
@@ -1332,18 +1317,7 @@ export class IpcMain {
13321317
})();
13331318
});
13341319

1335-
// Handle subscription events for workspace meta stream (init hook output)
1336-
ipcMain.on(`workspace:meta:subscribe`, (_event, workspaceId: string) => {
1337-
if (!workspaceId || typeof workspaceId !== "string") return;
1338-
const buffered = this.metaEventBuffer.get(workspaceId) ?? [];
1339-
for (const ev of buffered) {
1340-
this.mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_STREAM_META, ev);
1341-
}
1342-
});
13431320

1344-
ipcMain.on(`workspace:meta:unsubscribe`, (_event, _workspaceId: string) => {
1345-
// No-op: we keep buffer for the session; it will be disposed with the session
1346-
});
13471321

13481322
// Handle subscription events for metadata
13491323
ipcMain.on(IPC_CHANNELS.WORKSPACE_METADATA_SUBSCRIBE, () => {

0 commit comments

Comments
 (0)