diff --git a/.claude/rules/58-state-tree.md b/.claude/rules/58-state-tree.md new file mode 100644 index 0000000..d60d6ba --- /dev/null +++ b/.claude/rules/58-state-tree.md @@ -0,0 +1,104 @@ +# State Tree Architecture (MANDATORY) + +All application state lives in a single hierarchical state tree. Components are pure renderers — they read from the tree via getter functions and dispatch actions. No component-local `$state` except DOM refs (`bind:this`). + +## Tree Structure + +``` +AppState (root) → app-state.svelte.ts +├── theme, locale, appReady +├── ui: settings, palette, search, wizard, notifications +├── workspace +│ ├── groups[], activeGroupId +│ └── projects[] (per project ↓) +│ ├── agent: session, messages, status, cost +│ ├── terminals: tabs[], collapsed, activeTabId +│ ├── files: tree, selectedPath, content, dirty +│ ├── comms: channels[], messages[], mode +│ ├── tasks: items[], dragTarget +│ └── tab: activeTab, activatedTabs +└── health: per-project trackers, attention queue +``` + +## Rules + +### 1. State ownership follows the tree + +Each node owns its children. A node NEVER reads siblings or ancestors directly — it receives what it needs via getter functions scoped to its level. + +```typescript +// RIGHT — scoped access through parent +function getProjectAgent(projectId: string) { + return getProjectState(projectId).agent; +} + +// WRONG — cross-branch reach +function getAgentForComms() { + return agentStore.getSession(commsStore.activeAgentId); +} +``` + +### 2. Actions bubble up, state flows down + +Components dispatch actions to the store that owns the state. State changes propagate downward via Svelte's reactive reads. + +```typescript +// Component dispatches action +onclick={() => projectActions.setActiveTab(projectId, 'files')} + +// Store mutates owned state +function setActiveTab(projectId: string, tab: ProjectTab) { + getProjectState(projectId).tab.activeTab = tab; +} +``` + +### 3. One store file per tree level + +| Level | File | Owns | +|-------|------|------| +| Root | `app-state.svelte.ts` | theme, locale, appReady | +| UI | `ui-store.svelte.ts` | drawers, overlays, modals | +| Workspace | `workspace-store.svelte.ts` | groups, projects list | +| Project | `project-state.svelte.ts` | per-project sub-states | +| Health | `health-store.svelte.ts` | trackers, attention | + +### 4. Per-project state is a typed object, not scattered stores + +```typescript +interface ProjectState { + agent: AgentState; + terminals: TerminalState; + files: FileState; + comms: CommsState; + tasks: TaskState; + tab: TabState; +} + +// Accessed via: +function getProjectState(id: string): ProjectState +``` + +### 5. No `$state` in components + +Components use ONLY: +- `$props()` for parent-passed values +- Getter functions from stores (e.g., `getProjectAgent(id)`) +- Action functions from stores (e.g., `projectActions.sendMessage(id, text)`) +- `bind:this` for DOM element references (the sole exception) +- Ephemeral interaction state (hover, drag coordinates) via plain `let` — NOT `$state` + +### 6. No `$derived` with new objects (Rule 57) + +All computed values are plain functions. See Rule 57 for details. + +### 7. Initialization in `onMount`, never `$effect` + +All async data loading, timer setup, and event listener registration goes in `onMount`. See Rule 57. + +## Adding New State + +1. Identify which tree node owns it +2. Add the field to that node's interface +3. Add getter + action functions in the store file +4. Components read via getter, mutate via action +5. NEVER add `$state` to a `.svelte` component file