From 66f66d1c17213f55aa56d69c0cccc309b16f3362 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sat, 28 Feb 2026 07:27:49 -0800 Subject: Phase 3: IPC layer - Implement src/main/preload.ts with typed API bridge - Projects, sessions, messages CRUD - Chat send/interrupt - Workflow review/advance/permissions - Artifact read/write - Directory picker dialog - Claude message event subscription - Implement src/main/ipc/handlers.ts - All IPC handlers with proper error handling - Message forwarding to renderer - Assistant message storage - Update src/main/index.ts - Initialize database on startup - Register IPC handlers - Clean database close on exit --- src/main/index.ts | 90 ++++++++++++++++++------------------ src/main/ipc/handlers.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++++ src/main/preload.ts | 115 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 275 insertions(+), 47 deletions(-) create mode 100644 src/main/ipc/handlers.ts diff --git a/src/main/index.ts b/src/main/index.ts index b164f15..f0b23f7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,63 +1,61 @@ -import Database from 'better-sqlite3' -import { app, BrowserWindow } from 'electron' -import fs from 'node:fs' -import path from 'node:path' +import { app, BrowserWindow } from "electron"; +import path from "node:path"; +import { getDb, closeDb } from "./db"; +import { registerIpcHandlers } from "./ipc/handlers"; -const isDev = !app.isPackaged // reliable dev/prod switch. [oai_citation:2‡Electron](https://electronjs.org/docs/latest/api/app?utm_source=chatgpt.com) +const isDev = !app.isPackaged; +let mainWindow: BrowserWindow | null = null; function createWindow() { - const win = new BrowserWindow({ - width: 800, - height: 600, + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1000, + minHeight: 600, show: false, + titleBarStyle: "hiddenInset", webPreferences: { contextIsolation: true, nodeIntegration: false, - preload: path.join(__dirname, 'preload.js'), + preload: path.join(__dirname, "preload.js"), }, - }) + }); + + registerIpcHandlers(mainWindow); if (isDev) { - const url = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5173' - win.loadURL(url).finally(() => { - win.show() - win.webContents.openDevTools({ mode: 'detach' }) - }) + const url = process.env.VITE_DEV_SERVER_URL ?? "http://localhost:5173"; + mainWindow.loadURL(url).finally(() => { + mainWindow!.show(); + mainWindow!.webContents.openDevTools({ mode: "detach" }); + }); } else { const indexHtml = path.join( app.getAppPath(), - 'renderer', - 'dist', - 'index.html' - ) - win.loadFile(indexHtml).finally(() => win.show()) + "renderer", + "dist", + "index.html" + ); + mainWindow.loadFile(indexHtml).finally(() => mainWindow!.show()); } } -function initDb() { - const dbDir = app.getPath('userData') - if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }) - const dbPath = path.join(dbDir, 'app.db') - - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - db.prepare('CREATE TABLE IF NOT EXISTS messages (text TEXT)').run() - db.prepare('INSERT INTO messages (text) VALUES (?)').run( - 'hello from better-sqlite3' - ) - const row = db.prepare('SELECT text FROM messages LIMIT 1').get() - console.log('Selected row:', row) - db.close() -} - app.whenReady().then(() => { - initDb() - createWindow() - app.on( - 'activate', - () => BrowserWindow.getAllWindows().length === 0 && createWindow() - ) -}) -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() -}) + // Initialize database + getDb(); + + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + closeDb(); + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts new file mode 100644 index 0000000..ce95f4c --- /dev/null +++ b/src/main/ipc/handlers.ts @@ -0,0 +1,117 @@ +import { ipcMain, dialog, type BrowserWindow } from "electron"; +import * as projects from "../db/projects"; +import * as sessions from "../db/sessions"; +import * as claude from "../claude"; +import type { UserPermissionMode } from "../claude/phases"; + +export function registerIpcHandlers(mainWindow: BrowserWindow): void { + // Projects + ipcMain.handle("projects:list", () => projects.listProjects()); + ipcMain.handle("projects:create", (_, name: string, path: string) => + projects.createProject(name, path) + ); + ipcMain.handle("projects:delete", (_, id: string) => + projects.deleteProject(id) + ); + + // Sessions + ipcMain.handle("sessions:list", (_, projectId: string) => + sessions.listSessions(projectId) + ); + ipcMain.handle("sessions:create", (_, projectId: string, name: string) => + sessions.createSession(projectId, name) + ); + ipcMain.handle("sessions:delete", (_, id: string) => + sessions.deleteSession(id) + ); + ipcMain.handle("sessions:get", (_, id: string) => sessions.getSession(id)); + + // Messages + ipcMain.handle("messages:list", (_, sessionId: string) => + sessions.listMessages(sessionId) + ); + + // Chat + ipcMain.handle( + "chat:send", + async (_, sessionId: string, message: string) => { + const session = sessions.getSession(sessionId); + if (!session) throw new Error("Session not found"); + + // Store user message + sessions.addMessage(sessionId, "user", message); + + await claude.sendMessage({ + session, + message, + onMessage: (msg) => { + // Forward all messages to renderer + mainWindow.webContents.send("claude:message", sessionId, msg); + + // Store assistant text messages + if (msg.type === "assistant") { + const content = (msg.message.content as Array<{ type: string; text?: string }>) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text!) + .join("\n"); + if (content) { + sessions.addMessage(sessionId, "assistant", content); + } + } + }, + }); + } + ); + + ipcMain.handle("chat:interrupt", (_, sessionId: string) => { + claude.interruptSession(sessionId); + }); + + // Workflow + ipcMain.handle("workflow:review", async (_, sessionId: string) => { + const session = sessions.getSession(sessionId); + if (!session) throw new Error("Session not found"); + + await claude.triggerReview(session, (msg) => { + mainWindow.webContents.send("claude:message", sessionId, msg); + }); + }); + + ipcMain.handle("workflow:advance", (_, sessionId: string) => { + const session = sessions.getSession(sessionId); + if (!session) throw new Error("Session not found"); + return claude.advancePhase(session); + }); + + ipcMain.handle( + "workflow:setPermissionMode", + (_, sessionId: string, mode: string) => { + sessions.updateSession(sessionId, { + permission_mode: mode as UserPermissionMode, + }); + } + ); + + // Artifacts + ipcMain.handle( + "artifact:read", + (_, projectPath: string, filename: string) => { + return claude.readArtifact(projectPath, filename); + } + ); + + ipcMain.handle( + "artifact:write", + (_, projectPath: string, filename: string, content: string) => { + claude.writeArtifact(projectPath, filename, content); + } + ); + + // Dialogs + ipcMain.handle("dialog:selectDirectory", async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openDirectory"], + }); + return result.canceled ? null : result.filePaths[0]; + }); +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 0b39d91..b3e3f8b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1 +1,114 @@ -// Expose nothing for now; keep the door open for future IPC-safe APIs. +import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { Project } from "./db/projects"; +import type { Session, Message } from "./db/sessions"; +import type { Phase, UserPermissionMode } from "./claude/phases"; + +export interface ClaudeFlowAPI { + // Projects + listProjects: () => Promise; + createProject: (name: string, path: string) => Promise; + deleteProject: (id: string) => Promise; + + // Sessions + listSessions: (projectId: string) => Promise; + createSession: (projectId: string, name: string) => Promise; + deleteSession: (id: string) => Promise; + getSession: (id: string) => Promise; + + // Messages + listMessages: (sessionId: string) => Promise; + + // Chat + sendMessage: (sessionId: string, message: string) => Promise; + interruptSession: (sessionId: string) => Promise; + + // Workflow + triggerReview: (sessionId: string) => Promise; + advancePhase: (sessionId: string) => Promise; + setPermissionMode: ( + sessionId: string, + mode: UserPermissionMode + ) => Promise; + + // Artifacts + readArtifact: ( + projectPath: string, + filename: string + ) => Promise; + writeArtifact: ( + projectPath: string, + filename: string, + content: string + ) => Promise; + + // Events + onClaudeMessage: ( + callback: (sessionId: string, message: SDKMessage) => void + ) => () => void; + + // Dialogs + selectDirectory: () => Promise; +} + +const api: ClaudeFlowAPI = { + // Projects + listProjects: () => ipcRenderer.invoke("projects:list"), + createProject: (name, path) => + ipcRenderer.invoke("projects:create", name, path), + deleteProject: (id) => ipcRenderer.invoke("projects:delete", id), + + // Sessions + listSessions: (projectId) => ipcRenderer.invoke("sessions:list", projectId), + createSession: (projectId, name) => + ipcRenderer.invoke("sessions:create", projectId, name), + deleteSession: (id) => ipcRenderer.invoke("sessions:delete", id), + getSession: (id) => ipcRenderer.invoke("sessions:get", id), + + // Messages + listMessages: (sessionId) => ipcRenderer.invoke("messages:list", sessionId), + + // Chat + sendMessage: (sessionId, message) => + ipcRenderer.invoke("chat:send", sessionId, message), + interruptSession: (sessionId) => + ipcRenderer.invoke("chat:interrupt", sessionId), + + // Workflow + triggerReview: (sessionId) => ipcRenderer.invoke("workflow:review", sessionId), + advancePhase: (sessionId) => ipcRenderer.invoke("workflow:advance", sessionId), + setPermissionMode: (sessionId, mode) => + ipcRenderer.invoke("workflow:setPermissionMode", sessionId, mode), + + // Artifacts + readArtifact: (projectPath, filename) => + ipcRenderer.invoke("artifact:read", projectPath, filename), + writeArtifact: (projectPath, filename, content) => + ipcRenderer.invoke("artifact:write", projectPath, filename, content), + + // Events + onClaudeMessage: (callback) => { + const handler = ( + _: IpcRendererEvent, + sessionId: string, + message: SDKMessage + ) => callback(sessionId, message); + ipcRenderer.on("claude:message", handler); + return () => ipcRenderer.removeListener("claude:message", handler); + }, + + // Dialogs + selectDirectory: async () => { + const result = await ipcRenderer.invoke("dialog:selectDirectory"); + return result; + }, +}; + +contextBridge.exposeInMainWorld("api", api); + +// Type declaration for renderer +declare global { + interface Window { + api: ClaudeFlowAPI; + } +} -- cgit v1.2.3