agent-orchestrator/.claude/rules/58-state-tree.md

3.4 KiB

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.

// 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.

// 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

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