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

@ -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;