feat(electrobun): final 5% — full integration, real data, polish

1. Claude CLI: additionalDirectories + worktreeName passthrough
2. Agent-store: reads settings (default_cwd, provider model, permission)
3. Project hydration: SQLite replaces hardcoded PROJECTS, add/remove UI
4. Group hydration: SQLite groups, add/delete in sidebar
5. Terminal auto-spawn: reads default_cwd from settings
6. Context tab: real tokens from agent-store, file refs, turn count
7. Memory tab: Memora DB integration (read-only, graceful if missing)
8. Docs tab: markdown viewer (files.list + files.read + inline renderer)
9. SSH tab: CRUD connections, spawn PTY with ssh command
10. Error handling: global unhandledrejection → toast notifications
11. Notifications: agent done/error/stall → toasts, 15min stall timer
12. Command palette: all 18 commands (was 10)

+1,198 lines, 13 files. Electrobun now 100% feature-complete vs Tauri v3.
This commit is contained in:
Hibryda 2026-03-22 02:02:54 +01:00
parent 4826b9dffa
commit 8e756d3523
13 changed files with 1199 additions and 239 deletions

View file

@ -12,6 +12,7 @@
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { trackProject } from './health-store.svelte.ts';
import { setAgentToastFn } from './agent-store.svelte.ts';
import { appRpc } from './rpc.ts';
// ── Types ─────────────────────────────────────────────────────
@ -53,60 +54,81 @@
hasNew?: boolean;
}
// ── Demo data ──────────────────────────────────────────────────
let PROJECTS = $state<Project[]>([
{
id: 'p1',
name: 'agent-orchestrator',
cwd: '~/code/ai/agent-orchestrator',
accent: 'var(--ctp-mauve)',
status: 'running',
costUsd: 0.034,
tokens: 18420,
provider: 'claude',
profile: 'dev',
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: 'p2',
name: 'quanta-discord-bot',
cwd: '~/code/quanta/discord-bot',
accent: 'var(--ctp-sapphire)',
status: 'idle',
costUsd: 0.011,
tokens: 6830,
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. Raised ef_searching to 128 and timeout to 8s as safety margin.' },
],
},
// ── Accent colors for auto-assignment ─────────────────────────
const ACCENTS = [
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
];
// ── Projects state (loaded from SQLite) ─────────────────────────
let PROJECTS = $state<Project[]>([]);
// ── Groups state (loaded from SQLite) ────────────────────────────
let groups = $state<Group[]>([
{ id: 'dev', name: 'Development', icon: '1', position: 0 },
]);
// ── Groups state ───────────────────────────────────────────────
let groups = $state<Group[]>([
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 },
{ id: 'test', name: 'Testing', icon: '🧪', position: 1, hasNew: true },
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
]);
// ── Add/Remove project UI state ──────────────────────────────────
let showAddProject = $state(false);
let newProjectName = $state('');
let newProjectCwd = $state('');
let projectToDelete = $state<string | null>(null);
async function addProject() {
const name = newProjectName.trim();
const cwd = newProjectCwd.trim();
if (!name || !cwd) return;
const id = `p-${Date.now()}`;
const accent = ACCENTS[PROJECTS.length % ACCENTS.length];
const project: Project = {
id, name, cwd, accent,
status: 'idle', costUsd: 0, tokens: 0, messages: [],
provider: 'claude', groupId: activeGroupId,
};
PROJECTS = [...PROJECTS, project];
trackProject(id);
await appRpc.request['settings.setProject']({
id,
config: JSON.stringify(project),
}).catch(console.error);
showAddProject = false;
newProjectName = '';
newProjectCwd = '';
}
async function confirmDeleteProject() {
if (!projectToDelete) return;
PROJECTS = PROJECTS.filter(p => p.id !== projectToDelete);
await appRpc.request['settings.deleteProject']({ id: projectToDelete }).catch(console.error);
projectToDelete = null;
}
// ── Add/Remove group UI state ───────────────────────────────────
let showAddGroup = $state(false);
let newGroupName = $state('');
async function addGroup() {
const name = newGroupName.trim();
if (!name) return;
const id = `grp-${Date.now()}`;
const position = groups.length;
const group: Group = { id, name, icon: String(position + 1), position };
groups = [...groups, group];
await appRpc.request['groups.create']({ id, name, icon: group.icon, position }).catch(console.error);
showAddGroup = false;
newGroupName = '';
}
async function removeGroup(id: string) {
if (groups.length <= 1) return; // keep at least one group
groups = groups.filter(g => g.id !== id);
if (activeGroupId === id) activeGroupId = groups[0]?.id ?? 'dev';
await appRpc.request['groups.delete']({ id }).catch(console.error);
}
let activeGroupId = $state('dev');
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
let previousGroupId = $state<string | null>(null);
@ -278,23 +300,64 @@
};
});
// ── Toast ref for agent notifications ─────────────────────────
let toastRef: ToastContainer | undefined;
function showToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
toastRef?.addToast(message, variant);
}
// ── Global error boundary ──────────────────────────────────────
function setupErrorBoundary() {
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason);
console.error('[unhandled rejection]', e.reason);
showToast(`Unhandled error: ${msg.slice(0, 100)}`, 'error');
e.preventDefault();
});
window.addEventListener('error', (e: ErrorEvent) => {
console.error('[uncaught error]', e.error);
showToast(`Error: ${e.message.slice(0, 100)}`, 'error');
});
}
// ── Init ───────────────────────────────────────────────────────
onMount(() => {
// Wire agent toast callback
setAgentToastFn(showToast);
// Set up global error boundary
setupErrorBoundary();
// Run all init tasks in parallel, mark app ready when all complete
const initTasks = [
themeStore.initTheme(appRpc).catch(console.error),
fontStore.initFonts(appRpc).catch(console.error),
keybindingStore.init(appRpc).catch(console.error),
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => {
if (dbGroups.length > 0) groups = dbGroups;
}).catch(console.error),
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }: { value: string | null }) => {
if (value && groups.some(g => g.id === value)) activeGroupId = value;
}).catch(console.error),
// Load projects from SQLite
appRpc.request["settings.getProjects"]({}).then(({ projects }: { projects: Array<{ id: string; config: string }> }) => {
if (projects.length > 0) {
const loaded: Project[] = projects.flatMap(({ config }) => {
try {
const p = JSON.parse(config) as Project;
return [{ ...p, status: p.status ?? 'idle', costUsd: p.costUsd ?? 0, tokens: p.tokens ?? 0, messages: p.messages ?? [] }];
} catch { return []; }
});
if (loaded.length > 0) PROJECTS = loaded;
}
}).catch(console.error),
];
Promise.allSettled(initTasks).then(() => {
appReady = true;
// Track projects for health monitoring after load
for (const p of PROJECTS) trackProject(p.id);
});
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
@ -314,9 +377,6 @@
}
document.addEventListener('keydown', handleSearchShortcut);
// Track projects for health monitoring
for (const p of PROJECTS) trackProject(p.id);
const cleanup = keybindingStore.installListener();
return () => {
cleanup();
@ -329,7 +389,7 @@
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
<ToastContainer />
<ToastContainer bind:this={toastRef} />
<NotifDrawer
open={drawerOpen}
{notifications}
@ -363,8 +423,43 @@
{/if}
</button>
{/each}
<!-- Add group button -->
<button
class="group-btn add-group-btn"
onclick={() => showAddGroup = !showAddGroup}
aria-label="Add group"
title="Add group"
>
<span class="group-circle" aria-hidden="true">+</span>
</button>
</div>
{#if showAddGroup}
<div class="add-group-form">
<input
class="add-group-input"
type="text"
placeholder="Group name"
bind:value={newGroupName}
onkeydown={(e) => { if (e.key === 'Enter') addGroup(); if (e.key === 'Escape') showAddGroup = false; }}
autofocus
/>
</div>
{/if}
<!-- Add project button -->
<button
class="sidebar-icon"
onclick={() => showAddProject = !showAddProject}
aria-label="Add project"
title="Add project"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<div class="sidebar-spacer"></div>
<!-- Settings gear -->
@ -416,6 +511,41 @@
style:display={filteredProjects.length === 0 ? 'flex' : 'none'}>
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
</div>
<!-- Add project card -->
<div class="add-card" role="listitem" style:display={showAddProject ? 'flex' : 'none'}>
<div class="add-card-form">
<input
class="add-input"
type="text"
placeholder="Project name"
bind:value={newProjectName}
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
/>
<input
class="add-input"
type="text"
placeholder="Working directory (e.g. ~/code/myproject)"
bind:value={newProjectCwd}
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
/>
<div class="add-card-actions">
<button class="add-cancel" onclick={() => showAddProject = false}>Cancel</button>
<button class="add-confirm" onclick={addProject}>Add</button>
</div>
</div>
</div>
<!-- Delete project confirmation -->
{#if projectToDelete}
<div class="delete-overlay" role="listitem">
<p class="delete-text">Delete project "{PROJECTS.find(p => p.id === projectToDelete)?.name}"?</p>
<div class="add-card-actions">
<button class="add-cancel" onclick={() => projectToDelete = null}>Cancel</button>
<button class="delete-confirm" onclick={confirmDeleteProject}>Delete</button>
</div>
</div>
{/if}
</div>
</main>
@ -706,5 +836,79 @@
line-height: 1;
}
/* ── Add group form ─────────────────────────────────────────── */
.add-group-form {
padding: 0.25rem;
width: 100%;
}
.add-group-input {
width: 100%;
padding: 0.25rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.625rem;
font-family: var(--ui-font-family);
text-align: center;
}
.add-group-input:focus { outline: none; border-color: var(--ctp-blue); }
/* ── Add project card ──────────────────────────────────────── */
.add-card {
grid-column: 1 / -1;
flex-direction: column;
background: var(--ctp-base);
border: 1px dashed var(--ctp-surface1);
border-radius: 0.5rem;
padding: 0.75rem;
}
.add-card-form { display: flex; flex-direction: column; gap: 0.375rem; }
.add-input {
padding: 0.375rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8125rem;
font-family: var(--ui-font-family);
}
.add-input:focus { outline: none; border-color: var(--ctp-blue); }
.add-input::placeholder { color: var(--ctp-overlay0); }
.add-card-actions { display: flex; gap: 0.375rem; justify-content: flex-end; }
.add-cancel, .add-confirm, .delete-confirm {
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
font-family: var(--ui-font-family);
}
.add-cancel { background: transparent; border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); }
.add-cancel:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.add-confirm { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border: 1px solid var(--ctp-green); color: var(--ctp-green); }
.add-confirm:hover { background: color-mix(in srgb, var(--ctp-green) 35%, transparent); }
.delete-overlay {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-red);
border-radius: 0.5rem;
}
.delete-text { font-size: 0.875rem; color: var(--ctp-text); margin: 0; }
.delete-confirm { background: color-mix(in srgb, var(--ctp-red) 20%, transparent); border: 1px solid var(--ctp-red); color: var(--ctp-red); }
.delete-confirm:hover { background: color-mix(in srgb, var(--ctp-red) 35%, transparent); }
/* Status bar styles are in StatusBar.svelte */
</style>