Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
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'sratios. - 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.jsonbehind aStateStoretrait, atomic write (temp+fsync+rename), debounced ~500ms. SQLite swap is a later, localized change. - Cold daemon start restores structure from
state.json; panels come backstopped(no PTY) unlessautostartis 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) vsrunning(has actor).exitnow transitionsrunning→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):
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 onesave(scheduler inpersist.rs: dirty channel → timer → save). - Cold start (no live sessions):
load→ restore groups/workspaces/trees/SurfaceSpec. All panelsstopped. If a surface'sautostart == trueor global[daemon] autostart_surfacesis set → immediatelyrestart_surface. Default →stopped. stoppedlifecycle: panel present in the tree and instatuswith astoppedflag, no actor/PTY.restart_surfacespawns the PTY fromSurfaceSpec, marksrunning, emitssurface_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.jsonon load: rename tostate.json.corrupt-<ts>, log, start empty.
5. Frontend
LayoutEngine.tsx — recursive render of LayoutNode:
Split→ flex container (orienth/v), children grow perratios, splitter handles between children (drag →set_ratios, throttled ~30ms during drag, final on mouseup; col/row-resize cursor).Leaf→ M0+M1TerminalViewbysurface_id.stoppedpanel → overlay "Process exited · ⏎ Restart" callingrestart_surface.- Hotkeys (
DOCS/MAIN.md§11): split⌘⇧T(split_surface dir=right), neighbor focus⌘⌥←↑→↓(geometry-based over the tree), close panel. - Reacts to
layout_changedfor 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 aLayoutNodegenerator (pure fn inspacesh-core/layout.rs, shared by daemon). - Wizard: folder (
open) → preset → agent per slot →apply_preset. Center toolbar presets re-split the active workspace viaapply_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):
closelast leaf of a split → node collapses, parent ratios recomputed; closing the last panel of a workspace → empty tree (preset CTA).closethat 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 byedgeat target leaf; forbid moving onto itself; moving the sole leaf is a no-op.apply_presetover a non-empty workspace: kills current panels (GUI confirms) → builds new tree.- out-of-range
node_path/ unknownsurface_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)
spacesh-core/layout.rs—LayoutNode, ops, preset generators + tests.spacesh-core/workspace.rs—Workspace/Group/SurfaceSpec+ meta + tests.spacesh-proto— new command/event variants + serde tests.spaceshd/state_store.rs+persist.rs— persistence + tests.spaceshdregistry/surface — workspace structure,stopped/restart, tree dispatch.spaceshd/server.rs— new commands/events, cold-start restore + integration tests.app—LayoutEngine, splitter resize, stopped overlay.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).