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
|
|
@ -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<Project[]>([]);
|
||||
|
||||
// ── Groups state (loaded from SQLite) ────────────────────────────
|
||||
let groups = $state<Group[]>([
|
||||
{ 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<string | null>(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<string, unknown>;
|
||||
}) {
|
||||
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<string | null>(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<Notification[]>([
|
||||
{ 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<typeof setTimeout> | 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<string[]>([]);
|
||||
|
||||
|
|
@ -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 <html> lang and dir in sync ─────────────────────
|
||||
// ── i18n: keep <html> 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 @@
|
|||
<ToastContainer bind:this={toastRef} />
|
||||
<NotifDrawer
|
||||
open={drawerOpen}
|
||||
{notifications}
|
||||
onClear={clearNotifications}
|
||||
notifications={getNotifications()}
|
||||
onClear={handleClearNotifications}
|
||||
onClose={() => drawerOpen = false}
|
||||
/>
|
||||
|
||||
|
|
@ -413,10 +244,10 @@
|
|||
|
||||
<!-- Group icons — numbered circles -->
|
||||
<div class="sidebar-groups" role="list" aria-label="Project groups">
|
||||
{#each groups as group, i}
|
||||
{#each getGroups() as group, i}
|
||||
<button
|
||||
class="group-btn"
|
||||
class:active={activeGroupId === group.id}
|
||||
class:active={getActiveGroupId() === group.id}
|
||||
onclick={() => setActiveGroup(group.id)}
|
||||
aria-label="{group.name} (Ctrl+{i + 1})"
|
||||
title="{group.name} (Ctrl+{i + 1})"
|
||||
|
|
@ -446,7 +277,7 @@
|
|||
type="text"
|
||||
placeholder="Group name"
|
||||
bind:value={newGroupName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addGroup(); if (e.key === 'Escape') showAddGroup = false; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleAddGroup(); if (e.key === 'Escape') showAddGroup = false; }}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -482,15 +313,13 @@
|
|||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Main workspace — no drag region (WebKitGTK captures clicks incorrectly) -->
|
||||
<!-- Main workspace -->
|
||||
<main class="workspace">
|
||||
<!-- Project grid -->
|
||||
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
|
||||
<!-- Fix #10: Only mount projects in active + previous group (max 2 groups).
|
||||
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
|
||||
{#each PROJECTS as project (project.id)}
|
||||
{#if mountedGroupIds.has(project.groupId ?? 'dev')}
|
||||
<div role="listitem" style:display={(project.groupId ?? 'dev') === activeGroupId ? 'flex' : 'none'}>
|
||||
<div class="project-grid" role="list" aria-label="{getActiveGroup()?.name ?? 'Projects'} projects">
|
||||
{#each getProjects() as project (project.id)}
|
||||
{#if getMountedGroupIds().has(project.groupId ?? 'dev')}
|
||||
<div role="listitem" style:display={(project.groupId ?? 'dev') === getActiveGroupId() ? 'flex' : 'none'}>
|
||||
<ProjectCard
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
|
|
@ -511,10 +340,10 @@
|
|||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Empty group — always in DOM, visibility toggled -->
|
||||
<!-- Empty group -->
|
||||
<div class="empty-group" role="listitem"
|
||||
style:display={filteredProjects.length === 0 ? 'flex' : 'none'}>
|
||||
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
|
||||
style:display={getFilteredProjects().length === 0 ? 'flex' : 'none'}>
|
||||
<p class="empty-group-text">No projects in {getActiveGroup()?.name ?? 'this group'}</p>
|
||||
</div>
|
||||
|
||||
<!-- Project wizard overlay (display toggle) -->
|
||||
|
|
@ -522,16 +351,16 @@
|
|||
<ProjectWizard
|
||||
onClose={() => 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)}
|
||||
/>
|
||||
</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>
|
||||
<p class="delete-text">Delete project "{getProjects().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>
|
||||
|
|
@ -544,9 +373,9 @@
|
|||
<!-- Right sidebar: window controls + notification bell -->
|
||||
<aside class="right-bar" aria-label="Window controls and notifications">
|
||||
<div class="window-controls-vertical" role="toolbar" aria-label="Window controls">
|
||||
<button class="wc-btn close-btn" onclick={handleClose} aria-label="Close window" title="Close">✕</button>
|
||||
<button class="wc-btn" onclick={handleMaximize} aria-label="Maximize window" title="Maximize">□</button>
|
||||
<button class="wc-btn" onclick={handleMinimize} aria-label="Minimize window" title="Minimize">─</button>
|
||||
<button class="wc-btn close-btn" onclick={handleClose} aria-label="Close window" title="Close">✕</button>
|
||||
<button class="wc-btn" onclick={handleMaximize} aria-label="Maximize window" title="Maximize">□</button>
|
||||
<button class="wc-btn" onclick={handleMinimize} aria-label="Minimize window" title="Minimize">─</button>
|
||||
</div>
|
||||
|
||||
<div class="right-spacer"></div>
|
||||
|
|
@ -555,14 +384,14 @@
|
|||
class="right-icon notif-btn"
|
||||
class:active={drawerOpen}
|
||||
onclick={() => drawerOpen = !drawerOpen}
|
||||
aria-label="{notifCount > 0 ? `${notifCount} notifications` : 'Notifications'}"
|
||||
aria-label="{getNotifCount() > 0 ? `${getNotifCount()} notifications` : 'Notifications'}"
|
||||
title="Notifications"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
{#if notifCount > 0}
|
||||
<span class="right-badge" aria-hidden="true">{notifCount}</span>
|
||||
{#if getNotifCount() > 0}
|
||||
<span class="right-badge" aria-hidden="true">{getNotifCount()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</aside>
|
||||
|
|
@ -570,15 +399,14 @@
|
|||
|
||||
<!-- Status bar (health-backed) -->
|
||||
<StatusBar
|
||||
projectCount={filteredProjects.length}
|
||||
{totalTokens}
|
||||
totalCost={totalCost}
|
||||
projectCount={getFilteredProjects().length}
|
||||
totalTokens={getTotalTokensDerived()}
|
||||
totalCost={getTotalCostDerived()}
|
||||
{sessionDuration}
|
||||
groupName={activeGroup?.name ?? ''}
|
||||
groupName={getActiveGroup()?.name ?? ''}
|
||||
/>
|
||||
|
||||
{#if DEBUG_ENABLED && debugLog.length > 0}
|
||||
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
|
||||
<div style="position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-family:monospace;font-size:10px;padding:4px 8px;z-index:9999;max-height:100px;overflow-y:auto;pointer-events:none;">
|
||||
{#each debugLog as line}
|
||||
<div>{line}</div>
|
||||
|
|
@ -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 */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
<script lang="ts">
|
||||
export interface Notification {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'warning' | 'info' | 'error';
|
||||
time: string;
|
||||
}
|
||||
import type { Notification } from './notifications-store.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import TaskBoardTab from './TaskBoardTab.svelte';
|
||||
import DocsTab from './DocsTab.svelte';
|
||||
import SshTab from './SshTab.svelte';
|
||||
import StatusDot from './ui/StatusDot.svelte';
|
||||
import {
|
||||
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
||||
loadLastSession,
|
||||
|
|
@ -162,12 +163,7 @@
|
|||
<!-- Header -->
|
||||
<header class="project-header">
|
||||
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
|
||||
<div
|
||||
class="status-dot {agentStatus}"
|
||||
class:blink-off={agentStatus === 'running' && !blinkVisible}
|
||||
role="img"
|
||||
aria-label={agentStatus}
|
||||
></div>
|
||||
<StatusDot status={agentStatus} blinkOff={agentStatus === 'running' && !blinkVisible} />
|
||||
</div>
|
||||
|
||||
<span class="project-name" title={name}>{name}</span>
|
||||
|
|
@ -476,19 +472,6 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.status-dot.running { background: var(--ctp-green); }
|
||||
.status-dot.idle { background: var(--ctp-overlay1); }
|
||||
.status-dot.done { background: var(--ctp-green); }
|
||||
.status-dot.error { background: var(--ctp-red); }
|
||||
.status-dot.stalled { background: var(--ctp-peach); }
|
||||
.status-dot.blink-off { opacity: 0.3; }
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
|
|
|
|||
49
ui-electrobun/src/mainview/notifications-store.svelte.ts
Normal file
49
ui-electrobun/src/mainview/notifications-store.svelte.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Notifications store — owns notification list and toast integration.
|
||||
*
|
||||
* Extracted from App.svelte inline state (Phase 2).
|
||||
*/
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'warning' | 'info' | 'error';
|
||||
time: string;
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let notifications = $state<Notification[]>([
|
||||
{ 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 nextId = $state(100);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getNotifications(): Notification[] {
|
||||
return notifications;
|
||||
}
|
||||
|
||||
export function getNotifCount(): number {
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
export function addNotification(message: string, type: Notification['type'] = 'info'): void {
|
||||
const now = new Date();
|
||||
const time = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const id = nextId++;
|
||||
notifications = [{ id, message, type, time }, ...notifications].slice(0, 100);
|
||||
}
|
||||
|
||||
export function removeNotification(id: number): void {
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
}
|
||||
|
||||
export function clearAll(): void {
|
||||
notifications = [];
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
78
ui-electrobun/src/mainview/ui/IconButton.svelte
Normal file
78
ui-electrobun/src/mainview/ui/IconButton.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
label: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Button size: 'sm' = 1.5rem, 'md' = 2rem (default), 'lg' = 2.25rem */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
onclick,
|
||||
label,
|
||||
title,
|
||||
active = false,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
children,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="icon-btn size-{size}"
|
||||
class:active
|
||||
{disabled}
|
||||
{onclick}
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn :global(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.size-sm { width: 1.5rem; height: 1.5rem; }
|
||||
.size-sm :global(svg) { width: 0.875rem; height: 0.875rem; }
|
||||
|
||||
.size-md { width: 2rem; height: 2rem; }
|
||||
|
||||
.size-lg { width: 2.25rem; height: 2.25rem; }
|
||||
.size-lg :global(svg) { width: 1.125rem; height: 1.125rem; }
|
||||
</style>
|
||||
40
ui-electrobun/src/mainview/ui/Section.svelte
Normal file
40
ui-electrobun/src/mainview/ui/Section.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
heading?: string;
|
||||
/** Extra top margin (for non-first sections). */
|
||||
spaced?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { heading, spaced = false, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="section" class:spaced>
|
||||
{#if heading}
|
||||
<h3 class="section-heading">{heading}</h3>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section.spaced {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin: 0.125rem 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
</style>
|
||||
71
ui-electrobun/src/mainview/ui/SegmentedControl.svelte
Normal file
71
ui-electrobun/src/mainview/ui/SegmentedControl.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option[];
|
||||
selected: string;
|
||||
onSelect: (value: string) => void;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
let { options, selected, onSelect, size = 'sm' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="seg" class:seg-md={size === 'md'} role="radiogroup">
|
||||
{#each options as opt}
|
||||
<button
|
||||
class="seg-btn"
|
||||
class:active={selected === opt.value}
|
||||
role="radio"
|
||||
aria-checked={selected === opt.value}
|
||||
onclick={() => onSelect(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.seg {
|
||||
display: flex;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.seg-btn {
|
||||
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);
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.seg-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.seg-btn:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.seg-btn.active {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0));
|
||||
color: var(--ctp-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seg-md .seg-btn {
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
</style>
|
||||
82
ui-electrobun/src/mainview/ui/SliderInput.svelte
Normal file
82
ui-electrobun/src/mainview/ui/SliderInput.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
id?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
/** Format display value. Defaults to String(value). */
|
||||
format?: (value: number) => string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
id,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
onChange,
|
||||
format,
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
let displayValue = $derived(format ? format(value) : String(value));
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
if (!Number.isNaN(v)) onChange(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="slider-row">
|
||||
<label class="slider-label" for={id}>{label}</label>
|
||||
<input
|
||||
{id}
|
||||
type="range"
|
||||
class="slider-input"
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{value}
|
||||
{disabled}
|
||||
oninput={handleInput}
|
||||
/>
|
||||
<span class="slider-val">{displayValue}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
flex: 1;
|
||||
accent-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.slider-input:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider-val {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-text);
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
39
ui-electrobun/src/mainview/ui/StatusDot.svelte
Normal file
39
ui-electrobun/src/mainview/ui/StatusDot.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
type DotStatus = 'running' | 'idle' | 'done' | 'error' | 'stalled' | 'inactive';
|
||||
|
||||
interface Props {
|
||||
status: DotStatus;
|
||||
/** Hide on blink phase (for running pulse). */
|
||||
blinkOff?: boolean;
|
||||
/** Dot diameter in rem. Default 0.625. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { status, blinkOff = false, size = 0.625 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="status-dot {status}"
|
||||
class:blink-off={status === 'running' && blinkOff}
|
||||
style="width: {size}rem; height: {size}rem;"
|
||||
role="img"
|
||||
aria-label={status}
|
||||
></span>
|
||||
|
||||
<style>
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-overlay0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.running { background: var(--ctp-green); }
|
||||
.status-dot.idle { background: var(--ctp-overlay1); }
|
||||
.status-dot.done { background: var(--ctp-green); }
|
||||
.status-dot.error { background: var(--ctp-red); }
|
||||
.status-dot.stalled { background: var(--ctp-peach); }
|
||||
.status-dot.inactive { background: var(--ctp-overlay0); }
|
||||
|
||||
.status-dot.blink-off { opacity: 0.3; }
|
||||
</style>
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Accent colors ─────────────────────────────────────────────────────────
|
||||
|
||||
const ACCENTS = [
|
||||
|
|
@ -58,11 +65,23 @@ let groups = $state<Group[]>([
|
|||
let activeGroupId = $state('dev');
|
||||
let previousGroupId = $state<string | null>(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<string> {
|
||||
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<void> {
|
|||
}).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<void> {
|
||||
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<void> {
|
|||
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<void> {
|
||||
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<void> {
|
||||
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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue