feat(electrobun): groups, cloning, shortcuts, custom window — all 5 features

Groups Sidebar:
- SQLite groups table (4 seeded: Development, Testing, DevOps, Research)
- Left icon rail with emoji group icons, Ctrl+1-4 switching
- Active group highlighted, projects filtered by group

Project Cloning:
- Clone button on project cards (fork icon)
- git worktree add via Bun.spawn (array form, no shell strings)
- 3-clone limit, branch name validation, pending-status pattern
- Clone cards: WT badge + branch name + accent top border
- Chain link SVG icons between linked clones in grid

Keyboard Shortcuts:
- keybinding-store.svelte.ts: 16 defaults across 4 categories
- Two-scope: document capture + terminal focus guard
- KeyboardSettings.svelte: search, click-to-capture, conflict detection
- Per-binding reset + Reset All

Custom Window:
- titleBarStyle: "hidden" — no native title bar
- Vertical "AGOR" text in left sidebar (writing-mode: vertical-rl)
- Floating window controls badge (minimize/maximize/close)
- Draggable region via -webkit-app-region: drag
- Window frame persisted to SQLite (debounced 500ms)

Window is resizable by default (Electrobun BrowserWindow).
This commit is contained in:
Hibryda 2026-03-20 06:24:24 +01:00
parent 5032021915
commit a020f59cb4
14 changed files with 1741 additions and 189 deletions

View file

@ -3,14 +3,13 @@ import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "./pty-client.ts";
import { settingsDb } from "./settings-db.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { randomUUID } from "crypto";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
// ── PTY daemon client ────────────────────────────────────────────────────────
// Resolve daemon socket directory. agor-ptyd writes ptyd.sock and ptyd.token
// into $XDG_RUNTIME_DIR/agor or ~/.local/share/agor/run.
const ptyClient = new PtyClient();
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
@ -34,19 +33,27 @@ async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
return false;
}
// ── Clone helpers ────────────────────────────────────────────────────────────
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
async function gitWorktreeAdd(mainRepoPath: string, worktreePath: string, branchName: string): Promise<{ ok: boolean; error?: string }> {
const proc = Bun.spawn(
["git", "worktree", "add", worktreePath, "-b", branchName],
{ cwd: mainRepoPath, stderr: "pipe", stdout: "pipe" }
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
const errText = await new Response(proc.stderr).text();
return { ok: false, error: errText.trim() || `git exited with code ${exitCode}` };
}
return { ok: true };
}
// ── RPC definition ────────────────────────────────────────────────────────────
/**
* BrowserView.defineRPC defines handlers that the WebView can call.
* The schema type parameter describes what the Bun side handles (requests from
* WebView) and what messages Bun sends to the WebView.
*
* Pattern: handlers.requests = WebViewBun calls
* handlers.messages = BunWebView fire-and-forget
* To push a message to the WebView: rpc.send["pty.output"](payload)
*/
const rpc = BrowserView.defineRPC<PtyRPCSchema>({
maxRequestTime: 10_000,
maxRequestTime: 15_000,
handlers: {
requests: {
"pty.create": async ({ sessionId, cols, rows, cwd }) => {
@ -55,8 +62,6 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
}
try {
ptyClient.createSession({ id: sessionId, cols, rows, cwd });
// Don't call subscribe() — CreateSession already auto-subscribes
// the creating client and starts a fanout task.
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
@ -77,7 +82,7 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
},
"pty.resize": ({ sessionId, cols, rows }) => {
if (!ptyClient.isConnected) return { ok: true }; // Best-effort
if (!ptyClient.isConnected) return { ok: true };
try {
ptyClient.resize(sessionId, cols, rows);
} catch { /* ignore */ }
@ -182,29 +187,174 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
return { ok: false };
}
},
// ── Groups handlers ──────────────────────────────────────────────────
"groups.list": () => {
try {
return { groups: settingsDb.listGroups() };
} catch (err) {
console.error("[groups.list]", err);
return { groups: [] };
}
},
// ── Project clone handler ────────────────────────────────────────────
"project.clone": async ({ projectId, branchName }) => {
try {
if (!BRANCH_RE.test(branchName)) {
return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." };
}
const source = settingsDb.getProject(projectId);
if (!source) {
return { ok: false, error: `Project not found: ${projectId}` };
}
// Determine the authoritative main repo path
const mainRepoPath = source.mainRepoPath ?? source.cwd;
// Count existing clones
const allProjects = settingsDb.listProjects();
const existingClones = allProjects.filter(
(p) => p.cloneOf === projectId || (source.cloneOf && p.cloneOf === source.cloneOf)
);
if (existingClones.length >= 3) {
return { ok: false, error: "Maximum 3 clones per project reached" };
}
const cloneIndex = existingClones.length + 1;
const worktreePath = `${mainRepoPath}-wt-${cloneIndex}`;
const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName);
if (!gitResult.ok) {
return { ok: false, error: gitResult.error };
}
const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`;
const cloneConfig = {
id: cloneId,
name: `${source.name} [${branchName}]`,
cwd: worktreePath,
accent: source.accent,
provider: source.provider,
profile: source.profile,
model: source.model,
groupId: source.groupId ?? "dev",
mainRepoPath,
cloneOf: projectId,
worktreePath,
worktreeBranch: branchName,
cloneIndex,
};
settingsDb.setProject(cloneId, cloneConfig);
return {
ok: true,
project: { id: cloneId, config: JSON.stringify(cloneConfig) },
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[project.clone]", err);
return { ok: false, error };
}
},
// ── Window control handlers ──────────────────────────────────────────
"window.minimize": () => {
try {
mainWindow.minimize();
return { ok: true };
} catch (err) {
console.error("[window.minimize]", err);
return { ok: false };
}
},
"window.maximize": () => {
try {
const frame = mainWindow.getFrame();
// Heuristic: if window is already near the screen edge, unmaximize
if (frame.x <= 0 && frame.y <= 0) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
return { ok: true };
} catch (err) {
console.error("[window.maximize]", err);
return { ok: false };
}
},
"window.close": () => {
try {
mainWindow.close();
return { ok: true };
} catch (err) {
console.error("[window.close]", err);
return { ok: false };
}
},
"window.getFrame": () => {
try {
return mainWindow.getFrame();
} catch {
return { x: 0, y: 0, width: 1400, height: 900 };
}
},
// ── Keybinding handlers ──────────────────────────────────────────────
"keybindings.getAll": () => {
try {
return { keybindings: settingsDb.getKeybindings() };
} catch (err) {
console.error("[keybindings.getAll]", err);
return { keybindings: {} };
}
},
"keybindings.set": ({ id, chord }) => {
try {
settingsDb.setKeybinding(id, chord);
return { ok: true };
} catch (err) {
console.error("[keybindings.set]", err);
return { ok: false };
}
},
"keybindings.reset": ({ id }) => {
try {
settingsDb.deleteKeybinding(id);
return { ok: true };
} catch (err) {
console.error("[keybindings.reset]", err);
return { ok: false };
}
},
},
messages: {
// Messages section defines what the WebView can *send* to Bun (fire-and-forget).
// We don't expect any inbound messages from the WebView in this direction.
},
messages: {},
},
});
// ── Forward daemon events to WebView ────────────────────────────────────────
// session_output: forward each chunk to the WebView as a "pty.output" message.
ptyClient.on("session_output", (msg) => {
if (msg.type !== "session_output") return;
try {
// data is already base64 from the daemon — pass it straight through.
rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data });
} catch (err) {
console.error("[pty.output] forward error:", err);
}
});
// session_closed: notify the WebView so it can display "[Process exited]".
ptyClient.on("session_closed", (msg) => {
if (msg.type !== "session_closed") return;
try {
@ -237,15 +387,22 @@ connectToDaemon();
const url = await getMainViewUrl();
// Restore persisted window frame if available
const savedX = Number(settingsDb.getSetting("win_x") ?? 100);
const savedY = Number(settingsDb.getSetting("win_y") ?? 100);
const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400);
const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
const mainWindow = new BrowserWindow({
title: "Agent Orchestrator — Electrobun",
title: "Agent Orchestrator",
titleBarStyle: "hidden",
url,
rpc,
frame: {
width: 1400,
height: 900,
x: 100,
y: 100,
width: isNaN(savedWidth) ? 1400 : savedWidth,
height: isNaN(savedHeight) ? 900 : savedHeight,
x: isNaN(savedX) ? 100 : savedX,
y: isNaN(savedY) ? 100 : savedY,
},
});