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

14 KiB
Raw Blame History

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 runningstopped (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 runningstopped (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):

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

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

{
  "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. exitrunningstopped (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_idNOT_FOUND.

Protocol errors: invalid preset_idBAD_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.rsLayoutNode, ops, preset generators + tests.
  2. spacesh-core/workspace.rsWorkspace/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. appLayoutEngine, splitter resize, stopped overlay.
  8. appSidebar (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).