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:
parent
265ddd3f1d
commit
c88577a34a
12 changed files with 647 additions and 437 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue