# spacesh M2 — design spec

> Slice: **M2 — layouts & workspaces** (split tree, LayoutEngine, resize, disk persistence, multi-workspace sidebar with UX-metadata, presets, wizard).
> Builds on M0+M1 (shipped). Base spec: `DOCS/MAIN.md` §8. Prior slice spec: `DOCS/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md`.
> Date: 2026-06-09.

---

## 1. Scope

**In (decided in brainstorm):**
- **Split tree: n-ary** (`Split { orient, ratios, children[] }` + `Leaf { surface_id }`). Maps directly onto presets (3, 4, 2×3, 2×4); resize = editing one node's `ratios`.
- **Daemon owns the layout tree** (single source of truth, per `DOCS/MAIN.md` §2). GUI renders it and sends commands; CLI parity preserved. No GUI-local layout state.
- **Disk persistence: single `~/.spacesh/state.json`** behind a `StateStore` trait, atomic write (temp+fsync+rename), debounced ~500ms. SQLite swap is a later, localized change.
- **Cold daemon start restores structure** from `state.json`; panels come back **`stopped`** (no PTY) unless `autostart` is set (per-surface flag or global `[daemon] autostart_surfaces`, default off → restart on demand).
- **UX-metadata (§8.5):** colored workspace groups, "не забыть" unread flag, custom order (drag-reorder of workspaces and groups).
- **10 layout presets** (§8.2) + `apply_preset`. **Wizard** (folder → preset → agent-per-slot → `apply_preset`).
- New surface lifecycle state **`stopped`** (in tree, no actor) vs **`running`** (has actor). `exit` now transitions `running`→`stopped` (formalizes M1's "stays visible").

**Out (later slices):**
- Status detection / rings colored by agent state (`set_state`, hooks, OSC 133) — **M3**. The sidebar/panel status ring is a placeholder in M2 (aggregate running/stopped only).
- Auto-unread driven by agent events — M3 (M2 unread is set on creation / manually, cleared on visit).
- Zoom-panel, scrollback search, CodeMirror diff view, external notifications — **M5**. Remote — **M6**. Auth/account — separate milestone.

---

## 2. Crate & file changes

`spacesh-core` grows from a flat panel set to a layout tree (still I/O-free, unit-testable):

```
Workspace { id: WorkspaceId, path, name, group_id: Option<GroupId>, order: u32,
            unread: bool, layout: LayoutNode, surfaces: Map<SurfaceId, SurfaceSpec> }

enum LayoutNode {
  Leaf  { surface_id: SurfaceId },
  Split { orient: Orient /* H | V */, ratios: Vec<f32>, children: Vec<LayoutNode> },
}

SurfaceSpec { command: String, args: Vec<String>, cwd: PathBuf,
              agent_label: Option<String>, cols: u16, rows: u16, autostart: bool }

Group { id: GroupId, name: String, color: String, order: u32 }
```

| File (new/changed) | Responsibility |
|---|---|
| `crates/spacesh-core/src/layout.rs` (new) | `LayoutNode`, `Orient`; ops: `insert_split`, `remove_leaf` (collapse + single-child promote), `set_ratios` (normalize + clamp), `move_leaf`, `find_path`/addressing; the 10 preset generators. Pure, serde. |
| `crates/spacesh-core/src/workspace.rs` (new) | `Workspace`, `Group`, `SurfaceSpec`, `WorkspaceId`/`GroupId` newtypes (or reuse proto ids), meta mutations. Pure, serde. |
| `crates/spacesh-core/src/lib.rs` | re-export layout + workspace. |
| `crates/spaceshd/src/registry.rs` | own `Vec<Workspace>` + `Vec<Group>` (structure) over the M0+M1 actor map (live surfaces); track `running`/`stopped` per surface; dispatch tree ops to `spacesh-core`. |
| `crates/spaceshd/src/state_store.rs` (new) | `StateStore` trait + `JsonStateStore` (atomic temp+rename, corrupt-file backup). |
| `crates/spaceshd/src/persist.rs` (new) | debounce scheduler: dirty signal → ~500ms timer → one `save`. |
| `crates/spaceshd/src/server.rs` | dispatch the new commands; emit the new events; restore on cold start. |
| `crates/spaceshd/src/surface.rs` | support `stopped` (actor absent) + `restart` from `SurfaceSpec`. |
| `app/src/LayoutEngine.tsx` (new) | recursive split-tree render, splitter drag → `set_ratios`, stopped-panel restart overlay, geometry-based focus hotkeys. |
| `app/src/Sidebar.tsx` (new) | groups + workspaces, drag-reorder, unread, counts, context menu. |
| `app/src/PresetPicker.tsx` (new) | 10 preset thumbnails (used by toolbar + wizard). |
| `app/src/Wizard.tsx` (new) | folder → preset → agent-per-slot → `apply_preset`. |
| `app/src/App.tsx`, `socketBridge.ts` | wire new commands/events; subscribe `layout_changed`/`workspace_changed`/`groups_changed`/`surface_restarted`. |

`SurfaceSpec` carries everything to recreate a panel, so cold start restores structure without live processes.

---

## 3. Protocol additions

Layered on the M0+M1 subset; same envelope (`req`/`res`/`evt`, length-prefixed JSON).

### New commands

| Command | Args | Res | Purpose |
|---|---|---|---|
| `split_surface` | `{ surface_id, dir: "right"\|"down", command?, args? }` | `{ surface_id }` | split a panel, create the neighbor (running) |
| `close` (extended) | `{ surface_id }` | `{}` | kill PTY + **collapse** the tree node (parent ratios recomputed, single-child promoted) |
| `set_ratios` | `{ workspace_id, node_path: [u32], ratios: [f32] }` | `{}` | resize: one split node's ratios; node addressed by child-index path from root |
| `move_surface` | `{ surface_id, target_surface_id, edge: "left"\|"right"\|"top"\|"bottom" }` | `{}` | drag a panel to a new tree position |
| `apply_preset` | `{ workspace_id, preset_id, slots: [{ command?, args? }] }` | `{ surface_ids: [...] }` | build the preset tree + spawn panels per slot |
| `restart_surface` | `{ surface_id }` | `{}` | re-spawn PTY from `SurfaceSpec` for a `stopped` panel |
| `close_workspace` | `{ workspace_id }` | `{}` | close a workspace (live-panel confirmation is GUI-side) |
| `set_workspace_meta` | `{ workspace_id, name?, group_id?, unread?, order? }` | `{}` | name / group / unread / order |
| `create_group` | `{ name, color }` | `{ group_id }` | new colored group |
| `set_group` | `{ group_id, name?, color?, order? }` | `{}` | edit group |
| `delete_group` | `{ group_id }` | `{}` | delete group (members become ungrouped) |

### New events (push, multi-client sync)

| Event | Data | Purpose |
|---|---|---|
| `layout_changed` | `{ workspace_id, layout }` | tree changed (split/close/move/ratios/preset) |
| `workspace_changed` | `{ workspace }` | workspace meta changed or workspace created |
| `workspace_closed` | `{ workspace_id }` | workspace closed |
| `groups_changed` | `{ groups: [...] }` | groups created/edited/reordered/deleted |
| `surface_restarted` | `{ surface_id }` | a `stopped` panel is `running` again |

### Changed

`status` now returns workspaces with full `layout` tree, `groups[]`, and per-surface `running`/`stopped` + `SurfaceSpec`. `surface_created` / `surface_closed` retained. **`exit` semantics:** the panel is kept and transitions `running`→`stopped` (no longer removed); the tree is unchanged.

**Addressing:** leaves by `surface_id`; split nodes (for `set_ratios`) by `node_path` (vec of child indices from root). `move_surface` uses target `surface_id` + `edge`.

---

## 4. Persistence

`StateStore` trait isolates the backend (SQLite swap later stays localized):

```rust
trait StateStore {
    fn load(&self) -> Result<PersistState>;
    fn save(&self, state: &PersistState) -> Result<()>;  // atomic
}
```

`~/.spacesh/state.json` — snapshot of structure (NOT live processes):

```jsonc
{
  "version": 1,
  "groups": [{ "id": "g_1", "name": "production", "color": "#F4544E", "order": 0 }],
  "workspaces": [{
    "id": "w_1", "path": "~/infra-platform", "name": "infra-platform",
    "group_id": "g_1", "order": 0, "unread": false,
    "layout": { "split": { "orient": "v", "ratios": [0.5, 0.5], "children": [
      { "leaf": { "surface_id": "s_1" } },
      { "split": { "orient": "h", "ratios": [0.5, 0.5], "children": [
        { "leaf": { "surface_id": "s_2" } }, { "leaf": { "surface_id": "s_3" } } ] } } ] } },
    "surfaces": {
      "s_1": { "command": "claude", "args": [], "cwd": "~/infra-platform",
               "agent_label": "claude", "cols": 80, "rows": 24, "autostart": false }
    }
  }]
}
```

- **Write:** atomic (`state.json.tmp` → fsync → rename), **debounced ~500ms** after any structural/meta change; bursts coalesce into one `save` (scheduler in `persist.rs`: dirty channel → timer → save).
- **Cold start** (no live sessions): `load` → restore groups/workspaces/trees/`SurfaceSpec`. All panels `stopped`. If a surface's `autostart == true` or global `[daemon] autostart_surfaces` is set → immediately `restart_surface`. Default → `stopped`.
- **`stopped` lifecycle:** panel present in the tree and in `status` with a `stopped` flag, no actor/PTY. `restart_surface` spawns the PTY from `SurfaceSpec`, marks `running`, emits `surface_restarted`. `exit` → `running`→`stopped` (panel + tree preserved).
- **Not persisted:** live grids / scrollback / snapshots (ephemeral; screen restoration is M1 live-reattach only — survives GUI death, not a cold daemon restart).
- **Corrupt `state.json` on load:** rename to `state.json.corrupt-<ts>`, log, start empty.

---

## 5. Frontend

**`LayoutEngine.tsx`** — recursive render of `LayoutNode`:
- `Split` → flex container (`orient` h/v), children grow per `ratios`, splitter handles between children (drag → `set_ratios`, throttled ~30ms during drag, final on mouseup; col/row-resize cursor).
- `Leaf` → M0+M1 `TerminalView` by `surface_id`. `stopped` panel → overlay "Process exited · ⏎ Restart" calling `restart_surface`.
- Hotkeys (`DOCS/MAIN.md` §11): split `⌘⇧T` (`split_surface dir=right`), neighbor focus `⌘⌥←↑→↓` (geometry-based over the tree), close panel.
- Reacts to `layout_changed` for live multi-client sync; no GUI-local layout state.

**`Sidebar.tsx`** (§8.5):
- Groups (color swatch + name + chevron) with workspaces beneath. Workspace row: status ring (M2 placeholder: aggregate running/stopped color), name, unread dot, panel count.
- Drag-reorder of workspaces (incl. across groups) → `set_workspace_meta { order, group_id }`; drag groups → `set_group { order }`.
- Selecting a workspace makes it active and clears unread (`set_workspace_meta unread=false`). Context menu on group: rename / color / delete.
- `+ New workspace` (`⌘N`) → Wizard.

**`PresetPicker` + `Wizard`** (mockups already approved in `DOCS/space-sh.pen`):
- 10 presets §8.2: `1, 2↔, 2↕, 2+1, 1+2, 3, 2×2, 4, 2×3, 2×4` — each a `LayoutNode` generator (pure fn in `spacesh-core/layout.rs`, shared by daemon).
- Wizard: folder (`open`) → preset → agent per slot → `apply_preset`. Center toolbar presets re-split the active workspace via `apply_preset`.

**States** (Web App guide §6): empty workspace (no panels → preset CTA), stopped panel (restart overlay), empty sidebar (no workspaces → onboarding CTA).

---

## 6. Errors, tree edge-cases, testing

**Tree edge-cases** (core correctness, `spacesh-core` unit tests):
- `close` last leaf of a split → node collapses, parent ratios recomputed; closing the last panel of a workspace → empty tree (preset CTA).
- `close` that leaves a split with one child → node replaced by that child (no dangling single-child splits).
- `set_ratios`: `ratios.len() == children.len()`, sum normalized to 1.0, min-clamp (panel can't collapse to 0; floor e.g. 0.05).
- `move_surface`: remove (with collapse) + insert by `edge` at target leaf; forbid moving onto itself; moving the sole leaf is a no-op.
- `apply_preset` over a non-empty workspace: kills current panels (GUI confirms) → builds new tree.
- out-of-range `node_path` / unknown `surface_id`/`workspace_id`/`group_id` → `NOT_FOUND`.

**Protocol errors:** invalid `preset_id` → `BAD_REQUEST`; `restart_surface` on a `running` surface → no-op `ok`; `set_ratios` with wrong length → `BAD_REQUEST`. Persist disk failure → log, best-effort, never crashes the daemon; corrupt `state.json` on load → backup + empty start.

**Tests:**

| Level | What |
|---|---|
| `spacesh-core/layout` (unit) | split/close/collapse/single-child-promote; `set_ratios` normalize+clamp; `move_leaf` for all 4 edges; all 10 preset generators (tree shape); `LayoutNode` serde round-trip |
| `spacesh-core/workspace` (unit) | group/order/unread mutations; `SurfaceSpec` serde round-trip |
| `spaceshd/state_store` (unit) | save→load round-trip; atomic (tmp+rename); corrupt json → backup + empty |
| `spaceshd/persist` (unit) | debounce coalesces a burst into one save |
| `spaceshd` (integration, serial-guard as in M1) | open→apply_preset→status tree; split→close collapses; restart_surface revives stopped; cold daemon restart restores structure (panels stopped); move_surface |
| `app` (manual) | mouse split/resize; sidebar drag-reorder; wizard→apply_preset; restart stopped panel; daemon restart → structure intact |

**Budgets:** `set_ratios` during drag throttled ~30ms; persist debounce 500ms; structure restore on daemon start < 200ms on a typical config.

---

## 7. Implementation order (within the slice)

1. `spacesh-core/layout.rs` — `LayoutNode`, ops, preset generators + tests.
2. `spacesh-core/workspace.rs` — `Workspace`/`Group`/`SurfaceSpec` + meta + tests.
3. `spacesh-proto` — new command/event variants + serde tests.
4. `spaceshd/state_store.rs` + `persist.rs` — persistence + tests.
5. `spaceshd` registry/surface — workspace structure, `stopped`/`restart`, tree dispatch.
6. `spaceshd/server.rs` — new commands/events, cold-start restore + integration tests.
7. `app` — `LayoutEngine`, splitter resize, stopped overlay.
8. `app` — `Sidebar` (groups/unread/drag-reorder), `PresetPicker`, `Wizard`, bridge wiring.

End of slice: multi-workspace, n-ary split grids resizable by mouse, presets + wizard, layout & metadata persisted, cold daemon restart restores structure (panels stopped, restart on demand).
