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:
parent
5032021915
commit
a020f59cb4
14 changed files with 1741 additions and 189 deletions
|
|
@ -231891,6 +231891,24 @@ CREATE TABLE IF NOT EXISTS custom_themes (
|
|||
name TEXT NOT NULL,
|
||||
palette TEXT NOT NULL -- JSON blob
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keybindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
chord TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
var SEED_GROUPS = `
|
||||
INSERT OR IGNORE INTO groups VALUES ('dev', 'Development', '\uD83D\uDD27', 0);
|
||||
INSERT OR IGNORE INTO groups VALUES ('test', 'Testing', '\uD83E\uDDEA', 1);
|
||||
INSERT OR IGNORE INTO groups VALUES ('ops', 'DevOps', '\uD83D\uDE80', 2);
|
||||
INSERT OR IGNORE INTO groups VALUES ('research', 'Research', '\uD83D\uDD2C', 3);
|
||||
`;
|
||||
|
||||
class SettingsDb {
|
||||
|
|
@ -231899,6 +231917,7 @@ class SettingsDb {
|
|||
mkdirSync2(CONFIG_DIR, { recursive: true });
|
||||
this.db = new Database2(DB_PATH);
|
||||
this.db.exec(SCHEMA);
|
||||
this.db.exec(SEED_GROUPS);
|
||||
const version = this.db.query("SELECT version FROM schema_version LIMIT 1").get();
|
||||
if (!version) {
|
||||
this.db.exec("INSERT INTO schema_version (version) VALUES (1)");
|
||||
|
|
@ -231940,6 +231959,9 @@ class SettingsDb {
|
|||
}
|
||||
});
|
||||
}
|
||||
listGroups() {
|
||||
return this.db.query("SELECT id, name, icon, position FROM groups ORDER BY position ASC").all();
|
||||
}
|
||||
getCustomThemes() {
|
||||
const rows = this.db.query("SELECT id, name, palette FROM custom_themes").all();
|
||||
return rows.flatMap((r) => {
|
||||
|
|
@ -231957,6 +231979,16 @@ class SettingsDb {
|
|||
deleteCustomTheme(id) {
|
||||
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
|
||||
}
|
||||
getKeybindings() {
|
||||
const rows = this.db.query("SELECT id, chord FROM keybindings").all();
|
||||
return Object.fromEntries(rows.map((r) => [r.id, r.chord]));
|
||||
}
|
||||
setKeybinding(id, chord) {
|
||||
this.db.query("INSERT INTO keybindings (id, chord) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET chord = excluded.chord").run(id, chord);
|
||||
}
|
||||
deleteKeybinding(id) {
|
||||
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
|
||||
}
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
|
|
@ -231964,6 +231996,7 @@ class SettingsDb {
|
|||
var settingsDb = new SettingsDb;
|
||||
|
||||
// src/bun/index.ts
|
||||
import { randomUUID } from "crypto";
|
||||
var DEV_SERVER_PORT = 9760;
|
||||
var DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
||||
var ptyClient = new PtyClient;
|
||||
|
|
@ -231987,8 +232020,18 @@ async function connectToDaemon(retries = 5, delayMs = 500) {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
var BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
|
||||
async function gitWorktreeAdd(mainRepoPath, worktreePath, branchName) {
|
||||
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 };
|
||||
}
|
||||
var rpc = BrowserView.defineRPC({
|
||||
maxRequestTime: 1e4,
|
||||
maxRequestTime: 15000,
|
||||
handlers: {
|
||||
requests: {
|
||||
"pty.create": async ({ sessionId, cols, rows, cwd }) => {
|
||||
|
|
@ -232107,6 +232150,127 @@ var rpc = BrowserView.defineRPC({
|
|||
console.error("[themes.deleteCustom]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
"groups.list": () => {
|
||||
try {
|
||||
return { groups: settingsDb.listGroups() };
|
||||
} catch (err) {
|
||||
console.error("[groups.list]", err);
|
||||
return { groups: [] };
|
||||
}
|
||||
},
|
||||
"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}` };
|
||||
}
|
||||
const mainRepoPath = source.mainRepoPath ?? source.cwd;
|
||||
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 error2 = err instanceof Error ? err.message : String(err);
|
||||
console.error("[project.clone]", err);
|
||||
return { ok: false, error: error2 };
|
||||
}
|
||||
},
|
||||
"window.minimize": () => {
|
||||
try {
|
||||
mainWindow.minimize();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.error("[window.minimize]", err);
|
||||
return { ok: false };
|
||||
}
|
||||
},
|
||||
"window.maximize": () => {
|
||||
try {
|
||||
const frame2 = mainWindow.getFrame();
|
||||
if (frame2.x <= 0 && frame2.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 };
|
||||
}
|
||||
},
|
||||
"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: {}
|
||||
|
|
@ -232145,15 +232309,20 @@ async function getMainViewUrl() {
|
|||
}
|
||||
connectToDaemon();
|
||||
var url = await getMainViewUrl();
|
||||
var savedX = Number(settingsDb.getSetting("win_x") ?? 100);
|
||||
var savedY = Number(settingsDb.getSetting("win_y") ?? 100);
|
||||
var savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400);
|
||||
var savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
|
||||
var mainWindow = new BrowserWindow({
|
||||
title: "Agent Orchestrator \u2014 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
|
||||
}
|
||||
});
|
||||
console.log("Agent Orchestrator (Electrobun) started!");
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Svelte App</title>
|
||||
<script type="module" crossorigin src="/assets/index-CEtguZVp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dvoh622C.css">
|
||||
<script type="module" crossorigin src="/assets/index-CgAt0V08.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-rAZ5LabM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -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 = WebView→Bun calls
|
||||
* handlers.messages = Bun→WebView 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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,26 @@ CREATE TABLE IF NOT EXISTS custom_themes (
|
|||
name TEXT NOT NULL,
|
||||
palette TEXT NOT NULL -- JSON blob
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keybindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
chord TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// Seed default groups (idempotent via INSERT OR IGNORE)
|
||||
const SEED_GROUPS = `
|
||||
INSERT OR IGNORE INTO groups VALUES ('dev', 'Development', '🔧', 0);
|
||||
INSERT OR IGNORE INTO groups VALUES ('test', 'Testing', '🧪', 1);
|
||||
INSERT OR IGNORE INTO groups VALUES ('ops', 'DevOps', '🚀', 2);
|
||||
INSERT OR IGNORE INTO groups VALUES ('research', 'Research', '🔬', 3);
|
||||
`;
|
||||
|
||||
// ── SettingsDb class ─────────────────────────────────────────────────────────
|
||||
|
|
@ -51,6 +71,18 @@ export interface ProjectConfig {
|
|||
provider?: string;
|
||||
profile?: string;
|
||||
model?: string;
|
||||
/** Group this project belongs to. Defaults to 'dev'. */
|
||||
groupId?: string;
|
||||
/** For clones: path of the source repo (worktree parent). */
|
||||
mainRepoPath?: string;
|
||||
/** For clones: ID of the original project this was cloned from. */
|
||||
cloneOf?: string;
|
||||
/** For clones: absolute path to the git worktree. */
|
||||
worktreePath?: string;
|
||||
/** For clones: branch name checked out in the worktree. */
|
||||
worktreeBranch?: string;
|
||||
/** 1-indexed clone number within the parent (1–3). */
|
||||
cloneIndex?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +92,13 @@ export interface CustomTheme {
|
|||
palette: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export class SettingsDb {
|
||||
private db: Database;
|
||||
|
||||
|
|
@ -69,6 +108,7 @@ export class SettingsDb {
|
|||
|
||||
this.db = new Database(DB_PATH);
|
||||
this.db.exec(SCHEMA);
|
||||
this.db.exec(SEED_GROUPS);
|
||||
|
||||
// Seed schema_version row if missing
|
||||
const version = this.db
|
||||
|
|
@ -136,6 +176,14 @@ export class SettingsDb {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
listGroups(): Group[] {
|
||||
return this.db
|
||||
.query<Group, []>("SELECT id, name, icon, position FROM groups ORDER BY position ASC")
|
||||
.all();
|
||||
}
|
||||
|
||||
// ── Custom Themes ─────────────────────────────────────────────────────────
|
||||
|
||||
getCustomThemes(): CustomTheme[] {
|
||||
|
|
@ -166,6 +214,25 @@ export class SettingsDb {
|
|||
this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// ── Keybindings ───────────────────────────────────────────────────────────
|
||||
|
||||
getKeybindings(): Record<string, string> {
|
||||
const rows = this.db
|
||||
.query<{ id: string; chord: string }, []>("SELECT id, chord FROM keybindings")
|
||||
.all();
|
||||
return Object.fromEntries(rows.map((r) => [r.id, r.chord]));
|
||||
}
|
||||
|
||||
setKeybinding(id: string, chord: string): void {
|
||||
this.db
|
||||
.query("INSERT INTO keybindings (id, chord) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET chord = excluded.chord")
|
||||
.run(id, chord);
|
||||
}
|
||||
|
||||
deleteKeybinding(id: string): void {
|
||||
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
close(): void {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import ToastContainer from './ToastContainer.svelte';
|
||||
import { themeStore } from './theme-store.svelte.ts';
|
||||
import { fontStore } from './font-store.svelte.ts';
|
||||
import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||
import { appRpc } from './main.ts';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
|
@ -32,10 +33,22 @@
|
|||
model?: string;
|
||||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
groupId?: string;
|
||||
cloneOf?: string;
|
||||
worktreeBranch?: string;
|
||||
mainRepoPath?: string;
|
||||
cloneIndex?: number;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
// ── Demo data ──────────────────────────────────────────────────
|
||||
const PROJECTS: Project[] = [
|
||||
let PROJECTS = $state<Project[]>([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'agent-orchestrator',
|
||||
|
|
@ -49,14 +62,14 @@
|
|||
model: 'claude-opus-4-5',
|
||||
contextPct: 78,
|
||||
burnRate: 0.12,
|
||||
groupId: 'dev',
|
||||
mainRepoPath: '~/code/ai/agent-orchestrator',
|
||||
messages: [
|
||||
{ id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' },
|
||||
{ id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' },
|
||||
{ id: 3, role: 'tool-call', content: 'Read("src/lib/stores/wake-scheduler.svelte.ts")' },
|
||||
{ id: 4, role: 'tool-result', content: '// 312 lines\nexport type WakeStrategy = "persistent" | "on-demand" | "smart";\n...' },
|
||||
{ id: 5, role: 'assistant', content: 'Found the WakeSignal enum. I\'ll add ReviewBacklog(0.6) and hook into bttask polling. Writing wake-scorer.ts update now.' },
|
||||
{ id: 6, role: 'tool-call', content: 'Edit("src/lib/utils/wake-scorer.ts", old="// signals", new="// signals\n ReviewBacklog = 0.6,")' },
|
||||
{ id: 7, role: 'assistant', content: 'Done. ReviewBacklog signal added at priority 0.6 (between TaskQueuePressure 0.7 and PeriodicFloor 0.1). Run `npm test` to verify 24 scorer tests still pass.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -70,38 +83,143 @@
|
|||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
contextPct: 32,
|
||||
groupId: 'dev',
|
||||
messages: [
|
||||
{ id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' },
|
||||
{ id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' },
|
||||
{ id: 3, role: 'tool-call', content: 'Read("src/mcp/server.ts", offset=120, limit=40)' },
|
||||
{ id: 4, role: 'tool-result', content: 'const QUERY_TIMEOUT_MS = 5000; // default\n...' },
|
||||
{ id: 5, role: 'assistant', content: 'Found it. The MCP handler has a hardcoded 5s timeout but Qdrant HNSW search on 500k vectors takes 6–9s at ef=256. Two options:\n1. Raise timeout to 15s (quick fix)\n2. Lower ef to 128 for faster approximate search (~3s, slight recall drop)\n\nRecommend option 2 for latency-sensitive bot responses.' },
|
||||
{ id: 6, role: 'user', content: 'Go with option 2.' },
|
||||
{ id: 7, role: 'assistant', content: 'Updated `ef_searching: 128` in qdrant-config.ts and raised timeout to 8s as safety margin. Restarted service on port 9320.' },
|
||||
{ id: 5, role: 'assistant', content: 'Found it. Raised ef_searching to 128 and timeout to 8s as safety margin.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
// ── Init theme + fonts on mount ────────────────────────────────
|
||||
// ── Groups state ───────────────────────────────────────────────
|
||||
let groups = $state<Group[]>([
|
||||
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 },
|
||||
{ id: 'test', name: 'Testing', icon: '🧪', position: 1 },
|
||||
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
|
||||
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
|
||||
]);
|
||||
let activeGroupId = $state('dev');
|
||||
|
||||
// ── Filtered projects for active group ────────────────────────
|
||||
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
|
||||
let filteredProjects = $derived(
|
||||
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
|
||||
);
|
||||
|
||||
// Group projects into: top-level cards + clone groups
|
||||
// A "clone group" is a parent + its clones rendered side by side
|
||||
interface ProjectRow {
|
||||
type: 'standalone';
|
||||
project: Project;
|
||||
}
|
||||
interface CloneGroupRow {
|
||||
type: 'clone-group';
|
||||
parent: Project;
|
||||
clones: Project[];
|
||||
}
|
||||
type GridRow = ProjectRow | CloneGroupRow;
|
||||
|
||||
let gridRows = $derived((): GridRow[] => {
|
||||
const standalone: GridRow[] = [];
|
||||
const cloneParentIds = new Set(
|
||||
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
|
||||
);
|
||||
|
||||
for (const p of filteredProjects) {
|
||||
if (p.cloneOf) continue; // Skip clones — they go into clone groups
|
||||
if (cloneParentIds.has(p.id)) {
|
||||
// This parent has clones
|
||||
const clones = filteredProjects
|
||||
.filter(c => c.cloneOf === p.id)
|
||||
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
|
||||
standalone.push({ type: 'clone-group', parent: p, clones });
|
||||
} else {
|
||||
standalone.push({ type: 'standalone', project: p });
|
||||
}
|
||||
}
|
||||
return standalone;
|
||||
});
|
||||
|
||||
// ── Clone count helpers ────────────────────────────────────────
|
||||
function cloneCountForProject(projectId: string): number {
|
||||
return PROJECTS.filter(p => p.cloneOf === projectId).length;
|
||||
}
|
||||
|
||||
function handleClone(projectId: string) {
|
||||
// In a real app: open the clone dialog and call RPC.
|
||||
// Here we demonstrate the flow by calling the RPC directly with a demo branch.
|
||||
const source = PROJECTS.find(p => p.id === projectId);
|
||||
if (!source) return;
|
||||
const branchName = `feature/clone-${Date.now()}`;
|
||||
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
|
||||
if (result.ok && result.project) {
|
||||
const cloneConfig = JSON.parse(result.project.config) as Project;
|
||||
PROJECTS = [...PROJECTS, {
|
||||
...cloneConfig,
|
||||
status: 'idle',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
messages: [],
|
||||
}];
|
||||
} else {
|
||||
console.error('[clone]', result.error);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// ── Init on mount ──────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
themeStore.initTheme(appRpc).catch(console.error);
|
||||
fontStore.initFonts(appRpc).catch(console.error);
|
||||
keybindingStore.init(appRpc).catch(console.error);
|
||||
|
||||
// Load groups from DB
|
||||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
|
||||
if (dbGroups.length > 0) groups = dbGroups;
|
||||
}).catch(console.error);
|
||||
|
||||
// Restore active group from settings
|
||||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
|
||||
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
||||
}).catch(console.error);
|
||||
|
||||
// Register keybinding command handlers
|
||||
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
|
||||
keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; });
|
||||
keybindingStore.on('group1', () => setActiveGroup(groups[0]?.id));
|
||||
keybindingStore.on('group2', () => setActiveGroup(groups[1]?.id));
|
||||
keybindingStore.on('group3', () => setActiveGroup(groups[2]?.id));
|
||||
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
|
||||
keybindingStore.on('minimize', () => appRpc.request["window.minimize"]({}).catch(console.error));
|
||||
|
||||
// Install listener (returns cleanup fn, but onMount handles lifecycle)
|
||||
const cleanup = keybindingStore.installListener();
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// ── Reactive state ─────────────────────────────────────────────
|
||||
let settingsOpen = $state(false);
|
||||
let paletteOpen = $state(false);
|
||||
let notifCount = $state(2); // demo unread
|
||||
let notifCount = $state(2);
|
||||
let sessionStart = $state(Date.now());
|
||||
|
||||
// Blink state — JS timer, no CSS animation (0% CPU overhead)
|
||||
function setActiveGroup(id: string | undefined) {
|
||||
if (!id) return;
|
||||
activeGroupId = id;
|
||||
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
|
||||
}
|
||||
|
||||
// Blink state
|
||||
let blinkVisible = $state(true);
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Session duration string (updates every 10s)
|
||||
// Session duration
|
||||
let sessionDuration = $state('0m');
|
||||
$effect(() => {
|
||||
function update() {
|
||||
|
|
@ -113,21 +231,19 @@
|
|||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Global keyboard shortcuts ──────────────────────────────────
|
||||
$effect(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
paletteOpen = !paletteOpen;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
|
||||
e.preventDefault();
|
||||
settingsOpen = !settingsOpen;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
// Window frame persistence (debounced 500ms)
|
||||
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function saveWindowFrame() {
|
||||
if (frameSaveTimer) clearTimeout(frameSaveTimer);
|
||||
frameSaveTimer = setTimeout(() => {
|
||||
appRpc.request["window.getFrame"]({}).then((frame) => {
|
||||
appRpc.request["settings.set"]({ key: 'win_x', value: String(frame.x) }).catch(console.error);
|
||||
appRpc.request["settings.set"]({ key: 'win_y', value: String(frame.y) }).catch(console.error);
|
||||
appRpc.request["settings.set"]({ key: 'win_width', value: String(frame.width) }).catch(console.error);
|
||||
appRpc.request["settings.set"]({ key: 'win_height', value: String(frame.height) }).catch(console.error);
|
||||
}).catch(console.error);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// ── Status bar aggregates ──────────────────────────────────────
|
||||
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
|
||||
|
|
@ -135,7 +251,6 @@
|
|||
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
|
||||
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
||||
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
||||
// Attention queue: projects with high context or stalled
|
||||
let attentionItems = $derived(
|
||||
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
|
||||
);
|
||||
|
|
@ -147,16 +262,54 @@
|
|||
function fmtCost(n: number): string {
|
||||
return `$${n.toFixed(3)}`;
|
||||
}
|
||||
|
||||
// ── Window control helpers ─────────────────────────────────────
|
||||
function windowMinimize() {
|
||||
appRpc.request["window.minimize"]({}).catch(console.error);
|
||||
}
|
||||
|
||||
function windowMaximize() {
|
||||
appRpc.request["window.maximize"]({}).catch(console.error);
|
||||
}
|
||||
|
||||
function windowClose() {
|
||||
appRpc.request["window.close"]({}).catch(console.error);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||
<ToastContainer />
|
||||
|
||||
<div class="app-shell">
|
||||
<div
|
||||
class="app-shell"
|
||||
role="presentation"
|
||||
onresize={saveWindowFrame}
|
||||
>
|
||||
<!-- Sidebar icon rail -->
|
||||
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
|
||||
<!-- AGOR vertical title -->
|
||||
<div class="agor-title" aria-hidden="true">AGOR</div>
|
||||
|
||||
<!-- Group icons -->
|
||||
<div class="sidebar-groups" role="list" aria-label="Project groups">
|
||||
{#each groups as group, i}
|
||||
<button
|
||||
class="sidebar-icon group-icon"
|
||||
class:active={activeGroupId === group.id}
|
||||
onclick={() => setActiveGroup(group.id)}
|
||||
aria-label="{group.name} (Ctrl+{i + 1})"
|
||||
title="{group.name} (Ctrl+{i + 1})"
|
||||
role="listitem"
|
||||
>
|
||||
<span class="group-emoji" aria-hidden="true">{group.icon}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
|
||||
<!-- Settings gear -->
|
||||
<button
|
||||
class="sidebar-icon"
|
||||
class:active={settingsOpen}
|
||||
|
|
@ -173,32 +326,121 @@
|
|||
|
||||
<!-- Main workspace -->
|
||||
<main class="workspace">
|
||||
<div class="project-grid">
|
||||
{#each PROJECTS as project (project.id)}
|
||||
<ProjectCard
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
cwd={project.cwd}
|
||||
accent={project.accent}
|
||||
status={project.status}
|
||||
costUsd={project.costUsd}
|
||||
tokens={project.tokens}
|
||||
messages={project.messages}
|
||||
provider={project.provider}
|
||||
profile={project.profile}
|
||||
model={project.model}
|
||||
contextPct={project.contextPct}
|
||||
burnRate={project.burnRate}
|
||||
{blinkVisible}
|
||||
/>
|
||||
<!-- Draggable title bar area with window controls -->
|
||||
<div class="title-bar" aria-label="Window title bar">
|
||||
<div class="title-bar-drag" aria-hidden="true">
|
||||
<span class="title-bar-text">{activeGroup?.name ?? 'Agent Orchestrator'}</span>
|
||||
</div>
|
||||
<div class="window-controls" role="toolbar" aria-label="Window controls">
|
||||
<button
|
||||
class="wc-btn"
|
||||
onclick={windowMinimize}
|
||||
aria-label="Minimize window"
|
||||
title="Minimize"
|
||||
>─</button>
|
||||
<button
|
||||
class="wc-btn"
|
||||
onclick={windowMaximize}
|
||||
aria-label="Maximize window"
|
||||
title="Maximize"
|
||||
>□</button>
|
||||
<button
|
||||
class="wc-btn close-btn"
|
||||
onclick={windowClose}
|
||||
aria-label="Close window"
|
||||
title="Close"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project grid -->
|
||||
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
|
||||
{#each gridRows() as row (row.type === 'standalone' ? row.project.id : `cg-${row.parent.id}`)}
|
||||
{#if row.type === 'standalone'}
|
||||
<div role="listitem">
|
||||
<ProjectCard
|
||||
id={row.project.id}
|
||||
name={row.project.name}
|
||||
cwd={row.project.cwd}
|
||||
accent={row.project.accent}
|
||||
status={row.project.status}
|
||||
costUsd={row.project.costUsd}
|
||||
tokens={row.project.tokens}
|
||||
messages={row.project.messages}
|
||||
provider={row.project.provider}
|
||||
profile={row.project.profile}
|
||||
model={row.project.model}
|
||||
contextPct={row.project.contextPct}
|
||||
burnRate={row.project.burnRate}
|
||||
{blinkVisible}
|
||||
clonesAtMax={cloneCountForProject(row.project.id) >= 3}
|
||||
onClone={handleClone}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Clone group: parent + clones in a flex row spanning full width -->
|
||||
<div class="clone-group-row" role="listitem" aria-label="Project group: {row.parent.name}">
|
||||
<ProjectCard
|
||||
id={row.parent.id}
|
||||
name={row.parent.name}
|
||||
cwd={row.parent.cwd}
|
||||
accent={row.parent.accent}
|
||||
status={row.parent.status}
|
||||
costUsd={row.parent.costUsd}
|
||||
tokens={row.parent.tokens}
|
||||
messages={row.parent.messages}
|
||||
provider={row.parent.provider}
|
||||
profile={row.parent.profile}
|
||||
model={row.parent.model}
|
||||
contextPct={row.parent.contextPct}
|
||||
burnRate={row.parent.burnRate}
|
||||
{blinkVisible}
|
||||
clonesAtMax={row.clones.length >= 3}
|
||||
onClone={handleClone}
|
||||
/>
|
||||
{#each row.clones as clone (clone.id)}
|
||||
<!-- Chain link icon between cards -->
|
||||
<div class="chain-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<ProjectCard
|
||||
id={clone.id}
|
||||
name={clone.name}
|
||||
cwd={clone.cwd}
|
||||
accent={clone.accent ?? row.parent.accent}
|
||||
status={clone.status}
|
||||
costUsd={clone.costUsd}
|
||||
tokens={clone.tokens}
|
||||
messages={clone.messages}
|
||||
provider={clone.provider}
|
||||
profile={clone.profile}
|
||||
model={clone.model}
|
||||
contextPct={clone.contextPct}
|
||||
burnRate={clone.burnRate}
|
||||
{blinkVisible}
|
||||
worktreeBranch={clone.worktreeBranch}
|
||||
cloneOf={clone.cloneOf}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if filteredProjects.length === 0}
|
||||
<div class="empty-group" role="listitem">
|
||||
<span class="empty-group-icon" aria-hidden="true">{activeGroup?.icon ?? '📁'}</span>
|
||||
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
|
||||
<!-- Agent states -->
|
||||
{#if runningCount > 0}
|
||||
<span class="status-segment">
|
||||
<span class="status-dot-sm green" aria-hidden="true"></span>
|
||||
|
|
@ -221,7 +463,6 @@
|
|||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Attention queue -->
|
||||
{#if attentionItems.length > 0}
|
||||
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
|
||||
|
|
@ -234,13 +475,17 @@
|
|||
|
||||
<span class="status-bar-spacer"></span>
|
||||
|
||||
<!-- Session duration -->
|
||||
<!-- Group indicator -->
|
||||
<span class="status-segment" title="Active group">
|
||||
<span aria-hidden="true">{activeGroup?.icon}</span>
|
||||
<span class="status-value">{activeGroup?.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="status-segment" title="Session duration">
|
||||
<span>session</span>
|
||||
<span class="status-value">{sessionDuration}</span>
|
||||
</span>
|
||||
|
||||
<!-- Tokens + cost -->
|
||||
<span class="status-segment" title="Total tokens used">
|
||||
<span>tokens</span>
|
||||
<span class="status-value">{fmtTokens(totalTokens)}</span>
|
||||
|
|
@ -250,7 +495,6 @@
|
|||
<span class="status-value">{fmtCost(totalCost)}</span>
|
||||
</span>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="notif-btn"
|
||||
onclick={() => notifCount = 0}
|
||||
|
|
@ -265,7 +509,6 @@
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Palette hint -->
|
||||
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
|
||||
</footer>
|
||||
|
||||
|
|
@ -280,7 +523,7 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar icon rail */
|
||||
/* ── Sidebar ──────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
|
|
@ -289,8 +532,34 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0 0.5rem;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Vertical AGOR title */
|
||||
.agor-title {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--ui-font-family);
|
||||
font-weight: 800;
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--ctp-overlay1);
|
||||
padding: 0.625rem 0;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Group icons section */
|
||||
.sidebar-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; }
|
||||
|
|
@ -308,13 +577,31 @@
|
|||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-mauve); }
|
||||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.sidebar-icon svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Workspace */
|
||||
/* Active group: accent border-left indicator */
|
||||
.group-icon.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.375rem;
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 2px;
|
||||
background: var(--ctp-mauve);
|
||||
border-radius: 0 1px 1px 0;
|
||||
}
|
||||
|
||||
.group-emoji {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Workspace ────────────────────────────────────────────── */
|
||||
.workspace {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -323,6 +610,67 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Title bar (custom chrome) ────────────────────────────── */
|
||||
.title-bar {
|
||||
height: 2rem;
|
||||
background: var(--ctp-crust);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
/* Make entire title bar draggable; window controls override this */
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.title-bar-drag {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.title-bar-text {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-overlay1);
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
-webkit-app-region: no-drag;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wc-btn {
|
||||
width: 2.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
padding: 0;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.wc-btn:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
/* ── Project grid ─────────────────────────────────────────── */
|
||||
.project-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -331,9 +679,61 @@
|
|||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--ctp-crust);
|
||||
overflow-y: auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.project-grid::-webkit-scrollbar { width: 0.375rem; }
|
||||
.project-grid::-webkit-scrollbar-track { background: transparent; }
|
||||
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
/* Clone group: spans both grid columns, flex row */
|
||||
.clone-group-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Each ProjectCard inside a clone group gets flex: 1 */
|
||||
.clone-group-row :global(.project-card) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Chain icon between linked cards */
|
||||
.chain-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.chain-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Empty group placeholder */
|
||||
.empty-group {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 0;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.empty-group-icon { font-size: 2rem; }
|
||||
.empty-group-text { font-size: 0.875rem; font-style: italic; }
|
||||
|
||||
/* ── Status bar ───────────────────────────────────────────── */
|
||||
.status-bar {
|
||||
height: var(--status-bar-height);
|
||||
background: var(--ctp-crust);
|
||||
|
|
@ -368,11 +768,9 @@
|
|||
.status-value { color: var(--ctp-text); font-weight: 500; }
|
||||
.status-bar-spacer { flex: 1; }
|
||||
|
||||
/* Attention badge */
|
||||
.attn-badge { color: var(--ctp-yellow); }
|
||||
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
|
||||
|
||||
/* Notification bell */
|
||||
.notif-btn {
|
||||
position: relative;
|
||||
width: 1.5rem;
|
||||
|
|
@ -411,7 +809,6 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Palette shortcut hint */
|
||||
.palette-hint {
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: var(--ctp-surface0);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@
|
|||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
blinkVisible?: boolean;
|
||||
/** Worktree branch name — set when this is a clone card. */
|
||||
worktreeBranch?: string;
|
||||
/** ID of parent project — set when this is a clone card. */
|
||||
cloneOf?: string;
|
||||
/** Max clones reached for this project. */
|
||||
clonesAtMax?: boolean;
|
||||
/** Callback when user requests cloning. */
|
||||
onClone?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -46,8 +54,34 @@
|
|||
contextPct = 0,
|
||||
burnRate = 0,
|
||||
blinkVisible = true,
|
||||
worktreeBranch,
|
||||
cloneOf,
|
||||
clonesAtMax = false,
|
||||
onClone,
|
||||
}: Props = $props();
|
||||
|
||||
// ── Clone dialog state ──────────────────────────────────────────
|
||||
let showCloneDialog = $state(false);
|
||||
let cloneBranchName = $state('');
|
||||
let cloneError = $state('');
|
||||
|
||||
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
|
||||
|
||||
function openCloneDialog() {
|
||||
cloneBranchName = '';
|
||||
cloneError = '';
|
||||
showCloneDialog = true;
|
||||
}
|
||||
|
||||
function submitClone() {
|
||||
if (!BRANCH_RE.test(cloneBranchName)) {
|
||||
cloneError = 'Use only letters, numbers, /, _, -, .';
|
||||
return;
|
||||
}
|
||||
onClone?.(id);
|
||||
showCloneDialog = false;
|
||||
}
|
||||
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
// svelte-ignore state_referenced_locally
|
||||
const seedMessages = initialMessages.slice();
|
||||
|
|
@ -77,8 +111,9 @@
|
|||
|
||||
<article
|
||||
class="project-card"
|
||||
class:is-clone={!!cloneOf}
|
||||
style="--accent: {accent}"
|
||||
aria-label="Project: {name}"
|
||||
aria-label="Project: {name}{cloneOf ? ' (worktree clone)' : ''}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="project-header">
|
||||
|
|
@ -94,6 +129,12 @@
|
|||
<span class="project-name" title={name}>{name}</span>
|
||||
<span class="project-cwd" title={cwd}>{cwd}</span>
|
||||
|
||||
{#if worktreeBranch}
|
||||
<span class="wt-badge" title="Worktree branch: {worktreeBranch}">
|
||||
WT · {worktreeBranch}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="provider-badge" title="Provider: {provider}">{provider}</span>
|
||||
|
||||
{#if profile}
|
||||
|
|
@ -112,8 +153,56 @@
|
|||
{#if burnRate > 0}
|
||||
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
|
||||
{/if}
|
||||
|
||||
<!-- Clone button (only on non-clone cards) -->
|
||||
{#if !cloneOf && onClone}
|
||||
<button
|
||||
class="clone-btn"
|
||||
onclick={openCloneDialog}
|
||||
disabled={clonesAtMax}
|
||||
title={clonesAtMax ? 'Maximum 3 clones reached' : 'Clone into git worktree'}
|
||||
aria-label="Clone project into worktree"
|
||||
>
|
||||
<!-- Fork / branch SVG icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="6" y1="3" x2="6" y2="15"/>
|
||||
<circle cx="18" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M18 9a9 9 0 0 1-9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Clone dialog (inline, shown above tab bar) -->
|
||||
{#if showCloneDialog}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="clone-dialog"
|
||||
role="dialog"
|
||||
aria-label="Create worktree clone"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showCloneDialog = false; }}
|
||||
>
|
||||
<span class="clone-dialog-label">Branch name</span>
|
||||
<input
|
||||
class="clone-dialog-input"
|
||||
type="text"
|
||||
placeholder="feature/my-branch"
|
||||
bind:value={cloneBranchName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') submitClone(); }}
|
||||
autofocus
|
||||
aria-label="New branch name for worktree"
|
||||
/>
|
||||
{#if cloneError}
|
||||
<span class="clone-dialog-error">{cloneError}</span>
|
||||
{/if}
|
||||
<div class="clone-dialog-actions">
|
||||
<button class="clone-dialog-cancel" onclick={() => showCloneDialog = false}>Cancel</button>
|
||||
<button class="clone-dialog-submit" onclick={submitClone}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Project tab bar -->
|
||||
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
|
||||
{#each ALL_TABS as tab}
|
||||
|
|
@ -276,6 +365,15 @@
|
|||
border-radius: 0.5rem 0 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Worktree clone: accent top border instead of left stripe */
|
||||
.project-card.is-clone {
|
||||
border-top: 2px solid var(--accent, var(--ctp-mauve));
|
||||
}
|
||||
|
||||
.project-card.is-clone::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.project-header {
|
||||
height: 2.5rem;
|
||||
|
|
@ -367,6 +465,131 @@
|
|||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
/* Worktree badge */
|
||||
.wt-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 20%, transparent);
|
||||
color: var(--accent, var(--ctp-mauve));
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--ctp-mauve)) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Clone button */
|
||||
.clone-btn {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.clone-btn:hover:not(:disabled) {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.clone-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clone-btn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Inline clone dialog */
|
||||
.clone-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clone-dialog-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clone-dialog-input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
height: 1.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.375rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clone-dialog-input:focus { border-color: var(--ctp-mauve); }
|
||||
|
||||
.clone-dialog-error {
|
||||
width: 100%;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.clone-dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clone-dialog-cancel,
|
||||
.clone-dialog-submit {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--ui-font-family);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.clone-dialog-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.clone-dialog-cancel:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.clone-dialog-submit {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
|
||||
border: 1px solid var(--ctp-mauve);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.clone-dialog-submit:hover {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar {
|
||||
height: 2rem;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
|
||||
import AdvancedSettings from './settings/AdvancedSettings.svelte';
|
||||
import MarketplaceTab from './settings/MarketplaceTab.svelte';
|
||||
import KeyboardSettings from './settings/KeyboardSettings.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -14,7 +15,7 @@
|
|||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace';
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace' | 'keyboard';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
{ id: 'security', label: 'Security', icon: '🔒' },
|
||||
{ id: 'projects', label: 'Projects', icon: '📁' },
|
||||
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
|
||||
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
|
||||
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
|
||||
];
|
||||
|
|
@ -94,6 +96,8 @@
|
|||
<OrchestrationSettings />
|
||||
{:else if activeCategory === 'advanced'}
|
||||
<AdvancedSettings />
|
||||
{:else if activeCategory === 'keyboard'}
|
||||
<KeyboardSettings />
|
||||
{:else if activeCategory === 'marketplace'}
|
||||
<MarketplaceTab />
|
||||
{/if}
|
||||
|
|
|
|||
168
ui-electrobun/src/mainview/keybinding-store.svelte.ts
Normal file
168
ui-electrobun/src/mainview/keybinding-store.svelte.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Svelte 5 rune-based keybinding store.
|
||||
* Manages global keyboard shortcuts with user-customizable chords.
|
||||
* Persists overrides via settings RPC (only non-default bindings saved).
|
||||
*
|
||||
* Usage:
|
||||
* import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||
* await keybindingStore.init(rpc);
|
||||
*
|
||||
* // Register a command handler
|
||||
* keybindingStore.on('palette', () => paletteOpen = true);
|
||||
*
|
||||
* // Install the global keydown listener (call once)
|
||||
* keybindingStore.installListener();
|
||||
*/
|
||||
|
||||
// ── Minimal RPC interface ────────────────────────────────────────────────────
|
||||
|
||||
interface SettingsRpc {
|
||||
request: {
|
||||
"keybindings.getAll"(p: Record<string, never>): Promise<{ keybindings: Record<string, string> }>;
|
||||
"keybindings.set"(p: { id: string; chord: string }): Promise<{ ok: boolean }>;
|
||||
"keybindings.reset"(p: { id: string }): Promise<{ ok: boolean }>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Keybinding {
|
||||
id: string;
|
||||
label: string;
|
||||
category: "Global" | "Navigation" | "Terminal" | "Settings";
|
||||
chord: string;
|
||||
defaultChord: string;
|
||||
}
|
||||
|
||||
// ── Default bindings ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULTS: Keybinding[] = [
|
||||
{ id: "palette", label: "Command Palette", category: "Global", chord: "Ctrl+K", defaultChord: "Ctrl+K" },
|
||||
{ id: "settings", label: "Open Settings", category: "Global", chord: "Ctrl+,", defaultChord: "Ctrl+," },
|
||||
{ id: "group1", label: "Switch to Group 1", category: "Navigation", chord: "Ctrl+1", defaultChord: "Ctrl+1" },
|
||||
{ id: "group2", label: "Switch to Group 2", category: "Navigation", chord: "Ctrl+2", defaultChord: "Ctrl+2" },
|
||||
{ id: "group3", label: "Switch to Group 3", category: "Navigation", chord: "Ctrl+3", defaultChord: "Ctrl+3" },
|
||||
{ id: "group4", label: "Switch to Group 4", category: "Navigation", chord: "Ctrl+4", defaultChord: "Ctrl+4" },
|
||||
{ id: "newTerminal", label: "New Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+T", defaultChord: "Ctrl+Shift+T" },
|
||||
{ id: "closeTab", label: "Close Terminal Tab", category: "Terminal", chord: "Ctrl+Shift+W", defaultChord: "Ctrl+Shift+W" },
|
||||
{ id: "nextTab", label: "Next Terminal Tab", category: "Terminal", chord: "Ctrl+]", defaultChord: "Ctrl+]" },
|
||||
{ id: "prevTab", label: "Previous Terminal Tab", category: "Terminal", chord: "Ctrl+[", defaultChord: "Ctrl+[" },
|
||||
{ id: "search", label: "Global Search", category: "Global", chord: "Ctrl+Shift+F", defaultChord: "Ctrl+Shift+F" },
|
||||
{ id: "notifications",label: "Notification Center", category: "Global", chord: "Ctrl+Shift+N", defaultChord: "Ctrl+Shift+N" },
|
||||
{ id: "minimize", label: "Minimize Window", category: "Global", chord: "Ctrl+M", defaultChord: "Ctrl+M" },
|
||||
{ id: "toggleFiles", label: "Toggle Files Tab", category: "Navigation", chord: "Ctrl+Shift+E", defaultChord: "Ctrl+Shift+E" },
|
||||
{ id: "toggleMemory", label: "Toggle Memory Tab", category: "Navigation", chord: "Ctrl+Shift+M", defaultChord: "Ctrl+Shift+M" },
|
||||
{ id: "reload", label: "Reload App", category: "Settings", chord: "Ctrl+R", defaultChord: "Ctrl+R" },
|
||||
];
|
||||
|
||||
// ── Chord serialisation helpers ───────────────────────────────────────────────
|
||||
|
||||
export function chordFromEvent(e: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
const key = e.key === " " ? "Space" : e.key;
|
||||
// Exclude pure modifier keys
|
||||
if (!["Control", "Shift", "Alt", "Meta"].includes(key)) {
|
||||
parts.push(key.length === 1 ? key.toUpperCase() : key);
|
||||
}
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
function matchesChord(e: KeyboardEvent, chord: string): boolean {
|
||||
return chordFromEvent(e) === chord;
|
||||
}
|
||||
|
||||
// ── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createKeybindingStore() {
|
||||
let bindings = $state<Keybinding[]>(DEFAULTS.map((b) => ({ ...b })));
|
||||
let rpc: SettingsRpc | null = null;
|
||||
const handlers = new Map<string, () => void>();
|
||||
let listenerInstalled = false;
|
||||
|
||||
/** Load persisted overrides and merge with defaults. */
|
||||
async function init(rpcInstance: SettingsRpc): Promise<void> {
|
||||
rpc = rpcInstance;
|
||||
try {
|
||||
const { keybindings: overrides } = await rpc.request["keybindings.getAll"]({});
|
||||
bindings = DEFAULTS.map((b) => ({
|
||||
...b,
|
||||
chord: overrides[b.id] ?? b.defaultChord,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[keybinding-store] Failed to load keybindings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a custom chord for a binding id. Persists to SQLite. */
|
||||
function setChord(id: string, chord: string): void {
|
||||
bindings = bindings.map((b) => b.id === id ? { ...b, chord } : b);
|
||||
rpc?.request["keybindings.set"]({ id, chord }).catch(console.error);
|
||||
}
|
||||
|
||||
/** Reset a binding to its default chord. Removes SQLite override. */
|
||||
function resetChord(id: string): void {
|
||||
const def = DEFAULTS.find((b) => b.id === id);
|
||||
if (!def) return;
|
||||
bindings = bindings.map((b) => b.id === id ? { ...b, chord: def.defaultChord } : b);
|
||||
rpc?.request["keybindings.reset"]({ id }).catch(console.error);
|
||||
}
|
||||
|
||||
/** Reset all bindings to defaults. */
|
||||
function resetAll(): void {
|
||||
for (const b of bindings) {
|
||||
if (b.chord !== b.defaultChord) resetChord(b.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a handler for a binding id. */
|
||||
function on(id: string, handler: () => void): void {
|
||||
handlers.set(id, handler);
|
||||
}
|
||||
|
||||
/** Install a global capture-phase keydown listener. Idempotent. */
|
||||
function installListener(): () => void {
|
||||
if (listenerInstalled) return () => {};
|
||||
listenerInstalled = true;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
// Never intercept keys when focus is inside a terminal canvas
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest(".terminal-container, .xterm")) return;
|
||||
|
||||
const chord = chordFromEvent(e);
|
||||
if (!chord) return;
|
||||
|
||||
for (const b of bindings) {
|
||||
if (b.chord === chord) {
|
||||
const handler = handlers.get(b.id);
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeydown, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeydown, { capture: true });
|
||||
listenerInstalled = false;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get bindings() { return bindings; },
|
||||
init,
|
||||
setChord,
|
||||
resetChord,
|
||||
resetAll,
|
||||
on,
|
||||
installListener,
|
||||
};
|
||||
}
|
||||
|
||||
export const keybindingStore = createKeybindingStore();
|
||||
315
ui-electrobun/src/mainview/settings/KeyboardSettings.svelte
Normal file
315
ui-electrobun/src/mainview/settings/KeyboardSettings.svelte
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<script lang="ts">
|
||||
import { keybindingStore, chordFromEvent, type Keybinding } from '../keybinding-store.svelte.ts';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
let capturingId = $state<string | null>(null);
|
||||
let conflictWarning = $state<string | null>(null);
|
||||
|
||||
// ── Derived filtered list ──────────────────────────────────────
|
||||
let filtered = $derived(
|
||||
searchQuery.trim()
|
||||
? keybindingStore.bindings.filter(
|
||||
(b) =>
|
||||
b.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.chord.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: keybindingStore.bindings
|
||||
);
|
||||
|
||||
// ── Category groups ────────────────────────────────────────────
|
||||
let grouped = $derived(
|
||||
filtered.reduce<Record<string, Keybinding[]>>((acc, b) => {
|
||||
(acc[b.category] ??= []).push(b);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const CATEGORY_ORDER = ['Global', 'Navigation', 'Terminal', 'Settings'];
|
||||
|
||||
// ── Capture mode ──────────────────────────────────────────────
|
||||
function startCapture(id: string) {
|
||||
capturingId = id;
|
||||
conflictWarning = null;
|
||||
}
|
||||
|
||||
function handleCaptureKeydown(e: KeyboardEvent, id: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const chord = chordFromEvent(e);
|
||||
if (!chord || chord === 'Escape') {
|
||||
capturingId = null;
|
||||
conflictWarning = null;
|
||||
return;
|
||||
}
|
||||
// Skip bare modifier key presses (no actual key yet)
|
||||
if (!chord.match(/[A-Z0-9,.\[\]\\/'`\-=; ]|F\d+|Enter|Tab|Space|Backspace|Delete|Arrow/)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict check
|
||||
const conflict = keybindingStore.bindings.find(
|
||||
(b) => b.id !== id && b.chord === chord
|
||||
);
|
||||
|
||||
if (conflict) {
|
||||
conflictWarning = `Conflicts with "${conflict.label}"`;
|
||||
} else {
|
||||
conflictWarning = null;
|
||||
}
|
||||
|
||||
keybindingStore.setChord(id, chord);
|
||||
capturingId = null;
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
keybindingStore.resetAll();
|
||||
conflictWarning = null;
|
||||
}
|
||||
|
||||
function isModified(b: Keybinding): boolean {
|
||||
return b.chord !== b.defaultChord;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kb-settings">
|
||||
<!-- Toolbar -->
|
||||
<div class="kb-toolbar">
|
||||
<input
|
||||
class="kb-search"
|
||||
type="search"
|
||||
placeholder="Search shortcuts…"
|
||||
bind:value={searchQuery}
|
||||
aria-label="Search keyboard shortcuts"
|
||||
/>
|
||||
<button class="kb-reset-all" onclick={resetAll} title="Reset all shortcuts to defaults">
|
||||
Reset All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if conflictWarning}
|
||||
<div class="kb-conflict-banner" role="alert">
|
||||
Warning: {conflictWarning}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Table by category -->
|
||||
{#each CATEGORY_ORDER as category}
|
||||
{#if grouped[category]?.length}
|
||||
<div class="kb-category">
|
||||
<div class="kb-category-header">{category}</div>
|
||||
<div class="kb-table">
|
||||
{#each grouped[category] as binding (binding.id)}
|
||||
<div class="kb-row" class:modified={isModified(binding)}>
|
||||
<span class="kb-label">{binding.label}</span>
|
||||
|
||||
<!-- Chord cell: click to capture -->
|
||||
{#if capturingId === binding.id}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="kb-chord capturing"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Press new key combination for {binding.label}"
|
||||
autofocus
|
||||
onkeydown={(e) => handleCaptureKeydown(e, binding.id)}
|
||||
onblur={() => capturingId = null}
|
||||
>
|
||||
Press keys…
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="kb-chord"
|
||||
onclick={() => startCapture(binding.id)}
|
||||
title="Click to rebind"
|
||||
aria-label="Current shortcut for {binding.label}: {binding.chord}. Click to change."
|
||||
>
|
||||
{binding.chord}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Reset button: only shown when modified -->
|
||||
{#if isModified(binding)}
|
||||
<button
|
||||
class="kb-reset"
|
||||
onclick={() => keybindingStore.resetChord(binding.id)}
|
||||
title="Reset to {binding.defaultChord}"
|
||||
aria-label="Reset {binding.label} to default"
|
||||
>
|
||||
↩ {binding.defaultChord}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="kb-reset-placeholder"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="kb-empty">No shortcuts match "{searchQuery}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kb-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kb-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kb-search {
|
||||
flex: 1;
|
||||
height: 1.75rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem;
|
||||
padding: 0 0.5rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kb-search:focus { border-color: var(--ctp-mauve); }
|
||||
|
||||
.kb-reset-all {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.kb-reset-all:hover {
|
||||
border-color: var(--ctp-red);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.kb-conflict-banner {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
||||
border: 1px solid var(--ctp-yellow);
|
||||
border-radius: 0.3rem;
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.kb-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.kb-category-header {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ctp-overlay0);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.kb-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.kb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.kb-row:hover { background: var(--ctp-surface0); }
|
||||
.kb-row.modified { background: color-mix(in srgb, var(--ctp-mauve) 6%, transparent); }
|
||||
|
||||
.kb-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ctp-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-chord {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-chord:hover:not(.capturing) {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.kb-chord.capturing {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 15%, var(--ctp-surface0));
|
||||
color: var(--ctp-mauve);
|
||||
animation: pulse-capture 0.8s ease-in-out infinite;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-capture {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.kb-reset {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.kb-reset:hover {
|
||||
border-color: var(--ctp-surface1);
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.kb-reset-placeholder {
|
||||
width: 5rem; /* Reserve space so layout stays stable */
|
||||
}
|
||||
|
||||
.kb-empty {
|
||||
text-align: center;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -89,6 +89,63 @@ export type PtyRPCRequests = {
|
|||
params: { id: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
|
||||
// ── Groups RPC ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return all project groups. */
|
||||
"groups.list": {
|
||||
params: Record<string, never>;
|
||||
response: { groups: Array<{ id: string; name: string; icon: string; position: number }> };
|
||||
};
|
||||
|
||||
// ── Project clone RPC ──────────────────────────────────────────────────────
|
||||
|
||||
/** Clone a project into a git worktree. branchName must match /^[a-zA-Z0-9\/_.-]+$/. */
|
||||
"project.clone": {
|
||||
params: { projectId: string; branchName: string };
|
||||
response: { ok: boolean; project?: { id: string; config: string }; error?: string };
|
||||
};
|
||||
|
||||
// ── Window control RPC ─────────────────────────────────────────────────────
|
||||
|
||||
/** Minimize the main window. */
|
||||
"window.minimize": {
|
||||
params: Record<string, never>;
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Toggle maximize/restore on the main window. */
|
||||
"window.maximize": {
|
||||
params: Record<string, never>;
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Close the main window. */
|
||||
"window.close": {
|
||||
params: Record<string, never>;
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Get current window frame (x, y, width, height). */
|
||||
"window.getFrame": {
|
||||
params: Record<string, never>;
|
||||
response: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
|
||||
// ── Keybindings RPC ────────────────────────────────────────────────────────
|
||||
|
||||
/** Return all persisted custom keybindings (overrides only). */
|
||||
"keybindings.getAll": {
|
||||
params: Record<string, never>;
|
||||
response: { keybindings: Record<string, string> };
|
||||
};
|
||||
/** Persist a single keybinding override. */
|
||||
"keybindings.set": {
|
||||
params: { id: string; chord: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Reset a keybinding to default (removes override). */
|
||||
"keybindings.reset": {
|
||||
params: { id: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue