feat(electrobun): hierarchical state tree (Rule 58)

New files:
- project-state.types.ts: all per-project state interfaces
- project-state.svelte.ts: unified per-project state with version counter
- app-state.svelte.ts: root facade re-exporting all stores as appState.*

Rewired components (no more local $state):
- ProjectCard: reads via appState.agent.* and appState.project.tab.*
- TerminalTabs: state in appState.project.terminals.*
- FileBrowser: state in appState.project.files.*
- CommsTab: state in appState.project.comms.*
- TaskBoardTab: state in appState.project.tasks.*

All follow Rule 57 (no $derived with new objects) and Rule 58
(state tree architecture, components are pure renderers).
This commit is contained in:
Hibryda 2026-03-24 15:20:09 +01:00
parent ae4c07c160
commit 162b5417e4
9 changed files with 870 additions and 400 deletions

View file

@ -8,15 +8,8 @@
import DocsTab from './DocsTab.svelte';
import SshTab from './SshTab.svelte';
import StatusDot from './ui/StatusDot.svelte';
import {
startAgent, stopAgent, sendPrompt, getSession, hasSession,
loadLastSession,
type AgentStatus, type AgentMessage,
} from './agent-store.svelte.ts';
import {
getActiveTab, setActiveTab, isTabActivated,
ALL_TABS, type ProjectTab,
} from './project-tabs-store.svelte.ts';
import { appState, type AgentStatus, type AgentMessage, type ProjectTab } from './app-state.svelte.ts';
import { ALL_TABS } from './project-tabs-store.svelte.ts';
interface Props {
id: string;
@ -61,9 +54,7 @@
// ── Agent session (reactive from store) ──────────────────────────
const EMPTY_MESSAGES: AgentMessage[] = [];
// ALL agent session state as plain getter functions — NO $derived.
// $derived + store getters that return new refs = infinite loops in Svelte 5.
function getAgentSession() { return getSession(id); }
function getAgentSession() { return appState.agent.getSession(id); }
function getAgentStatus(): AgentStatus { return getAgentSession()?.status ?? 'idle'; }
function getAgentMessages(): AgentMessage[] { return getAgentSession()?.messages ?? []; }
function getAgentCost(): number { return getAgentSession()?.costUsd ?? 0; }
@ -107,7 +98,7 @@
return getAgentMessages().length > 0 ? getAgentMessages() : EMPTY_MESSAGES;
}
// ── Clone dialog state ──────────────────────────────────────────
// ── Clone dialog state (needs $state for template bindings) ─────
let showCloneDialog = $state(false);
let cloneBranchName = $state('');
let cloneError = $state('');
@ -129,33 +120,31 @@
showCloneDialog = false;
}
// Derived from project-tabs-store for reactive reads
function getCurrentTab() { return getActiveTab(id); }
// Tab state from project state tree
function getCurrentTab() { return appState.project.tab.getActiveTab(id); }
// ── Load last session on mount (once, not reactive) ─────────────────
import { onMount } from 'svelte';
onMount(() => { loadLastSession(id); });
onMount(() => { appState.agent.loadLastSession(id); });
function setTab(tab: ProjectTab) {
setActiveTab(id, tab);
appState.project.tab.setActiveTab(id, tab);
}
function handleSend(text: string) {
if (hasSession(id)) {
// Session exists — send follow-up prompt
sendPrompt(id, text).catch((err) => {
if (appState.agent.hasSession(id)) {
appState.agent.sendPrompt(id, text).catch((err) => {
console.error('[agent.prompt] error:', err);
});
} else {
// No session — start a new agent
startAgent(id, provider, text, { cwd, model }).catch((err) => {
appState.agent.startAgent(id, provider, text, { cwd, model }).catch((err) => {
console.error('[agent.start] error:', err);
});
}
}
function handleStop() {
stopAgent(id).catch((err) => {
appState.agent.stopAgent(id).catch((err) => {
console.error('[agent.stop] error:', err);
});
}
@ -373,7 +362,7 @@
role="tabpanel"
aria-label="Files"
>
<FileBrowser {cwd} />
<FileBrowser {cwd} projectId={id} />
</div>
<!-- SSH tab -->
@ -409,7 +398,7 @@
role="tabpanel"
aria-label="Comms"
>
<CommsTab {groupId} agentId={id} />
<CommsTab {groupId} projectId={id} agentId={id} />
</div>
<!-- Tasks tab (kanban board) -->
@ -421,7 +410,7 @@
role="tabpanel"
aria-label="Tasks"
>
<TaskBoardTab {groupId} agentId={id} />
<TaskBoardTab {groupId} projectId={id} agentId={id} />
</div>
</div>
</article>