From c88577a34ae55bf3775c3d0f90e2f06fe00945ef Mon Sep 17 00:00:00 2001 From: Hibryda Date: Mon, 23 Mar 2026 19:42:47 +0100 Subject: [PATCH] refactor(electrobun): modularize stores + shared UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui-electrobun/src/mainview/App.svelte | 440 +++++------------- ui-electrobun/src/mainview/NotifDrawer.svelte | 7 +- ui-electrobun/src/mainview/ProjectCard.svelte | 21 +- .../mainview/notifications-store.svelte.ts | 49 ++ .../mainview/settings/AgentSettings.svelte | 51 +- .../settings/OrchestrationSettings.svelte | 96 ++-- .../src/mainview/ui/IconButton.svelte | 78 ++++ ui-electrobun/src/mainview/ui/Section.svelte | 40 ++ .../src/mainview/ui/SegmentedControl.svelte | 71 +++ .../src/mainview/ui/SliderInput.svelte | 82 ++++ .../src/mainview/ui/StatusDot.svelte | 39 ++ .../src/mainview/workspace-store.svelte.ts | 110 ++++- 12 files changed, 647 insertions(+), 437 deletions(-) create mode 100644 ui-electrobun/src/mainview/notifications-store.svelte.ts create mode 100644 ui-electrobun/src/mainview/ui/IconButton.svelte create mode 100644 ui-electrobun/src/mainview/ui/Section.svelte create mode 100644 ui-electrobun/src/mainview/ui/SegmentedControl.svelte create mode 100644 ui-electrobun/src/mainview/ui/SliderInput.svelte create mode 100644 ui-electrobun/src/mainview/ui/StatusDot.svelte diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index ee52fca..b8080d5 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -4,7 +4,7 @@ import SettingsDrawer from './SettingsDrawer.svelte'; import CommandPalette from './CommandPalette.svelte'; import ToastContainer from './ToastContainer.svelte'; - import NotifDrawer, { type Notification } from './NotifDrawer.svelte'; + import NotifDrawer from './NotifDrawer.svelte'; import StatusBar from './StatusBar.svelte'; import SearchOverlay from './SearchOverlay.svelte'; import SplashScreen from './SplashScreen.svelte'; @@ -12,211 +12,42 @@ import { themeStore } from './theme-store.svelte.ts'; 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'; - import { initI18n, getDir, getLocale, t } from './i18n.svelte.ts'; + import { initI18n, getDir, getLocale } from './i18n.svelte.ts'; + import { + getProjects, getGroups, getActiveGroupId, + getMountedGroupIds, getActiveGroup, getFilteredProjects, + getTotalCostDerived, getTotalTokensDerived, + setActiveGroup, addProjectFromWizard, deleteProject, + cloneCountForProject, handleClone, addGroup as wsAddGroup, + loadGroupsFromDb, loadProjectsFromDb, trackAllProjects, + type WizardResult, + } from './workspace-store.svelte.ts'; + import { + getNotifications, clearAll as clearNotifications, getNotifCount, + } from './notifications-store.svelte.ts'; - // ── Types ───────────────────────────────────────────────────── - type AgentStatus = 'running' | 'idle' | 'stalled'; - type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result'; - - interface AgentMessage { - id: number; - role: MsgRole; - content: string; - } - - interface Project { - id: string; - name: string; - cwd: string; - accent: string; - status: AgentStatus; - costUsd: number; - tokens: number; - messages: AgentMessage[]; - provider?: string; - profile?: string; - model?: string; - contextPct?: number; - burnRate?: number; - groupId?: string; - cloneOf?: string; - worktreeBranch?: string; - mainRepoPath?: string; - cloneIndex?: number; - } - - interface Group { - id: string; - name: string; - icon: string; - position: number; - hasNew?: boolean; - } - - // ── 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([]); - - // ── Groups state (loaded from SQLite) ──────────────────────────── - let groups = $state([ - { id: 'dev', name: 'Development', icon: '1', position: 0 }, - ]); - - // ── Add/Remove project UI state ────────────────────────────────── - let showWizard = $state(false); + // ── Local UI state (view-only, not domain state) ──────────── + let appReady = $state(false); + let settingsOpen = $state(false); + let paletteOpen = $state(false); + let drawerOpen = $state(false); + let searchOpen = $state(false); + let showWizard = $state(false); let projectToDelete = $state(null); + let showAddGroup = $state(false); + let newGroupName = $state(''); + let sessionStart = $state(Date.now()); - function handleWizardCreated(result: { - id: string; name: string; cwd: string; provider?: string; model?: string; - systemPrompt?: string; autoStart?: boolean; groupId?: string; - useWorktrees?: boolean; shell?: string; icon?: string; color?: string; - modelConfig?: Record; - }) { - const accent = result.color || ACCENTS[PROJECTS.length % ACCENTS.length]; - const gid = result.groupId ?? activeGroupId; - const project: Project = { - id: result.id, - name: result.name, - cwd: result.cwd, - accent, - status: 'idle', - costUsd: 0, - tokens: 0, - messages: [], - provider: result.provider ?? 'claude', - model: result.model, - groupId: gid, - }; - PROJECTS = [...PROJECTS, project]; - trackProject(project.id); - - // Persist full config including shell, icon, modelConfig etc. - const persistConfig = { - id: result.id, name: result.name, cwd: result.cwd, accent, - provider: result.provider ?? 'claude', model: result.model, - groupId: gid, shell: result.shell, icon: result.icon, - useWorktrees: result.useWorktrees, systemPrompt: result.systemPrompt, - autoStart: result.autoStart, modelConfig: result.modelConfig, - }; - appRpc.request['settings.setProject']({ - id: project.id, - config: JSON.stringify(persistConfig), - }).catch(console.error); - - showWizard = false; - } - - 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 = ''; - } - - // Fix #19: removeGroup removed — was defined but never called from UI - let activeGroupId = $state('dev'); - // Fix #10: Track previous group to limit mounted DOM (max 2 groups) - let previousGroupId = $state(null); - let mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])])); - - // ── Filtered projects for active group ──────────────────────── - let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]); - let filteredProjects = $derived( - PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId) - ); - - // ── Clone helpers ────────────────────────────────────────────── - function cloneCountForProject(projectId: string): number { - return PROJECTS.filter(p => p.cloneOf === projectId).length; - } - - function handleClone(projectId: string, branch: string) { - const source = PROJECTS.find(p => p.id === projectId); - if (!source) return; - const branchName = branch || `feature/clone-${Date.now()}`; - appRpc.request["project.clone"]({ projectId, branchName }).then((result) => { - if (result.ok && result.project) { - const cloneConfig = JSON.parse(result.project.config) as Project; - PROJECTS = [...PROJECTS, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }]; - } else { - console.error('[clone]', result.error); - } - }).catch(console.error); - } - - // ── Splash screen ───────────────────────────────────────────── - let appReady = $state(false); - - // ── Reactive state ───────────────────────────────────────────── - let settingsOpen = $state(false); - let paletteOpen = $state(false); - let drawerOpen = $state(false); - let searchOpen = $state(false); - let sessionStart = $state(Date.now()); - - let notifications = $state([ - { id: 1, message: 'Agent completed: wake scheduler implemented', type: 'success', time: '2m ago' }, - { id: 2, message: 'Context pressure: 78% on agent-orchestrator', type: 'warning', time: '5m ago' }, - { id: 3, message: 'PTY daemon connected', type: 'info', time: '12m ago' }, - ]); - - let notifCount = $derived(notifications.length); - - function clearNotifications() { - notifications = []; - drawerOpen = false; - } - - // ── setActiveGroup: fire-and-forget RPC ─────────────────────── - function setActiveGroup(id: string | undefined) { - if (!id) return; - // Fix #10: Track previous group for DOM mount limit - if (activeGroupId !== id) { - previousGroupId = activeGroupId; - } - activeGroupId = id; - // Fix #16: Persist active_group selection - appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error); - } - - // ── Window controls ──────────────────────────────────────────── - function handleClose() { appRpc.request["window.close"]({}).catch(console.error); } - function handleMaximize() { appRpc.request["window.maximize"]({}).catch(console.error); } - function handleMinimize() { appRpc.request["window.minimize"]({}).catch(console.error); } - - // ── Blink ────────────────────────────────────────────────────── + // ── Blink timer ───────────────────────────────────────────── let blinkVisible = $state(true); $effect(() => { const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500); return () => clearInterval(id); }); - // ── Session duration ─────────────────────────────────────────── + // ── Session duration ──────────────────────────────────────── let sessionDuration = $state('0m'); $effect(() => { function update() { @@ -228,9 +59,12 @@ return () => clearInterval(id); }); - // Fix #19: onDragStart/onDragMove/onDragEnd removed — no longer referenced from template + // ── Window controls ───────────────────────────────────────── + function handleClose() { appRpc.request["window.close"]({}).catch(console.error); } + function handleMaximize() { appRpc.request["window.maximize"]({}).catch(console.error); } + function handleMinimize() { appRpc.request["window.minimize"]({}).catch(console.error); } - // ── Window frame persistence (debounced 500ms) ───────────────── + // ── Window frame persistence (debounced 500ms) ────────────── let frameSaveTimer: ReturnType | null = null; function saveWindowFrame() { if (frameSaveTimer) clearTimeout(frameSaveTimer); @@ -244,11 +78,55 @@ }, 500); } - // ── Status bar aggregates ────────────────────────────────────── - let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0)); - let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0)); + // ── Wizard handler ────────────────────────────────────────── + function handleWizardCreated(result: WizardResult) { + addProjectFromWizard(result); + showWizard = false; + } - // ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ──── + async function confirmDeleteProject() { + if (!projectToDelete) return; + await deleteProject(projectToDelete); + projectToDelete = null; + } + + // ── Group add (local UI + store) ──────────────────────────── + async function handleAddGroup() { + const name = newGroupName.trim(); + if (!name) return; + await wsAddGroup(name); + showAddGroup = false; + newGroupName = ''; + } + + // ── Notification drawer ───────────────────────────────────── + function handleClearNotifications() { + clearNotifications(); + drawerOpen = false; + } + + // ── 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'); + }); + } + + // ── DEBUG overlay ─────────────────────────────────────────── const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug'); let debugLog = $state([]); @@ -279,86 +157,40 @@ }; }); - // ── 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'); - }); - } - - // ── i18n: keep lang and dir in sync ───────────────────── + // ── i18n: keep lang and dir in sync ────────────────── $effect(() => { document.documentElement.lang = getLocale(); document.documentElement.dir = getDir(); }); - // ── Init ─────────────────────────────────────────────────────── + // ── Init ──────────────────────────────────────────────────── onMount(() => { - // Wire agent toast callback setAgentToastFn(showToast); - - // Set up global error boundary setupErrorBoundary(); - // Fix #8: Load groups FIRST, then apply saved active_group. - // Other init tasks run in parallel. const initTasks = [ initI18n().catch(console.error), themeStore.initTheme(appRpc).catch(console.error), fontStore.initFonts(appRpc).catch(console.error), keybindingStore.init(appRpc).catch(console.error), - // Sequential: groups.list -> active_group (depends on groups being loaded) - appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => { - if (dbGroups.length > 0) groups = dbGroups; - // Now that groups are loaded, apply saved active_group - return 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), + loadGroupsFromDb().catch(console.error), + loadProjectsFromDb().catch(console.error), ]; - // Timeout: if init hangs for 10s, force ready anyway const timeout = new Promise(r => setTimeout(r, 10000)); Promise.race([Promise.allSettled(initTasks), timeout]).then(() => { appReady = true; - for (const p of PROJECTS) trackProject(p.id); + trackAllProjects(); }); keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; }); keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; }); - keybindingStore.on('group1', () => setActiveGroup(groups[0]?.id)); - keybindingStore.on('group2', () => setActiveGroup(groups[1]?.id)); - keybindingStore.on('group3', () => setActiveGroup(groups[2]?.id)); - keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id)); + keybindingStore.on('group1', () => setActiveGroup(getGroups()[0]?.id)); + keybindingStore.on('group2', () => setActiveGroup(getGroups()[1]?.id)); + keybindingStore.on('group3', () => setActiveGroup(getGroups()[2]?.id)); + keybindingStore.on('group4', () => setActiveGroup(getGroups()[3]?.id)); keybindingStore.on('minimize', () => handleMinimize()); - // Ctrl+Shift+F for search overlay function handleSearchShortcut(e: KeyboardEvent) { if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); @@ -367,7 +199,6 @@ } document.addEventListener('keydown', handleSearchShortcut); - // Fix #18: Wire CommandPalette events to action handlers function handlePaletteCommand(e: Event) { const detail = (e as CustomEvent).detail; switch (detail) { @@ -396,8 +227,8 @@ drawerOpen = false} /> @@ -413,10 +244,10 @@ @@ -482,15 +313,13 @@ - +
-
- - {#each PROJECTS as project (project.id)} - {#if mountedGroupIds.has(project.groupId ?? 'dev')} -
+
+ {#each getProjects() as project (project.id)} + {#if getMountedGroupIds().has(project.groupId ?? 'dev')} +
+
-

No projects in {activeGroup?.name ?? 'this group'}

+ style:display={getFilteredProjects().length === 0 ? 'flex' : 'none'}> +

No projects in {getActiveGroup()?.name ?? 'this group'}

@@ -522,16 +351,16 @@ showWizard = false} onCreated={handleWizardCreated} - groupId={activeGroupId} - groups={groups.map(g => ({ id: g.id, name: g.name }))} - existingNames={PROJECTS.map(p => p.name)} + groupId={getActiveGroupId()} + groups={getGroups().map(g => ({ id: g.id, name: g.name }))} + existingNames={getProjects().map(p => p.name)} />
{#if projectToDelete}
-

Delete project "{PROJECTS.find(p => p.id === projectToDelete)?.name}"?

+

Delete project "{getProjects().find(p => p.id === projectToDelete)?.name}"?

@@ -544,9 +373,9 @@ @@ -570,15 +399,14 @@ {#if DEBUG_ENABLED && debugLog.length > 0} -
{#each debugLog as line}
{line}
@@ -621,7 +449,6 @@ padding: 1rem 0; user-select: none; flex-shrink: 0; - /* NO -webkit-app-region — broken on WebKitGTK (captures all clicks in window) */ cursor: grab; } @@ -638,7 +465,6 @@ .sidebar-spacer { flex: 1; } - /* Numbered group button */ .group-btn { position: relative; width: 2.25rem; @@ -688,7 +514,6 @@ background: var(--ctp-red); } - /* Settings icon button */ .sidebar-icon { width: 2rem; height: 2rem; @@ -717,13 +542,12 @@ overflow: hidden; } - /* ── Project grid ─────────────────────────────────────────── */ .project-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 1fr; - grid-auto-rows: 1fr; /* equal rows that fill available height */ + grid-auto-rows: 1fr; gap: 0.5rem; padding: 0.5rem; background: var(--ctp-crust); @@ -782,7 +606,6 @@ transition: background 0.12s, color 0.12s; padding: 0; font-family: var(--ui-font-family); - /* no-drag not needed — all -webkit-app-region removed */ } .wc-btn:hover { background: var(--ctp-surface0); color: var(--ctp-text); } @@ -847,33 +670,10 @@ } .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); } - + /* ── Delete project overlay ──────────────────────────────────── */ .add-card-actions { display: flex; gap: 0.375rem; justify-content: flex-end; } - .add-cancel, .add-confirm, .delete-confirm { + .add-cancel, .delete-confirm { padding: 0.25rem 0.625rem; border-radius: 0.25rem; font-size: 0.75rem; @@ -883,8 +683,6 @@ .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; @@ -901,6 +699,4 @@ .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 */ diff --git a/ui-electrobun/src/mainview/NotifDrawer.svelte b/ui-electrobun/src/mainview/NotifDrawer.svelte index 09732a0..996b8a6 100644 --- a/ui-electrobun/src/mainview/NotifDrawer.svelte +++ b/ui-electrobun/src/mainview/NotifDrawer.svelte @@ -1,10 +1,5 @@ -
-

Defaults

- +
setCwd((e.target as HTMLInputElement).value)} />
+
-

Permission mode

-
- - - -
+
+ +
-

System prompt template

+
+
-

Providers

+
{#each PROVIDERS as prov} {@const state = providerState[prov.id]} @@ -106,7 +105,7 @@ {#if expandedProvider === prov.id}
@@ -132,11 +131,9 @@
{/each}
-
+ diff --git a/ui-electrobun/src/mainview/ui/Section.svelte b/ui-electrobun/src/mainview/ui/Section.svelte new file mode 100644 index 0000000..2ec8be4 --- /dev/null +++ b/ui-electrobun/src/mainview/ui/Section.svelte @@ -0,0 +1,40 @@ + + +
+ {#if heading} +

{heading}

+ {/if} + {@render children()} +
+ + diff --git a/ui-electrobun/src/mainview/ui/SegmentedControl.svelte b/ui-electrobun/src/mainview/ui/SegmentedControl.svelte new file mode 100644 index 0000000..05a237a --- /dev/null +++ b/ui-electrobun/src/mainview/ui/SegmentedControl.svelte @@ -0,0 +1,71 @@ + + +
+ {#each options as opt} + + {/each} +
+ + diff --git a/ui-electrobun/src/mainview/ui/SliderInput.svelte b/ui-electrobun/src/mainview/ui/SliderInput.svelte new file mode 100644 index 0000000..3ad993d --- /dev/null +++ b/ui-electrobun/src/mainview/ui/SliderInput.svelte @@ -0,0 +1,82 @@ + + +
+ + + {displayValue} +
+ + diff --git a/ui-electrobun/src/mainview/ui/StatusDot.svelte b/ui-electrobun/src/mainview/ui/StatusDot.svelte new file mode 100644 index 0000000..0295a58 --- /dev/null +++ b/ui-electrobun/src/mainview/ui/StatusDot.svelte @@ -0,0 +1,39 @@ + + + + + diff --git a/ui-electrobun/src/mainview/workspace-store.svelte.ts b/ui-electrobun/src/mainview/workspace-store.svelte.ts index ae167be..242231d 100644 --- a/ui-electrobun/src/mainview/workspace-store.svelte.ts +++ b/ui-electrobun/src/mainview/workspace-store.svelte.ts @@ -1,8 +1,8 @@ /** - * Workspace store — project/group CRUD extracted from App.svelte (Fix #16). + * Workspace store — project/group CRUD, aggregates, and DB loading. * - * Manages PROJECTS and groups state, persists via RPC. - * App.svelte imports and calls these methods instead of inline CRUD logic. + * Single source of truth for projects and groups state. + * App.svelte is a thin view layer that reads from this store. */ import { appRpc } from './rpc.ts'; @@ -41,6 +41,13 @@ export interface Group { hasNew?: boolean; } +export interface WizardResult { + id: string; name: string; cwd: string; provider?: string; model?: string; + systemPrompt?: string; autoStart?: boolean; groupId?: string; + useWorktrees?: boolean; shell?: string; icon?: string; color?: string; + modelConfig?: Record; +} + // ── Accent colors ───────────────────────────────────────────────────────── const ACCENTS = [ @@ -58,11 +65,23 @@ let groups = $state([ let activeGroupId = $state('dev'); let previousGroupId = $state(null); -// ── Derived ─────────────────────────────────────────────────────────────── +// ── Derived (exposed as getter functions — modules cannot export $derived) ── -export const mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])])); -export const activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]); -export const filteredProjects = $derived(projects.filter(p => (p.groupId ?? 'dev') === activeGroupId)); +export function getMountedGroupIds(): Set { + return new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]); +} +export function getActiveGroup(): Group { + return groups.find(g => g.id === activeGroupId) ?? groups[0]; +} +export function getFilteredProjects(): Project[] { + return projects.filter(p => (p.groupId ?? 'dev') === activeGroupId); +} +export function getTotalCostDerived(): number { + return projects.reduce((s, p) => s + p.costUsd, 0); +} +export function getTotalTokensDerived(): number { + return projects.reduce((s, p) => s + p.tokens, 0); +} // ── Getters/setters for state ───────────────────────────────────────────── @@ -97,6 +116,40 @@ export async function addProject(name: string, cwd: string): Promise { }).catch(console.error); } +/** Add a project from the ProjectWizard result. */ +export function addProjectFromWizard(result: WizardResult): void { + const accent = result.color || ACCENTS[projects.length % ACCENTS.length]; + const gid = result.groupId ?? activeGroupId; + const project: Project = { + id: result.id, + name: result.name, + cwd: result.cwd, + accent, + status: 'idle', + costUsd: 0, + tokens: 0, + messages: [], + provider: result.provider ?? 'claude', + model: result.model, + groupId: gid, + }; + projects = [...projects, project]; + trackProject(project.id); + + // Persist full config including shell, icon, modelConfig etc. + const persistConfig = { + id: result.id, name: result.name, cwd: result.cwd, accent, + provider: result.provider ?? 'claude', model: result.model, + groupId: gid, shell: result.shell, icon: result.icon, + useWorktrees: result.useWorktrees, systemPrompt: result.systemPrompt, + autoStart: result.autoStart, modelConfig: result.modelConfig, + }; + appRpc.request['settings.setProject']({ + id: project.id, + config: JSON.stringify(persistConfig), + }).catch(console.error); +} + export async function deleteProject(projectId: string): Promise { projects = projects.filter(p => p.id !== projectId); await appRpc.request['settings.deleteProject']({ id: projectId }).catch(console.error); @@ -131,7 +184,48 @@ export async function addGroup(name: string): Promise { await appRpc.request['groups.create']({ id, name: name.trim(), icon: group.icon, position }).catch(console.error); } -// ── Aggregates ──────────────────────────────────────────────────────────── +// ── DB loading ──────────────────────────────────────────────────────────── + +/** Load groups from DB, then apply saved active_group. */ +export async function loadGroupsFromDb(): Promise { + try { + const { groups: dbGroups } = await appRpc.request["groups.list"]({}) as { groups: Group[] }; + if (dbGroups.length > 0) groups = dbGroups; + const { value } = await appRpc.request["settings.get"]({ key: 'active_group' }) as { value: string | null }; + if (value && groups.some(g => g.id === value)) activeGroupId = value; + } catch (err) { console.error('[workspace] loadGroups error:', err); } +} + +/** Load projects from DB. */ +export async function loadProjectsFromDb(): Promise { + try { + const { projects: dbProjects } = await appRpc.request["settings.getProjects"]({}) as { + projects: Array<{ id: string; config: string }>; + }; + if (dbProjects.length > 0) { + const loaded: Project[] = dbProjects.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 (err) { console.error('[workspace] loadProjects error:', err); } +} + +/** Track all loaded projects in health store. */ +export function trackAllProjects(): void { + for (const p of projects) trackProject(p.id); +} + +// ── Aggregates (legacy function API — prefer derived exports) ───────────── export function getTotalCost(): number { return projects.reduce((s, p) => s + p.costUsd, 0); } export function getTotalTokens(): number { return projects.reduce((s, p) => s + p.tokens, 0); }