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:
parent
4826b9dffa
commit
8e756d3523
13 changed files with 1199 additions and 239 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue