diff --git a/apps/app/electrobun/src/__tests__/editor-bridge.test.ts b/apps/app/electrobun/src/__tests__/editor-bridge.test.ts new file mode 100644 index 000000000..d123841db --- /dev/null +++ b/apps/app/electrobun/src/__tests__/editor-bridge.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for native/editor-bridge.ts + * + * Covers editor detection, session lifecycle, and error cases. + * Uses spawnSync mock — no real processes are launched. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + default: { + existsSync: vi.fn(), + accessSync: vi.fn(), + constants: { X_OK: 1 }, + }, +})); + +// Mock Bun.spawn (used in openInEditor to launch the editor detached) +const mockBunSpawn = vi.fn(() => ({ + unref: vi.fn(), + exited: Promise.resolve(0), +})); +(Bun as unknown as { spawn: unknown }).spawn = mockBunSpawn; + +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + clearActiveEditorSession, + detectInstalledEditors, + getActiveEditorSession, + listInstalledEditors, + openInEditor, +} from "../native/editor-bridge"; + +const mockSpawnSync = vi.mocked(spawnSync); +const mockFs = vi.mocked(fs, true); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSpawnResult(exitCode: number) { + return { + status: exitCode, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + output: [] as string[], + pid: 1234, + signal: null, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("detectInstalledEditors", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockBunSpawn.mockReset(); + // Default: `which` returns failure, no candidates exist + mockSpawnSync.mockReturnValue(makeSpawnResult(1)); + mockFs.existsSync.mockReturnValue(false); + mockFs.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("returns all known editors even when none installed", () => { + const editors = detectInstalledEditors(); + expect(editors.length).toBeGreaterThan(0); + for (const editor of editors) { + expect(editor.installed).toBe(false); + } + }); + + it("marks vscode installed when `which code` succeeds", () => { + mockSpawnSync.mockImplementation((cmd, args) => { + if (cmd === "which" && Array.isArray(args) && args[0] === "code") { + return makeSpawnResult(0); + } + return makeSpawnResult(1); + }); + + const editors = detectInstalledEditors(); + const vscode = editors.find((e) => e.id === "vscode"); + expect(vscode?.installed).toBe(true); + expect(vscode?.command).toBe("code"); + }); + + it("marks cursor installed when `which cursor` succeeds", () => { + mockSpawnSync.mockImplementation((cmd, args) => { + if (cmd === "which" && Array.isArray(args) && args[0] === "cursor") { + return makeSpawnResult(0); + } + return makeSpawnResult(1); + }); + + const editors = detectInstalledEditors(); + const cursor = editors.find((e) => e.id === "cursor"); + expect(cursor?.installed).toBe(true); + }); +}); + +describe("listInstalledEditors", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockBunSpawn.mockReset(); + mockSpawnSync.mockReturnValue(makeSpawnResult(1)); + mockFs.existsSync.mockReturnValue(false); + mockFs.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("returns empty array when no editors installed", () => { + expect(listInstalledEditors()).toHaveLength(0); + }); + + it("returns only installed editors", () => { + mockSpawnSync.mockImplementation((cmd, args) => { + if (cmd === "which" && Array.isArray(args) && args[0] === "code") { + return makeSpawnResult(0); + } + return makeSpawnResult(1); + }); + + const installed = listInstalledEditors(); + expect(installed.every((e) => e.installed)).toBe(true); + expect(installed.some((e) => e.id === "vscode")).toBe(true); + }); +}); + +describe("openInEditor", () => { + const workspacePath = "/Users/user/Projects/my-project"; + + beforeEach(() => { + vi.resetAllMocks(); + mockBunSpawn.mockReset(); + mockBunSpawn.mockReturnValue({ unref: vi.fn(), exited: Promise.resolve(0) }); + // Make `which code` succeed + mockSpawnSync.mockReturnValue(makeSpawnResult(0)); + // Workspace path exists + mockFs.existsSync.mockReturnValue(true); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("throws when workspace path does not exist", () => { + mockFs.existsSync.mockReturnValue(false); + expect(() => openInEditor("vscode", workspacePath)).toThrow( + /does not exist/, + ); + }); + + it("throws when editor is not installed", () => { + // `which` always fails and no candidates exist + mockSpawnSync.mockReturnValue(makeSpawnResult(1)); + mockFs.existsSync.mockReturnValue(true); + mockFs.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(() => openInEditor("cursor", workspacePath)).toThrow(/not installed/); + }); + + it("throws for unknown editor id", () => { + expect(() => + openInEditor("unknown-editor" as never, workspacePath), + ).toThrow(/Unknown editor id/); + }); + + it("returns a session with correct metadata", () => { + const before = Date.now(); + const session = openInEditor("vscode", workspacePath); + const after = Date.now(); + + expect(session.editorId).toBe("vscode"); + expect(session.workspacePath).toBe(workspacePath); + expect(session.startedAt).toBeGreaterThanOrEqual(before); + expect(session.startedAt).toBeLessThanOrEqual(after); + }); + + it("stores the session so getActiveEditorSession returns it", () => { + const session = openInEditor("vscode", workspacePath); + expect(getActiveEditorSession()).toEqual(session); + }); + + it("calls Bun.spawn with the editor command and workspace path", () => { + openInEditor("vscode", workspacePath); + expect(mockBunSpawn).toHaveBeenCalledWith( + expect.arrayContaining(["code", workspacePath]), + expect.objectContaining({ stdio: expect.anything() }), + ); + }); +}); + +describe("getActiveEditorSession / clearActiveEditorSession", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockBunSpawn.mockReturnValue({ unref: vi.fn(), exited: Promise.resolve(0) }); + mockSpawnSync.mockReturnValue(makeSpawnResult(0)); + mockFs.existsSync.mockReturnValue(true); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("returns null when no session active", () => { + expect(getActiveEditorSession()).toBeNull(); + }); + + it("clearActiveEditorSession removes the session", () => { + openInEditor("vscode", "/tmp/project"); + expect(getActiveEditorSession()).not.toBeNull(); + clearActiveEditorSession(); + expect(getActiveEditorSession()).toBeNull(); + }); +}); + + +const mockSpawnSync = vi.mocked(spawnSync); +const mockFs = vi.mocked(fs, true); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSpawnResult(exitCode: number) { + return { + status: exitCode, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + output: [] as string[], + pid: 1234, + signal: null, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("detectInstalledEditors", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Default: `which` returns failure, no candidates exist + mockSpawnSync.mockReturnValue(makeSpawnResult(1)); + mockFs.existsSync.mockReturnValue(false); + mockFs.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("returns all known editors even when none installed", () => { + const editors = detectInstalledEditors(); + expect(editors.length).toBeGreaterThan(0); + for (const editor of editors) { + expect(editor.installed).toBe(false); + } + }); + + it("marks vscode installed when `which code` succeeds", () => { + mockSpawnSync.mockImplementation((cmd, args) => { + if (cmd === "which" && Array.isArray(args) && args[0] === "code") { + return makeSpawnResult(0); + } + return makeSpawnResult(1); + }); + + const editors = detectInstalledEditors(); + const vscode = editors.find((e) => e.id === "vscode"); + expect(vscode?.installed).toBe(true); + expect(vscode?.command).toBe("code"); + }); + + it("marks cursor installed when `which cursor` succeeds", () => { + mockSpawnSync.mockImplementation((cmd, args) => { + if (cmd === "which" && Array.isArray(args) && args[0] === "cursor") { + return makeSpawnResult(0); + } + return makeSpawnResult(1); + }); + + const editors = detectInstalledEditors(); + const cursor = editors.find((e) => e.id === "cursor"); + expect(cursor?.installed).toBe(true); + }); +}); + +describe("listInstalledEditors", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockSpawnSync.mockReturnValue(makeSpawnResult(1)); + mockFs.existsSync.mockReturnValue(false); + mockFs.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("returns empty array when no editors installed", () => { + expect(listInstalledEditors()).toHaveLength(0); + }); + + it("returns only installed editors", () => { + mockSpawnSync.mockImplementation((cmd, args) => { + if (cmd === "which" && Array.isArray(args) && args[0] === "code") { + return makeSpawnResult(0); + } + return makeSpawnResult(1); + }); + + const installed = listInstalledEditors(); + expect(installed.every((e) => e.installed)).toBe(true); + expect(installed.some((e) => e.id === "vscode")).toBe(true); + }); +}); + +describe("openInEditor", () => { + const workspacePath = "/Users/user/Projects/my-project"; + + beforeEach(() => { + vi.resetAllMocks(); + // Make `which code` succeed + mockSpawnSync.mockReturnValue(makeSpawnResult(0)); + // Workspace path exists + mockFs.existsSync.mockReturnValue(true); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("throws when workspace path does not exist", () => { + mockFs.existsSync.mockReturnValue(false); + expect(() => openInEditor("vscode", workspacePath)).toThrow( + /does not exist/, + ); + }); + + it("throws when editor is not installed", () => { + // `which` always fails + mockSpawnSync.mockReturnValue(makeSpawnResult(1)); + mockFs.existsSync.mockReturnValue(true); + mockFs.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(() => openInEditor("cursor", workspacePath)).toThrow(/not installed/); + }); + + it("throws for unknown editor id", () => { + expect(() => + openInEditor("unknown-editor" as never, workspacePath), + ).toThrow(/Unknown editor id/); + }); + + it("returns a session with correct metadata", () => { + const before = Date.now(); + const session = openInEditor("vscode", workspacePath); + const after = Date.now(); + + expect(session.editorId).toBe("vscode"); + expect(session.workspacePath).toBe(workspacePath); + expect(session.startedAt).toBeGreaterThanOrEqual(before); + expect(session.startedAt).toBeLessThanOrEqual(after); + }); + + it("stores the session so getActiveEditorSession returns it", () => { + const session = openInEditor("vscode", workspacePath); + expect(getActiveEditorSession()).toEqual(session); + }); +}); + +describe("getActiveEditorSession / clearActiveEditorSession", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockSpawnSync.mockReturnValue(makeSpawnResult(0)); + mockFs.existsSync.mockReturnValue(true); + }); + + afterEach(() => { + clearActiveEditorSession(); + }); + + it("returns null when no session active", () => { + expect(getActiveEditorSession()).toBeNull(); + }); + + it("clearActiveEditorSession removes the session", () => { + openInEditor("vscode", "/tmp/project"); + expect(getActiveEditorSession()).not.toBeNull(); + clearActiveEditorSession(); + expect(getActiveEditorSession()).toBeNull(); + }); +}); diff --git a/apps/app/electrobun/src/__tests__/file-watcher.test.ts b/apps/app/electrobun/src/__tests__/file-watcher.test.ts new file mode 100644 index 000000000..21cb75e71 --- /dev/null +++ b/apps/app/electrobun/src/__tests__/file-watcher.test.ts @@ -0,0 +1,226 @@ +/** + * Tests for native/file-watcher.ts + * + * Covers watch lifecycle, event filtering, and status reporting. + * Uses Node fs.watch stub — no real FS events are generated. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockWatcher = { + close: vi.fn(), +}; + +vi.mock("node:fs", () => ({ + default: { + existsSync: vi.fn().mockReturnValue(true), + watch: vi.fn().mockReturnValue(mockWatcher), + }, +})); + +import fs from "node:fs"; +import { getFileWatcher } from "../native/file-watcher"; +import type { FileChangeEvent } from "../native/file-watcher"; + +const mockFs = vi.mocked(fs, true); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Re-create a fresh watcher instance for each test. */ +function freshWatcher() { + // The module-level singleton persists across tests; stop all watches first. + const w = getFileWatcher(); + w.stopAll(); + return w; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("getFileWatcher (singleton)", () => { + it("returns the same instance on every call", () => { + const a = getFileWatcher(); + const b = getFileWatcher(); + expect(a).toBe(b); + }); +}); + +describe("fileWatcher.startWatch", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockFs.existsSync.mockReturnValue(true); + (mockFs.watch as ReturnType).mockReturnValue(mockWatcher); + }); + + afterEach(() => { + freshWatcher().stopAll(); + }); + + it("throws when watch path does not exist", () => { + mockFs.existsSync.mockReturnValue(false); + const watcher = freshWatcher(); + expect(() => watcher.startWatch("/nonexistent", () => {})).toThrow( + /does not exist/, + ); + }); + + it("returns a string watchId", () => { + const watcher = freshWatcher(); + const id = watcher.startWatch("/tmp/project", () => {}); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + }); + + it("each call returns a unique watchId", () => { + const watcher = freshWatcher(); + const id1 = watcher.startWatch("/tmp/project", () => {}); + const id2 = watcher.startWatch("/tmp/other", () => {}); + expect(id1).not.toBe(id2); + }); + + it("calls fs.watch with recursive option", () => { + const watcher = freshWatcher(); + watcher.startWatch("/tmp/project", () => {}); + expect(mockFs.watch).toHaveBeenCalledWith( + "/tmp/project", + { recursive: true, persistent: false }, + expect.any(Function), + ); + }); +}); + +describe("fileWatcher.stopWatch", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockFs.existsSync.mockReturnValue(true); + (mockFs.watch as ReturnType).mockReturnValue(mockWatcher); + mockWatcher.close.mockReset(); + }); + + afterEach(() => { + freshWatcher().stopAll(); + }); + + it("returns false for an unknown watchId", () => { + const watcher = freshWatcher(); + expect(watcher.stopWatch("nonexistent")).toBe(false); + }); + + it("closes the watcher and returns true for a known watchId", () => { + const watcher = freshWatcher(); + const id = watcher.startWatch("/tmp/project", () => {}); + expect(watcher.stopWatch(id)).toBe(true); + expect(mockWatcher.close).toHaveBeenCalledTimes(1); + }); + + it("removes the watch from listWatches after stopping", () => { + const watcher = freshWatcher(); + const id = watcher.startWatch("/tmp/project", () => {}); + expect(watcher.listWatches()).toHaveLength(1); + watcher.stopWatch(id); + expect(watcher.listWatches()).toHaveLength(0); + }); +}); + +describe("fileWatcher.listWatches / getWatch", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockFs.existsSync.mockReturnValue(true); + (mockFs.watch as ReturnType).mockReturnValue(mockWatcher); + mockWatcher.close.mockReset(); + }); + + afterEach(() => { + freshWatcher().stopAll(); + }); + + it("returns an empty array when no watches are active", () => { + expect(freshWatcher().listWatches()).toHaveLength(0); + }); + + it("lists all active watches", () => { + const watcher = freshWatcher(); + watcher.startWatch("/tmp/a", () => {}); + watcher.startWatch("/tmp/b", () => {}); + const list = watcher.listWatches(); + expect(list).toHaveLength(2); + expect(list.map((w) => w.watchPath).sort()).toEqual( + ["/tmp/a", "/tmp/b"].sort(), + ); + }); + + it("getWatch returns null for unknown id", () => { + expect(freshWatcher().getWatch("unknown")).toBeNull(); + }); + + it("getWatch returns status for a known watchId", () => { + const watcher = freshWatcher(); + const id = watcher.startWatch("/tmp/project", () => {}); + const status = watcher.getWatch(id); + expect(status).not.toBeNull(); + expect(status?.watchId).toBe(id); + expect(status?.watchPath).toBe("/tmp/project"); + expect(status?.active).toBe(true); + expect(status?.eventCount).toBe(0); + }); +}); + +describe("fileWatcher change event emission", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + mockFs.existsSync.mockReturnValue(true); + (mockFs.watch as ReturnType).mockImplementation( + (_path, _opts, callback) => { + // Store the callback so tests can invoke it manually. + mockWatchCallback = callback as ( + event: string, + filename: string, + ) => void; + return mockWatcher; + }, + ); + mockWatcher.close.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + freshWatcher().stopAll(); + }); + + let mockWatchCallback: ((event: string, filename: string) => void) | null = + null; + + it("calls send callback with a FileChangeEvent after debounce", async () => { + mockFs.existsSync.mockImplementation((p) => { + // Workspace path exists, and the changed file also exists (= modified) + return true; + }); + + const events: FileChangeEvent[] = []; + const watcher = freshWatcher(); + watcher.startWatch("/tmp/project", (event) => events.push(event)); + + // Simulate a file change event from the OS watcher + mockWatchCallback?.("change", "src/index.ts"); + + // Before debounce fires, nothing should be emitted + expect(events).toHaveLength(0); + + // Advance past the 50ms debounce + await vi.runAllTimersAsync(); + + expect(events).toHaveLength(1); + const ev = events[0]; + expect(ev?.type).toBe("modified"); + expect(ev?.filePath).toContain("index.ts"); + expect(typeof ev?.timestamp).toBe("number"); + }); +}); diff --git a/apps/app/electrobun/src/floating-chat-window.ts b/apps/app/electrobun/src/floating-chat-window.ts new file mode 100644 index 000000000..1cb292403 --- /dev/null +++ b/apps/app/electrobun/src/floating-chat-window.ts @@ -0,0 +1,217 @@ +/** + * Floating Chat Window Manager for Electrobun + * + * Manages an always-on-top secondary BrowserWindow that provides a lightweight + * chat interface while the user works in a native editor (VS Code, Cursor, etc.). + * + * Key characteristics: + * - A single floating chat window is allowed at a time (singleton). + * - The window is always-on-top so it stays visible over the native editor. + * - It reconnects to the previous chat context if closed and reopened. + * - Positioning defaults to the bottom-right corner of the primary screen. + * - The window can be freely dragged and resized by the user. + * - Closing the window marks it as hidden; it can be restored via tray or hotkey. + * + * WHY: When the user opens a workspace in a native editor, Milady minimises its + * main window. Without the floating chat the user loses the ability to talk to + * the agent. This window bridges that gap without requiring the user to switch + * back to the main Milady window. + */ + +import { BrowserWindow } from "electrobun/bun"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface FloatingChatStatus { + open: boolean; + visible: boolean; + contextId: string | null; + bounds: { x: number; y: number; width: number; height: number } | null; +} + +// --------------------------------------------------------------------------- +// Default geometry +// --------------------------------------------------------------------------- + +const FLOAT_WIDTH = 380; +const FLOAT_HEIGHT = 600; +const FLOAT_MARGIN = 24; + +function resolveDefaultPosition(): { x: number; y: number } { + // Fall back to a reasonable bottom-right estimate if we cannot query the + // screen. The user can always drag the window to a preferred location. + try { + // Approximate 1080p; actual display query needs electrobun/bun Screen API. + const screenWidth = 1920; + const screenHeight = 1080; + return { + x: screenWidth - FLOAT_WIDTH - FLOAT_MARGIN, + y: screenHeight - FLOAT_HEIGHT - FLOAT_MARGIN - 40, // 40 = taskbar + }; + } catch { + return { x: 1500, y: 400 }; + } +} + +// --------------------------------------------------------------------------- +// FloatingChatWindowManager +// --------------------------------------------------------------------------- + +type FloatingBrowserWindow = InstanceType; + +class FloatingChatWindowManager { + private window: FloatingBrowserWindow | null = null; + private contextId: string | null = null; + private isVisible = false; + private lastBounds: { x: number; y: number; width: number; height: number } | null = null; + private rendererUrl = ""; + private preloadPath = ""; + + /** + * Provide the renderer URL and preload script path once at startup. + * Must be called from index.ts after the main window renderer URL is resolved. + */ + configure(rendererUrl: string, preload: string): void { + this.rendererUrl = rendererUrl; + this.preloadPath = preload; + } + + /** + * Opens (or shows) the floating chat window. + * If a window already exists it is focused; otherwise a new one is created. + */ + open(options?: { contextId?: string; x?: number; y?: number }): FloatingChatStatus { + if (options?.contextId) { + this.contextId = options.contextId; + } + + if (this.window) { + this.show(); + return this.getStatus(); + } + + if (!this.rendererUrl) { + throw new Error("FloatingChatWindowManager is not configured — call configure() first"); + } + + const pos = this.lastBounds + ? { x: this.lastBounds.x, y: this.lastBounds.y } + : resolveDefaultPosition(); + const x = options?.x ?? pos.x; + const y = options?.y ?? pos.y; + + // Build the renderer URL with shell=floating-chat so the React app + // can render the compact floating-chat UI variant. + const contextParam = this.contextId + ? `&context=${encodeURIComponent(this.contextId)}` + : ""; + const url = `${this.rendererUrl}?shell=floating-chat${contextParam}`; + + // Electrobun BrowserWindow constructor: uses `frame` object for bounds. + const win = new BrowserWindow({ + title: "Milady Chat", + url, + preload: this.preloadPath || null, + frame: { x, y, width: FLOAT_WIDTH, height: FLOAT_HEIGHT }, + titleBarStyle: "hiddenInset", + transparent: false, + }); + + // Set always-on-top after creation (Electrobun API, not a constructor option). + try { + win.setAlwaysOnTop(true); + } catch { + // Gracefully degrade if setAlwaysOnTop is not available in the build. + } + + this.window = win; + this.isVisible = true; + + win.on("close", () => { + // Persist bounds for next open so the window reopens in the same spot. + try { + const { x: wx, y: wy } = win.getPosition(); + const { width: ww, height: wh } = win.getSize(); + this.lastBounds = { x: wx, y: wy, width: ww, height: wh }; + } catch { /* ignore */ } + this.window = null; + this.isVisible = false; + }); + + return this.getStatus(); + } + + /** Shows the floating chat window if it exists. */ + show(): void { + if (!this.window) return; + try { + this.window.show(); + this.window.focus(); + this.isVisible = true; + } catch { /* ignore */ } + } + + /** Hides the floating chat window without closing it. */ + hide(): void { + if (!this.window) return; + try { + // Electrobun uses minimize() as a hide fallback when hide() is absent. + if (typeof (this.window as unknown as { hide?: () => void }).hide === "function") { + (this.window as unknown as { hide: () => void }).hide(); + } else { + this.window.minimize(); + } + this.isVisible = false; + } catch { /* ignore */ } + } + + /** Closes and destroys the floating chat window. */ + close(): void { + if (!this.window) return; + try { + const { x, y } = this.window.getPosition(); + const { width, height } = this.window.getSize(); + this.lastBounds = { x, y, width, height }; + this.window.close(); + } catch { /* ignore */ } + this.window = null; + this.isVisible = false; + } + + /** + * Updates the active chat context. + * The new context will be picked up the next time the window is opened. + */ + setContextId(contextId: string | null): void { + this.contextId = contextId; + } + + getStatus(): FloatingChatStatus { + return { + open: this.window !== null, + visible: this.isVisible, + contextId: this.contextId, + bounds: this.lastBounds, + }; + } + + isOpen(): boolean { + return this.window !== null; + } +} + +// --------------------------------------------------------------------------- +// Module-level singleton +// --------------------------------------------------------------------------- + +let _manager: FloatingChatWindowManager | null = null; + +export function getFloatingChatManager(): FloatingChatWindowManager { + if (!_manager) { + _manager = new FloatingChatWindowManager(); + } + return _manager; +} + diff --git a/apps/app/electrobun/src/index.ts b/apps/app/electrobun/src/index.ts index da50f5c95..2ecbb693b 100644 --- a/apps/app/electrobun/src/index.ts +++ b/apps/app/electrobun/src/index.ts @@ -32,6 +32,7 @@ import { showBackgroundNoticeOnce } from "./background-notice"; import { startBrowserWorkspaceBridgeServer } from "./browser-workspace-bridge-server"; import { readNavigationEventUrl } from "./cloud-auth-window"; import { scheduleDevtoolsLayoutRefresh } from "./devtools-layout"; +import { getFloatingChatManager } from "./floating-chat-window"; import { resolveBootstrapShellRenderer, resolveBootstrapViewRenderer, @@ -1770,6 +1771,16 @@ async function main(): Promise { pid: process.pid, }); + // Configure the floating chat manager now that the renderer URL is resolved. + // This must run after createMainWindow() so rendererUrlPromise is already set. + void resolveRendererUrl().then((url) => { + let preload = ""; + try { + preload = readResolvedPreloadScript(import.meta.dir); + } catch { /* non-fatal */ } + getFloatingChatManager().configure(url, preload); + }); + surfaceWindowManager = new SurfaceWindowManager({ createWindow: (options) => new BrowserWindow(options) as unknown as ManagedWindowLike, @@ -1870,6 +1881,11 @@ async function main(): Promise { { id: "sep2", type: "separator" }, { id: "tray-show-window", label: "Show Window", type: "normal" }, { id: "tray-hide-window", label: "Hide Window", type: "normal" }, + { + id: "tray-floating-chat", + label: "Floating Chat", + type: "normal", + }, { id: "sep3", type: "separator" }, { id: "quit", label: "Quit", type: "normal" }, ], diff --git a/apps/app/electrobun/src/native/editor-bridge.ts b/apps/app/electrobun/src/native/editor-bridge.ts new file mode 100644 index 000000000..50af35c37 --- /dev/null +++ b/apps/app/electrobun/src/native/editor-bridge.ts @@ -0,0 +1,282 @@ +/** + * Native Editor Bridge for Electrobun + * + * Detects installed native code editors (VS Code, Cursor, Windsurf, Antigravity, + * etc.) and launches workspace folders in them. Tracks the active editor session + * so the floating chat widget and file watcher know which session is live. + * + * Design notes: + * - All detection is cross-platform (macOS, Linux, Windows). + * - The bridge does NOT control the native editor process; it only launches it. + * - Session state is kept in memory. On app restart the session is lost (by design – + * the floating chat reconnects to the running agent, not to a saved session token). + */ + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Editor catalogue +// --------------------------------------------------------------------------- + +export type NativeEditorId = + | "vscode" + | "cursor" + | "windsurf" + | "antigravity" + | "zed" + | "sublime"; + +export interface NativeEditorInfo { + id: NativeEditorId; + label: string; + installed: boolean; + /** CLI command that opens a path, e.g. "code" */ + command: string; +} + +export interface EditorSession { + editorId: NativeEditorId; + workspacePath: string; + startedAt: number; +} + +interface EditorSpec { + id: NativeEditorId; + label: string; + /** Primary CLI command to try first */ + command: string; + /** Extra candidate paths per platform to search for the binary */ + candidates?: Partial>; +} + +const EDITOR_SPECS: EditorSpec[] = [ + { + id: "vscode", + label: "VS Code", + command: "code", + candidates: { + darwin: [ + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + `${os.homedir()}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code`, + ], + linux: ["/usr/bin/code", "/usr/local/bin/code"], + win32: [ + path.join( + os.homedir(), + "AppData", + "Local", + "Programs", + "Microsoft VS Code", + "bin", + "code.cmd", + ), + ], + }, + }, + { + id: "cursor", + label: "Cursor", + command: "cursor", + candidates: { + darwin: [ + "/Applications/Cursor.app/Contents/MacOS/Cursor", + `${os.homedir()}/Applications/Cursor.app/Contents/MacOS/Cursor`, + ], + linux: ["/usr/bin/cursor", "/usr/local/bin/cursor"], + win32: [ + path.join( + os.homedir(), + "AppData", + "Local", + "Programs", + "cursor", + "Cursor.exe", + ), + ], + }, + }, + { + id: "windsurf", + label: "Windsurf", + command: "windsurf", + candidates: { + darwin: [ + "/Applications/Windsurf.app/Contents/MacOS/Windsurf", + `${os.homedir()}/Applications/Windsurf.app/Contents/MacOS/Windsurf`, + ], + linux: ["/usr/bin/windsurf", "/usr/local/bin/windsurf"], + win32: [ + path.join( + os.homedir(), + "AppData", + "Local", + "Programs", + "windsurf", + "Windsurf.exe", + ), + ], + }, + }, + { + id: "antigravity", + label: "Antigravity", + command: "ag", + candidates: { + darwin: [ + "/Applications/Antigravity.app/Contents/MacOS/Antigravity", + `${os.homedir()}/Applications/Antigravity.app/Contents/MacOS/Antigravity`, + ], + }, + }, + { + id: "zed", + label: "Zed", + command: "zed", + candidates: { + darwin: [ + "/Applications/Zed.app/Contents/MacOS/cli", + `${os.homedir()}/Applications/Zed.app/Contents/MacOS/cli`, + ], + linux: ["/usr/bin/zed", "/usr/local/bin/zed"], + }, + }, + { + id: "sublime", + label: "Sublime Text", + command: "subl", + candidates: { + darwin: ["/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl"], + linux: ["/usr/bin/subl", "/usr/local/bin/subl"], + win32: [ + path.join( + "C:", + "Program Files", + "Sublime Text", + "subl.exe", + ), + ], + }, + }, +]; + +// --------------------------------------------------------------------------- +// Detection helpers +// --------------------------------------------------------------------------- + +/** Returns true when a path exists and is executable. */ +function isExecutable(p: string): boolean { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Returns true when a CLI command is available via PATH. */ +function isCommandOnPath(cmd: string): boolean { + const which = process.platform === "win32" ? "where" : "which"; + try { + const result = spawnSync(which, [cmd], { + stdio: "pipe", + encoding: "utf8", + }); + return result.status === 0; + } catch { + return false; + } +} + +function detectEditor(spec: EditorSpec): NativeEditorInfo { + if (isCommandOnPath(spec.command)) { + return { id: spec.id, label: spec.label, installed: true, command: spec.command }; + } + const platform = process.platform as NodeJS.Platform; + const candidates = spec.candidates?.[platform] ?? []; + const resolved = candidates.find((p) => isExecutable(p)); + if (resolved) { + return { id: spec.id, label: spec.label, installed: true, command: resolved }; + } + return { id: spec.id, label: spec.label, installed: false, command: spec.command }; +} + +// --------------------------------------------------------------------------- +// Editor Bridge singleton +// --------------------------------------------------------------------------- + +let _activeSession: EditorSession | null = null; + +/** Returns the currently installed editors. */ +export function detectInstalledEditors(): NativeEditorInfo[] { + return EDITOR_SPECS.map(detectEditor); +} + +/** Returns only the editors that are detected as installed. */ +export function listInstalledEditors(): NativeEditorInfo[] { + return detectInstalledEditors().filter((e) => e.installed); +} + +/** + * Opens `workspacePath` in the selected editor. + * + * Throws if the editor is not installed or the workspace does not exist. + */ +export function openInEditor( + editorId: NativeEditorId, + workspacePath: string, +): EditorSession { + if (!fs.existsSync(workspacePath)) { + throw new Error(`Workspace path does not exist: ${workspacePath}`); + } + + const info = detectEditor( + EDITOR_SPECS.find((s) => s.id === editorId) ?? + (() => { + throw new Error(`Unknown editor id: ${editorId}`); + })(), + ); + + if (!info.installed) { + throw new Error(`Editor "${info.label}" is not installed`); + } + + // Detach the editor process — we do not own its lifecycle. + const child = Bun.spawn([info.command, workspacePath], { + stdio: ["ignore", "ignore", "ignore"], + env: process.env as Record, + }); + + // We do not await; the editor runs independently. + child.unref?.(); + + const session: EditorSession = { + editorId, + workspacePath, + startedAt: Date.now(), + }; + _activeSession = session; + return session; +} + +/** Returns the current active editor session (or null if none). */ +export function getActiveEditorSession(): EditorSession | null { + return _activeSession; +} + +/** Clears the active editor session (does NOT close the editor). */ +export function clearActiveEditorSession(): void { + _activeSession = null; +} + +// Singleton accessor — matches the pattern used by other native modules. +export function getEditorBridge() { + return { + listInstalledEditors, + openInEditor, + getActiveEditorSession, + clearActiveEditorSession, + }; +} diff --git a/apps/app/electrobun/src/native/file-watcher.ts b/apps/app/electrobun/src/native/file-watcher.ts new file mode 100644 index 000000000..3bc5825aa --- /dev/null +++ b/apps/app/electrobun/src/native/file-watcher.ts @@ -0,0 +1,216 @@ +/** + * Workspace File Watcher for Electrobun + * + * Watches a workspace directory for file changes and emits events to the + * webview via a `sendToWebview` callback. Used by the IDE app and the + * floating chat widget to keep the agent, native editor, and UI in sync. + * + * Uses Node's built-in `fs.watch` (recursive mode on macOS and Windows, + * simulated with per-directory watches on Linux). No external deps required. + * + * Design notes: + * - Multiple watches can be active simultaneously (keyed by watchId). + * - Rapid file changes are debounced (50 ms) to avoid event floods. + * - Only regular files are reported (not directories or system files). + */ + +import fs from "node:fs"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type FileChangeEventType = "created" | "modified" | "deleted" | "renamed"; + +export interface FileChangeEvent { + watchId: string; + type: FileChangeEventType; + filePath: string; + relativePath: string; + timestamp: number; +} + +export interface WatchStatus { + watchId: string; + watchPath: string; + active: boolean; + startedAt: number; + eventCount: number; +} + +// --------------------------------------------------------------------------- +// Debounce helper +// --------------------------------------------------------------------------- + +function debounce( + fn: (...args: T) => void, + delayMs: number, +): (...args: T) => void { + const timers = new Map>(); + return (...args: T) => { + // Use first arg as debounce key when it's a string (file path) + const key = typeof args[0] === "string" ? args[0] : "default"; + const existing = timers.get(key); + if (existing !== undefined) clearTimeout(existing); + timers.set( + key, + setTimeout(() => { + timers.delete(key); + fn(...args); + }, delayMs), + ); + }; +} + +// --------------------------------------------------------------------------- +// IGNORED patterns +// --------------------------------------------------------------------------- + +const IGNORED_DIRS = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "dist", + "build", + "out", + ".next", + ".nuxt", + ".cache", + ".vite", + "__pycache__", +]); + +function shouldIgnorePath(fullPath: string): boolean { + const parts = fullPath.split(path.sep); + return parts.some((part) => IGNORED_DIRS.has(part) || part.startsWith(".")); +} + +// --------------------------------------------------------------------------- +// WorkspaceFileWatcher singleton +// --------------------------------------------------------------------------- + +interface WatchEntry { + watchPath: string; + watcher: fs.FSWatcher; + startedAt: number; + eventCount: number; +} + +type SendFileChange = (event: FileChangeEvent) => void; + +class WorkspaceFileWatcher { + private readonly watches = new Map(); + private counter = 0; + + /** + * Start watching `watchPath`. + * @returns The new watchId. + */ + startWatch(watchPath: string, send: SendFileChange): string { + if (!fs.existsSync(watchPath)) { + throw new Error(`Watch path does not exist: ${watchPath}`); + } + + const watchId = `watch_${++this.counter}`; + + const emitChange = debounce( + (filePath: string, eventType: FileChangeEventType) => { + const entry = this.watches.get(watchId); + if (entry) entry.eventCount++; + send({ + watchId, + type: eventType, + filePath, + relativePath: path.relative(watchPath, filePath), + timestamp: Date.now(), + }); + }, + 50, + ); + + const watcher = fs.watch( + watchPath, + { recursive: true, persistent: false }, + (eventName, filename) => { + if (!filename) return; + const fullPath = path.resolve(watchPath, filename); + if (shouldIgnorePath(fullPath)) return; + + let type: FileChangeEventType; + try { + const exists = fs.existsSync(fullPath); + if (!exists) { + type = "deleted"; + } else { + // fs.watch reports both "rename" (create/delete) and "change" (modify). + type = eventName === "rename" ? "created" : "modified"; + } + } catch { + type = "modified"; + } + + emitChange(fullPath, type); + }, + ); + + this.watches.set(watchId, { + watchPath, + watcher, + startedAt: Date.now(), + eventCount: 0, + }); + + return watchId; + } + + /** Stop a specific watch. */ + stopWatch(watchId: string): boolean { + const entry = this.watches.get(watchId); + if (!entry) return false; + entry.watcher.close(); + this.watches.delete(watchId); + return true; + } + + /** Stop all active watches. */ + stopAll(): void { + for (const [id] of this.watches) { + this.stopWatch(id); + } + } + + /** Returns status for all active watches. */ + listWatches(): WatchStatus[] { + return Array.from(this.watches.entries()).map(([id, entry]) => ({ + watchId: id, + watchPath: entry.watchPath, + active: true, + startedAt: entry.startedAt, + eventCount: entry.eventCount, + })); + } + + getWatch(watchId: string): WatchStatus | null { + const entry = this.watches.get(watchId); + if (!entry) return null; + return { + watchId, + watchPath: entry.watchPath, + active: true, + startedAt: entry.startedAt, + eventCount: entry.eventCount, + }; + } +} + +// Module-level singleton +let _watcher: WorkspaceFileWatcher | null = null; + +export function getFileWatcher(): WorkspaceFileWatcher { + if (!_watcher) { + _watcher = new WorkspaceFileWatcher(); + } + return _watcher; +} diff --git a/apps/app/electrobun/src/rpc-handlers.ts b/apps/app/electrobun/src/rpc-handlers.ts index cf4439148..72b7da772 100644 --- a/apps/app/electrobun/src/rpc-handlers.ts +++ b/apps/app/electrobun/src/rpc-handlers.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; /** * RPC Handler Registration for Electrobun * @@ -14,6 +13,7 @@ import { setAgentReady } from "./agent-ready-state"; import { resolveDesktopRuntimeMode } from "./api-base"; import { showBackgroundNoticeOnce } from "./background-notice"; import { postCloudDisconnectFromMain } from "./cloud-disconnect-from-main"; +import { getFloatingChatManager } from "./floating-chat-window"; import { getAgentManager } from "./native/agent"; import { getCameraManager } from "./native/camera"; import { getCanvasManager } from "./native/canvas"; @@ -22,6 +22,9 @@ import { scanProviderCredentials, } from "./native/credentials"; import { getDesktopManager } from "./native/desktop"; +import { getEditorBridge } from "./native/editor-bridge"; +import type { NativeEditorId } from "./native/editor-bridge"; +import { getFileWatcher } from "./native/file-watcher"; import { getGatewayDiscovery } from "./native/gateway"; import { getGpuWindowManager } from "./native/gpu-window"; import { getLocationManager } from "./native/location"; @@ -104,6 +107,9 @@ export function registerRpcHandlers( const camera = getCameraManager(); const canvas = getCanvasManager(); const desktop = getDesktopManager(); + const editorBridge = getEditorBridge(); + const fileWatcher = getFileWatcher(); + const floatingChat = getFloatingChatManager(); const gateway = getGatewayDiscovery(); const gpuWindow = getGpuWindowManager(); const location = getLocationManager(); @@ -763,6 +769,68 @@ export function registerRpcHandlers( } return resetSteward(); }, + + // ---- Native Editor Bridge ---- + editorBridgeListEditors: async () => ({ + editors: editorBridge.listInstalledEditors(), + }), + editorBridgeOpenInEditor: async (params: { + editorId: NativeEditorId; + workspacePath: string; + }) => { + const session = editorBridge.openInEditor( + params.editorId, + params.workspacePath, + ); + sendToWebview("editorBridge:sessionChanged", session); + return session; + }, + editorBridgeGetSession: async () => editorBridge.getActiveEditorSession(), + editorBridgeClearSession: async () => { + editorBridge.clearActiveEditorSession(); + sendToWebview("editorBridge:sessionChanged", null); + }, + + // ---- Workspace File Watcher ---- + fileWatcherStart: async (params: { watchPath: string }) => { + const watchId = fileWatcher.startWatch(params.watchPath, (event) => { + sendToWebview("fileWatcher:fileChanged", event); + }); + return { watchId }; + }, + fileWatcherStop: async (params: { watchId: string }) => ({ + stopped: fileWatcher.stopWatch(params.watchId), + }), + fileWatcherStopAll: async () => { + fileWatcher.stopAll(); + }, + fileWatcherList: async () => ({ watches: fileWatcher.listWatches() }), + fileWatcherGetStatus: async (params: { watchId: string }) => + fileWatcher.getWatch(params.watchId), + + // ---- Floating Chat Window ---- + floatingChatOpen: async ( + params: { contextId?: string; x?: number; y?: number } | undefined, + ) => { + return floatingChat.open(params ?? {}); + }, + floatingChatShow: async () => { + floatingChat.show(); + return floatingChat.getStatus(); + }, + floatingChatHide: async () => { + floatingChat.hide(); + return floatingChat.getStatus(); + }, + floatingChatClose: async () => { + floatingChat.close(); + return floatingChat.getStatus(); + }, + floatingChatSetContext: async (params: { contextId: string | null }) => { + floatingChat.setContextId(params.contextId); + return floatingChat.getStatus(); + }, + floatingChatGetStatus: async () => floatingChat.getStatus(), }); console.log("[RPC] All handlers registered"); diff --git a/apps/app/electrobun/src/rpc-schema.ts b/apps/app/electrobun/src/rpc-schema.ts index 60227d173..388a6823c 100644 --- a/apps/app/electrobun/src/rpc-schema.ts +++ b/apps/app/electrobun/src/rpc-schema.ts @@ -285,6 +285,59 @@ export interface ScreenSource { appIcon?: string; } +// -- Native Editor Bridge -- +export type NativeEditorId = + | "vscode" + | "cursor" + | "windsurf" + | "antigravity" + | "zed" + | "sublime"; + +export interface NativeEditorInfo { + id: NativeEditorId; + label: string; + installed: boolean; + command: string; +} + +export interface EditorSession { + editorId: NativeEditorId; + workspacePath: string; + startedAt: number; +} + +// -- Workspace File Watcher -- +export type FileChangeEventType = + | "created" + | "modified" + | "deleted" + | "renamed"; + +export interface FileChangeEvent { + watchId: string; + type: FileChangeEventType; + filePath: string; + relativePath: string; + timestamp: number; +} + +export interface WatchStatus { + watchId: string; + watchPath: string; + active: boolean; + startedAt: number; + eventCount: number; +} + +// -- Floating Chat Window -- +export interface FloatingChatStatus { + open: boolean; + visible: boolean; + contextId: string | null; + bounds: WindowBounds | null; +} + // -- TalkMode -- export type TalkModeState = | "idle" @@ -1065,6 +1118,72 @@ export type MiladyRPCSchema = { response: { views: GpuViewInfo[] }; }; + // ---- Native Editor Bridge ---- + editorBridgeListEditors: { + params: undefined; + response: { editors: NativeEditorInfo[] }; + }; + editorBridgeOpenInEditor: { + params: { editorId: NativeEditorId; workspacePath: string }; + response: EditorSession; + }; + editorBridgeGetSession: { + params: undefined; + response: EditorSession | null; + }; + editorBridgeClearSession: { + params: undefined; + response: undefined; + }; + + // ---- Workspace File Watcher ---- + fileWatcherStart: { + params: { watchPath: string }; + response: { watchId: string }; + }; + fileWatcherStop: { + params: { watchId: string }; + response: { stopped: boolean }; + }; + fileWatcherStopAll: { + params: undefined; + response: undefined; + }; + fileWatcherList: { + params: undefined; + response: { watches: WatchStatus[] }; + }; + fileWatcherGetStatus: { + params: { watchId: string }; + response: WatchStatus | null; + }; + + // ---- Floating Chat Window ---- + floatingChatOpen: { + params: { contextId?: string; x?: number; y?: number }; + response: FloatingChatStatus; + }; + floatingChatShow: { + params: undefined; + response: FloatingChatStatus; + }; + floatingChatHide: { + params: undefined; + response: FloatingChatStatus; + }; + floatingChatClose: { + params: undefined; + response: FloatingChatStatus; + }; + floatingChatSetContext: { + params: { contextId: string | null }; + response: FloatingChatStatus; + }; + floatingChatGetStatus: { + params: undefined; + response: FloatingChatStatus; + }; + // ---- Steward Sidecar ---- stewardGetStatus: { params: undefined; @@ -1183,6 +1302,15 @@ export type MiladyRPCSchema = { contextMenuQuoteInChat: { text: string }; contextMenuSaveAsCommand: { text: string }; + // Workspace file change push events + workspaceFileChanged: FileChangeEvent; + + // Editor bridge push events + editorSessionChanged: EditorSession | null; + + // Floating chat push events + floatingChatStatusChanged: FloatingChatStatus; + // API Base injection apiBaseUpdate: { base: string; token?: string }; @@ -1459,6 +1587,27 @@ export const CHANNEL_TO_RPC_METHOD: Record = { "steward:start": "stewardStart", "steward:restart": "stewardRestart", "steward:reset": "stewardReset", + + // Native Editor Bridge + "editorBridge:listEditors": "editorBridgeListEditors", + "editorBridge:openInEditor": "editorBridgeOpenInEditor", + "editorBridge:getSession": "editorBridgeGetSession", + "editorBridge:clearSession": "editorBridgeClearSession", + + // Workspace File Watcher + "fileWatcher:start": "fileWatcherStart", + "fileWatcher:stop": "fileWatcherStop", + "fileWatcher:stopAll": "fileWatcherStopAll", + "fileWatcher:list": "fileWatcherList", + "fileWatcher:getStatus": "fileWatcherGetStatus", + + // Floating Chat Window + "floatingChat:open": "floatingChatOpen", + "floatingChat:show": "floatingChatShow", + "floatingChat:hide": "floatingChatHide", + "floatingChat:close": "floatingChatClose", + "floatingChat:setContext": "floatingChatSetContext", + "floatingChat:getStatus": "floatingChatGetStatus", }; /** @@ -1507,6 +1656,15 @@ export const PUSH_CHANNEL_TO_RPC_MESSAGE: Record = { // WebGPU browser support "webgpu:browserStatus": "webGpuBrowserStatus", + + // Workspace file watcher + "fileWatcher:fileChanged": "workspaceFileChanged", + + // Editor bridge + "editorBridge:sessionChanged": "editorSessionChanged", + + // Floating chat + "floatingChat:statusChanged": "floatingChatStatusChanged", }; /**