Files
spaceshell/DOCS/superpowers/specs/2026-06-09-spacesh-m2-design.md
T
2026-06-09 20:58:51 +07:00

205 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).