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

@ -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 69s 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);