diff --git a/DOCS/superpowers/specs/2026-06-09-spacesh-m2-design.md b/DOCS/superpowers/specs/2026-06-09-spacesh-m2-design.md new file mode 100644 index 0000000..d5b8b7c --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-09-spacesh-m2-design.md @@ -0,0 +1,204 @@ +# 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, order: u32, + unread: bool, layout: LayoutNode, surfaces: Map } + +enum LayoutNode { + Leaf { surface_id: SurfaceId }, + Split { orient: Orient /* H | V */, ratios: Vec, children: Vec }, +} + +SurfaceSpec { command: String, args: Vec, cwd: PathBuf, + agent_label: Option, 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` + `Vec` (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; + 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-`, 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).