refactor(electrobun): modularize stores + shared UI components

Stores:
- notifications-store.svelte.ts: owns notifications array (was inline in App)
- workspace-store.svelte.ts: extended with addProjectFromWizard, loadGroupsFromDb,
  loadProjectsFromDb, derived getters (totalCost, totalTokens, mountedGroupIds)

Shared UI components (ui/):
- SegmentedControl.svelte: replaces repeated .seg button groups
- SliderInput.svelte: labeled range slider with value display
- StatusDot.svelte: colored dot with pulse support
- IconButton.svelte: icon-only button with tooltip, 3 sizes
- Section.svelte: settings section wrapper with heading

App.svelte: script 390→221 lines (removed all inline CRUD, delegates to stores)
ProjectCard: uses StatusDot shared component
AgentSettings + OrchestrationSettings: use SegmentedControl, SliderInput, Section
This commit is contained in:
Hibryda 2026-03-23 19:42:47 +01:00
parent 265ddd3f1d
commit c88577a34a
12 changed files with 647 additions and 437 deletions

View file

@ -2,6 +2,8 @@
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
import SegmentedControl from '../ui/SegmentedControl.svelte';
import Section from '../ui/Section.svelte';
type PermMode = 'bypassPermissions' | 'default' | 'plan';
@ -12,6 +14,12 @@
{ id: 'gemini', label: 'Gemini', desc: 'Google — gemini-2.5-pro' },
];
const PERM_OPTIONS = [
{ value: 'bypassPermissions', label: 'Bypass' },
{ value: 'default', label: 'Default' },
{ value: 'plan', label: 'Plan' },
];
let defaultShell = $state('/bin/bash');
let defaultCwd = $state('~');
let permissionMode = $state<PermMode>('bypassPermissions');
@ -27,8 +35,6 @@
let expandedProvider = $state<string | null>(null);
// ── Persistence helpers ──────────────────────────────────────────────────
function persist(key: string, value: string) {
appRpc?.request['settings.set']({ key, value }).catch(console.error);
}
@ -37,12 +43,10 @@
persist('provider_settings', JSON.stringify(providerState));
}
// ── Actions ──────────────────────────────────────────────────────────────
function setShell(v: string) { defaultShell = v; persist('default_shell', v); }
function setCwd(v: string) { defaultCwd = v; persist('default_cwd', v); }
function setPermMode(v: PermMode) { permissionMode = v; persist('permission_mode', v); }
function setPrompt(v: string) { systemPrompt = v; persist('system_prompt_template', v); }
function setPermMode(v: string) { permissionMode = v as PermMode; persist('permission_mode', v); }
function setPrompt(v: string) { systemPrompt = v; persist('system_prompt_template', v); }
function toggleProvider(id: string) {
providerState[id] = { ...providerState[id], enabled: !providerState[id].enabled };
@ -56,8 +60,6 @@
persistProviders();
}
// ── Restore on mount ─────────────────────────────────────────────────────
onMount(async () => {
if (!appRpc) return;
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
@ -71,9 +73,7 @@
});
</script>
<div class="section">
<h3 class="sh">Defaults</h3>
<Section heading="Defaults">
<div class="field">
<label class="lbl" for="ag-shell">Shell</label>
<input id="ag-shell" class="text-in" value={defaultShell} placeholder="/bin/bash"
@ -85,20 +85,19 @@
<input id="ag-cwd" class="text-in" value={defaultCwd} placeholder="~"
onchange={e => setCwd((e.target as HTMLInputElement).value)} />
</div>
</Section>
<h3 class="sh" style="margin-top: 0.75rem;">Permission mode</h3>
<div class="seg">
<button class:active={permissionMode === 'bypassPermissions'} onclick={() => setPermMode('bypassPermissions')}>Bypass</button>
<button class:active={permissionMode === 'default'} onclick={() => setPermMode('default')}>Default</button>
<button class:active={permissionMode === 'plan'} onclick={() => setPermMode('plan')}>Plan</button>
</div>
<Section heading="Permission mode" spaced>
<SegmentedControl options={PERM_OPTIONS} selected={permissionMode} onSelect={setPermMode} />
</Section>
<h3 class="sh" style="margin-top: 0.75rem;">System prompt template</h3>
<Section heading="System prompt template" spaced>
<textarea class="prompt" value={systemPrompt} rows="3"
placeholder="Optional prompt prepended to all agent sessions..."
onchange={e => setPrompt((e.target as HTMLTextAreaElement).value)}></textarea>
</Section>
<h3 class="sh" style="margin-top: 0.75rem;">Providers</h3>
<Section heading="Providers" spaced>
<div class="prov-list">
{#each PROVIDERS as prov}
{@const state = providerState[prov.id]}
@ -106,7 +105,7 @@
<button class="prov-hdr" onclick={() => expandedProvider = expandedProvider === prov.id ? null : prov.id}>
<span class="prov-name">{prov.label}</span>
<span class="prov-desc">{prov.desc}</span>
<span class="prov-chev">{expandedProvider === prov.id ? '▴' : '▾'}</span>
<span class="prov-chev">{expandedProvider === prov.id ? '\u25B4' : '\u25BE'}</span>
</button>
{#if expandedProvider === prov.id}
<div class="prov-body">
@ -132,11 +131,9 @@
</div>
{/each}
</div>
</div>
</Section>
<style>
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
.field { display: flex; flex-direction: column; gap: 0.2rem; }
.lbl { font-size: 0.75rem; color: var(--ctp-subtext0); }
@ -154,12 +151,6 @@
.prompt:focus { outline: none; border-color: var(--ctp-blue); }
.prompt::placeholder { color: var(--ctp-overlay0); }
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
.prov-list { display: flex; flex-direction: column; gap: 0.3rem; }
.prov-panel { background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem; overflow: hidden; transition: opacity 0.15s; }
.prov-panel.disabled { opacity: 0.5; }
@ -173,7 +164,7 @@
.toggle-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
.toggle { position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem; background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0; }
.toggle.on { background: var(--ctp-blue); }
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; }
.thumb { position: absolute; top: 0.125rem; left: 0.125rem; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--ctp-text); transition: transform 0.2s; display: block; }
.toggle.on .thumb { transform: translateX(0.875rem); }
.caps { display: flex; flex-wrap: wrap; gap: 0.25rem; }

View file

@ -2,15 +2,18 @@
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import CustomCheckbox from '../ui/CustomCheckbox.svelte';
import SegmentedControl from '../ui/SegmentedControl.svelte';
import SliderInput from '../ui/SliderInput.svelte';
import Section from '../ui/Section.svelte';
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
type AnchorScale = 'small' | 'medium' | 'large' | 'full';
const WAKE_LABELS: Record<WakeStrategy, string> = {
'persistent': 'Persistent',
'on-demand': 'On-demand',
'smart': 'Smart',
};
const WAKE_OPTIONS = [
{ value: 'persistent', label: 'Persistent' },
{ value: 'on-demand', label: 'On-demand' },
{ value: 'smart', label: 'Smart' },
];
const WAKE_DESCS: Record<WakeStrategy, string> = {
'persistent': 'Resume prompt whenever manager wakes',
'on-demand': 'Fresh session on each wake',
@ -18,7 +21,12 @@
};
const NOTIF_TYPES = ['complete', 'error', 'crash', 'stall'] as const;
const ANCHOR_SCALES: AnchorScale[] = ['small', 'medium', 'large', 'full'];
const ANCHOR_OPTIONS = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
{ value: 'full', label: 'Full' },
];
let wakeStrategy = $state<WakeStrategy>('persistent');
let wakeThreshold = $state(50);
@ -32,8 +40,8 @@
appRpc?.request['settings.set']({ key, value }).catch(console.error);
}
function setWakeStrategy(v: WakeStrategy) {
wakeStrategy = v;
function setWakeStrategy(v: string) {
wakeStrategy = v as WakeStrategy;
persist('wake_strategy', v);
}
@ -47,8 +55,8 @@
persist('auto_anchor', String(v));
}
function setAnchorBudget(v: AnchorScale) {
anchorBudget = v;
function setAnchorBudget(v: string) {
anchorBudget = v as AnchorScale;
persist('anchor_budget', v);
}
@ -84,25 +92,23 @@
});
</script>
<div class="section">
<h3 class="sh">Wake Strategy</h3>
<div class="seg">
{#each Object.keys(WAKE_LABELS) as s}
<button class:active={wakeStrategy === s} onclick={() => setWakeStrategy(s as WakeStrategy)}>{WAKE_LABELS[s as WakeStrategy]}</button>
{/each}
</div>
<Section heading="Wake Strategy">
<SegmentedControl options={WAKE_OPTIONS} selected={wakeStrategy} onSelect={setWakeStrategy} />
<p class="desc">{WAKE_DESCS[wakeStrategy]}</p>
{#if wakeStrategy === 'smart'}
<div class="slider-row">
<label class="lbl" for="wake-thresh">Wake threshold</label>
<input id="wake-thresh" type="range" min="0" max="100" step="5" value={wakeThreshold}
oninput={e => setWakeThreshold(parseInt((e.target as HTMLInputElement).value, 10))} />
<span class="slider-val">{wakeThreshold}%</span>
</div>
<SliderInput
label="Wake threshold"
id="wake-thresh"
min={0} max={100} step={5}
value={wakeThreshold}
onChange={setWakeThreshold}
format={v => `${v}%`}
/>
{/if}
</Section>
<h3 class="sh" style="margin-top: 0.875rem;">Session Anchors</h3>
<Section heading="Session Anchors" spaced>
<label class="toggle-row">
<span class="lbl">Auto-anchor on first compaction</span>
<button class="toggle" class:on={autoAnchor} role="switch" aria-checked={autoAnchor}
@ -111,23 +117,21 @@
</label>
<span class="lbl" style="margin-top: 0.375rem;">Anchor budget scale</span>
<div class="seg" style="margin-top: 0.25rem;">
{#each ANCHOR_SCALES as s}
<button class:active={anchorBudget === s} onclick={() => setAnchorBudget(s)}>
{s[0].toUpperCase() + s.slice(1)}
</button>
{/each}
</div>
<SegmentedControl options={ANCHOR_OPTIONS} selected={anchorBudget} onSelect={setAnchorBudget} />
</Section>
<h3 class="sh" style="margin-top: 0.875rem;">Health Monitoring</h3>
<div class="slider-row">
<label class="lbl" for="stall-thresh">Stall threshold</label>
<input id="stall-thresh" type="range" min="5" max="60" step="5" value={stallThreshold}
oninput={e => setStallThreshold(parseInt((e.target as HTMLInputElement).value, 10))} />
<span class="slider-val">{stallThreshold} min</span>
</div>
<Section heading="Health Monitoring" spaced>
<SliderInput
label="Stall threshold"
id="stall-thresh"
min={5} max={60} step={5}
value={stallThreshold}
onChange={setStallThreshold}
format={v => `${v} min`}
/>
</Section>
<h3 class="sh" style="margin-top: 0.875rem;">Notifications</h3>
<Section heading="Notifications" spaced>
<label class="toggle-row">
<span class="lbl">Desktop notifications</span>
<button class="toggle" class:on={notifDesktop} role="switch" aria-checked={notifDesktop}
@ -144,24 +148,12 @@
/>
{/each}
</div>
</div>
</Section>
<style>
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
.lbl { font-size: 0.8rem; color: var(--ctp-subtext0); }
.desc { font-size: 0.75rem; color: var(--ctp-overlay1); margin: 0; font-style: italic; }
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; font-family: var(--ui-font-family); }
.seg button:not(:last-child) { border-right: 1px solid var(--ctp-surface1); }
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
.seg button.active { background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0)); color: var(--ctp-blue); font-weight: 600; }
.slider-row { display: flex; align-items: center; gap: 0.5rem; }
.slider-row input[type="range"] { flex: 1; accent-color: var(--ctp-blue); }
.slider-val { font-size: 0.8rem; color: var(--ctp-text); min-width: 3rem; text-align: right; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; padding: 0.125rem 0; }
.toggle { position: relative; width: 2rem; height: 1.125rem; border: none; border-radius: 0.5625rem; background: var(--ctp-surface1); cursor: pointer; transition: background 0.2s; padding: 0; flex-shrink: 0; }
.toggle.on { background: var(--ctp-blue); }