Files
spaceshell/DOCS/superpowers/plans/2026-06-09-spacesh-m2.md
T

2641 lines
102 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 Implementation Plan — layouts & workspaces
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add n-ary split layouts, multi-workspace structure with UX-metadata (colored groups, unread, custom order), 10 layout presets + wizard, and disk persistence (`state.json`) with cold-start structure restore (panels come back `stopped`).
**Architecture:** Layout/workspace **data types live in `spacesh-proto`** (they cross the wire in `status` / `layout_changed`); the **tree algorithms + preset generators live in `spacesh-core`** (now depends on `spacesh-proto`). The daemon owns the authoritative tree and persists it behind a `StateStore` trait (atomic `state.json`, debounced). The Tauri app renders the tree (new `LayoutEngine`) and sidebar, sending commands; no GUI-local layout state.
**Tech Stack:** Rust (serde, tokio), React/TypeScript + Tauri 2. Builds on the shipped M0+M1 crates (`spacesh-proto`, `spacesh-pty`, `spacesh-core`, `spaceshd`) and `app/`.
**Spec:** `DOCS/superpowers/specs/2026-06-09-spacesh-m2-design.md`. Base: `DOCS/MAIN.md` §8.
**Conventions:** English code/comments. camelCase vars/fns, PascalCase types, snake_case files, UPPER_CASE env. `cargo test --workspace` is the Definition of Done and must stay green & non-flaky (heavy socket/PTY integration tests use `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` + the existing `crate::test_support::serial()` guard — apply both to any new such test). Commit after each task; append:
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Do not `git push`.
---
## File Structure
```
crates/spacesh-proto/src/
ids.rs # + GroupId newtype
layout.rs (new) # Orient, LayoutNode (external-tagged serde)
workspace.rs (new) # SurfaceSpec, Group, Workspace, SurfaceView, WorkspaceView
message.rs # + new Cmd / Evt variants
lib.rs # + re-exports
crates/spacesh-core/src/
Cargo.toml # + spacesh-proto path dep
ops.rs (new) # tree algorithms on proto::LayoutNode (insert/remove/ratios/move/find)
presets.rs (new) # 10 preset -> LayoutNode generators
lib.rs # + re-exports
crates/spaceshd/src/
state_store.rs (new) # StateStore trait + JsonStateStore (atomic, corrupt-backup)
persist.rs (new) # debounce scheduler
registry.rs # rewritten: Workspace/Group structure + running/stopped surfaces
surface.rs # + restart support (spec carried for stopped panels)
server.rs # + new command dispatch, new events, cold-start restore
app/src/
layoutTypes.ts (new) # TS mirror of LayoutNode/Workspace/Group
socketBridge.ts # + new commands/events
LayoutEngine.tsx (new) # recursive tree render + splitter resize + stopped overlay
Sidebar.tsx (new) # groups/workspaces/unread/drag-reorder
PresetPicker.tsx (new) # 10 preset thumbnails
Wizard.tsx (new) # folder -> preset -> agent-per-slot -> apply_preset
App.tsx # rewired around workspaces + LayoutEngine
```
---
## Phase 1 — proto: layout & workspace data types
### Task 1: GroupId + Orient + LayoutNode
**Files:**
- Modify: `crates/spacesh-proto/src/ids.rs`
- Create: `crates/spacesh-proto/src/layout.rs`
- Modify: `crates/spacesh-proto/src/lib.rs`
- [ ] **Step 1: Add GroupId to ids.rs**
Append to `crates/spacesh-proto/src/ids.rs`:
```rust
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GroupId(pub String);
impl std::fmt::Display for GroupId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
```
- [ ] **Step 2: Write the failing test for LayoutNode serde**
Create `crates/spacesh-proto/src/layout.rs`:
```rust
use serde::{Deserialize, Serialize};
use crate::ids::SurfaceId;
/// Split orientation. `H` lays children left-to-right; `V` top-to-bottom.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Orient {
H,
V,
}
/// Recursive n-ary layout tree. Externally tagged so JSON reads
/// `{ "leaf": { "surface_id": "s_1" } }` / `{ "split": { ... } }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LayoutNode {
Leaf { surface_id: SurfaceId },
Split {
orient: Orient,
ratios: Vec<f32>,
children: Vec<LayoutNode>,
},
}
impl LayoutNode {
pub fn leaf(id: SurfaceId) -> Self {
LayoutNode::Leaf { surface_id: id }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leaf_serializes_externally_tagged() {
let n = LayoutNode::leaf(SurfaceId("s_1".into()));
let j = serde_json::to_string(&n).unwrap();
assert_eq!(j, r#"{"leaf":{"surface_id":"s_1"}}"#);
}
#[test]
fn split_round_trips() {
let n = LayoutNode::Split {
orient: Orient::V,
ratios: vec![0.5, 0.5],
children: vec![
LayoutNode::leaf(SurfaceId("s_1".into())),
LayoutNode::leaf(SurfaceId("s_2".into())),
],
};
let j = serde_json::to_string(&n).unwrap();
assert!(j.contains(r#""split""#));
assert!(j.contains(r#""orient":"v""#));
let back: LayoutNode = serde_json::from_str(&j).unwrap();
assert_eq!(back, n);
}
}
```
- [ ] **Step 3: Wire the module**
`crates/spacesh-proto/src/lib.rs` — add after the existing modules:
```rust
pub mod codec;
pub mod ids;
pub mod layout;
pub mod message;
pub mod workspace;
pub use ids::{GroupId, SurfaceId, WorkspaceId};
pub use layout::{LayoutNode, Orient};
pub use message::{Cmd, Envelope, ErrorBody, Evt};
pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
```
(`workspace` is created in Task 2; if building Task 1 alone, temporarily omit the `workspace` mod + its re-export and restore in Task 2.)
- [ ] **Step 4: Run tests**
Run: `cargo test -p spacesh-proto layout`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add crates/spacesh-proto/src/ids.rs crates/spacesh-proto/src/layout.rs crates/spacesh-proto/src/lib.rs
git commit -m "feat(proto): GroupId, Orient, n-ary LayoutNode with external-tagged serde"
```
---
### Task 2: SurfaceSpec, Group, Workspace, view types
**Files:**
- Create: `crates/spacesh-proto/src/workspace.rs`
- Modify: `crates/spacesh-proto/src/lib.rs` (enable `workspace` mod from Task 1 Step 3)
- [ ] **Step 1: Write the failing test**
Create `crates/spacesh-proto/src/workspace.rs`:
```rust
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
use crate::layout::LayoutNode;
/// Everything needed to (re)create a panel's process.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SurfaceSpec {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
pub cwd: String,
#[serde(default)]
pub agent_label: Option<String>,
pub cols: u16,
pub rows: u16,
#[serde(default)]
pub autostart: bool,
}
/// A colored, ordered collection of workspaces.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Group {
pub id: GroupId,
pub name: String,
pub color: String,
pub order: u32,
}
/// Persisted workspace: structure only (no live process state).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Workspace {
pub id: WorkspaceId,
pub path: String,
pub name: String,
#[serde(default)]
pub group_id: Option<GroupId>,
pub order: u32,
#[serde(default)]
pub unread: bool,
/// None = empty workspace (no panels yet).
#[serde(default)]
pub layout: Option<LayoutNode>,
pub surfaces: HashMap<SurfaceId, SurfaceSpec>,
}
/// Per-surface view in `status` — spec plus live lifecycle flag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SurfaceView {
pub spec: SurfaceSpec,
/// true = has a live actor/PTY; false = stopped (in tree, no process).
pub running: bool,
}
/// Workspace view in `status` / `workspace_changed`: structure + per-surface state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceView {
pub id: WorkspaceId,
pub path: String,
pub name: String,
pub group_id: Option<GroupId>,
pub order: u32,
pub unread: bool,
pub layout: Option<LayoutNode>,
pub surfaces: HashMap<SurfaceId, SurfaceView>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn surface_spec_round_trips() {
let s = SurfaceSpec {
command: "claude".into(),
args: vec![],
cwd: "/tmp".into(),
agent_label: Some("claude".into()),
cols: 80,
rows: 24,
autostart: false,
};
let j = serde_json::to_string(&s).unwrap();
let back: SurfaceSpec = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
}
#[test]
fn workspace_round_trips_with_empty_layout() {
let w = Workspace {
id: WorkspaceId("w_1".into()),
path: "/tmp/p".into(),
name: "p".into(),
group_id: None,
order: 0,
unread: false,
layout: None,
surfaces: HashMap::new(),
};
let j = serde_json::to_string(&w).unwrap();
let back: Workspace = serde_json::from_str(&j).unwrap();
assert_eq!(back, w);
}
}
```
- [ ] **Step 2: Run tests**
Run: `cargo test -p spacesh-proto workspace`
Expected: PASS (2 tests). Ensure `lib.rs` has the `workspace` mod + re-exports from Task 1 Step 3.
- [ ] **Step 3: Commit**
```bash
git add crates/spacesh-proto/src/workspace.rs crates/spacesh-proto/src/lib.rs
git commit -m "feat(proto): SurfaceSpec, Group, Workspace, status view types"
```
---
## Phase 2 — proto: commands & events
### Task 3: New M2 command and event variants
**Files:**
- Modify: `crates/spacesh-proto/src/message.rs`
- [ ] **Step 1: Write the failing test**
Add to the `tests` module in `crates/spacesh-proto/src/message.rs`:
```rust
#[test]
fn split_surface_serializes() {
let env = Envelope::Req {
id: 1,
cmd: Cmd::SplitSurface {
surface_id: SurfaceId("s_1".into()),
dir: SplitDir::Right,
command: None,
args: vec![],
},
};
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains("split_surface"));
assert!(j.contains(r#""dir":"right""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn apply_preset_round_trips() {
let env = Envelope::Req {
id: 2,
cmd: Cmd::ApplyPreset {
workspace_id: WorkspaceId("w_1".into()),
preset_id: "2x2".into(),
slots: vec![
PresetSlot { command: Some("claude".into()), args: vec![] },
PresetSlot { command: None, args: vec![] },
],
},
};
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
assert_eq!(back, env);
}
#[test]
fn set_ratios_round_trips() {
let env = Envelope::Req {
id: 3,
cmd: Cmd::SetRatios {
workspace_id: WorkspaceId("w_1".into()),
node_path: vec![0, 1],
ratios: vec![0.3, 0.7],
},
};
let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
assert_eq!(back, env);
}
#[test]
fn layout_changed_event_round_trips() {
let evt = Envelope::Evt(Evt::LayoutChanged {
workspace_id: WorkspaceId("w_1".into()),
layout: Some(crate::layout::LayoutNode::leaf(SurfaceId("s_1".into()))),
});
let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap();
assert_eq!(back, evt);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p spacesh-proto message`
Expected: FAIL to compile (`Cmd::SplitSurface`, `SplitDir`, `PresetSlot`, `Evt::LayoutChanged` not defined).
- [ ] **Step 3: Add the new types and variants**
In `crates/spacesh-proto/src/message.rs`, update imports at the top:
```rust
use serde::{Deserialize, Serialize};
use crate::ids::{GroupId, SurfaceId, WorkspaceId};
use crate::layout::LayoutNode;
use crate::workspace::{Group, WorkspaceView};
```
Add these helper types above the `Cmd` enum:
```rust
/// Direction a split grows the new neighbor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SplitDir {
Right,
Down,
}
/// Edge of a target leaf to drop a moved panel against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Edge {
Left,
Right,
Top,
Bottom,
}
/// One panel slot when applying a preset.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PresetSlot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
}
```
Add these variants inside `enum Cmd` (before `Status`):
```rust
SplitSurface {
surface_id: SurfaceId,
dir: SplitDir,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
},
SetRatios { workspace_id: WorkspaceId, node_path: Vec<u32>, ratios: Vec<f32> },
MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge },
ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec<PresetSlot> },
RestartSurface { surface_id: SurfaceId },
CloseWorkspace { workspace_id: WorkspaceId },
SetWorkspaceMeta {
workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
group_id: Option<Option<GroupId>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
unread: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
order: Option<u32>,
},
CreateGroup { name: String, color: String },
SetGroup {
group_id: GroupId,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
order: Option<u32>,
},
DeleteGroup { group_id: GroupId },
```
Note `group_id: Option<Option<GroupId>>` on `SetWorkspaceMeta`: outer `None` = "don't change"; `Some(None)` = "ungroup"; `Some(Some(g))` = "move to group g".
Add these variants inside `enum Evt`:
```rust
LayoutChanged { workspace_id: WorkspaceId, layout: Option<LayoutNode> },
WorkspaceChanged { workspace: WorkspaceView },
WorkspaceClosed { workspace_id: WorkspaceId },
GroupsChanged { groups: Vec<Group> },
SurfaceRestarted { surface_id: SurfaceId },
```
- [ ] **Step 4: Run tests**
Run: `cargo test -p spacesh-proto`
Expected: PASS (all proto tests incl. the 4 new ones).
- [ ] **Step 5: Commit**
```bash
git add crates/spacesh-proto/src/message.rs
git commit -m "feat(proto): M2 commands (split/ratios/move/preset/restart/groups/meta) and events"
```
---
## Phase 3 — core: tree algorithms & presets
### Task 4: spacesh-core depends on proto; tree ops module
**Files:**
- Modify: `crates/spacesh-core/Cargo.toml`
- Create: `crates/spacesh-core/src/ops.rs`
- Modify: `crates/spacesh-core/src/lib.rs`
- [ ] **Step 1: Add the proto dependency**
`crates/spacesh-core/Cargo.toml` — add to `[dependencies]`:
```toml
spacesh-proto = { path = "../spacesh-proto" }
```
- [ ] **Step 2: Write the failing tests**
Create `crates/spacesh-core/src/ops.rs`:
```rust
//! Pure algorithms over `spacesh_proto::LayoutNode`. No I/O.
use spacesh_proto::layout::{LayoutNode, Orient};
use spacesh_proto::ids::SurfaceId;
/// Minimum ratio a panel may shrink to (5%).
const MIN_RATIO: f32 = 0.05;
/// Collect all surface ids in the tree, left-to-right.
pub fn leaves(node: &LayoutNode) -> Vec<SurfaceId> {
let mut out = Vec::new();
collect(node, &mut out);
out
}
fn collect(node: &LayoutNode, out: &mut Vec<SurfaceId>) {
match node {
LayoutNode::Leaf { surface_id } => out.push(surface_id.clone()),
LayoutNode::Split { children, .. } => children.iter().for_each(|c| collect(c, out)),
}
}
/// Split the leaf `target` by inserting `new_id` as a sibling on `dir`.
/// Returns true if the target was found and split.
pub fn split_leaf(root: &mut LayoutNode, target: &SurfaceId, dir: Orient, after: bool, new_id: SurfaceId) -> bool {
// If root itself is the target leaf, replace it with a split.
if let LayoutNode::Leaf { surface_id } = root {
if surface_id == target {
let existing = root.clone();
let new_leaf = LayoutNode::leaf(new_id);
let children = if after { vec![existing, new_leaf] } else { vec![new_leaf, existing] };
*root = LayoutNode::Split { orient: dir, ratios: even(children.len()), children };
return true;
}
return false;
}
if let LayoutNode::Split { orient, ratios, children } = root {
// If a direct child is the target leaf AND this split matches `dir`, insert as sibling.
if *orient == dir {
if let Some(i) = children.iter().position(|c| is_leaf(c, target)) {
children.insert(i + if after { 1 } else { 0 }, LayoutNode::leaf(new_id));
*ratios = even(children.len());
return true;
}
}
// Otherwise recurse.
for c in children.iter_mut() {
if split_leaf(c, target, dir, after, new_id.clone()) {
return true;
}
}
}
false
}
/// Remove the leaf `target`. Collapses empty/now-single-child splits and promotes
/// single children. Returns the new root (None if the tree became empty).
pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option<LayoutNode> {
match root {
LayoutNode::Leaf { surface_id } => {
if &surface_id == target { None } else { Some(LayoutNode::Leaf { surface_id }) }
}
LayoutNode::Split { orient, children, .. } => {
let kept: Vec<LayoutNode> = children
.into_iter()
.filter_map(|c| remove_leaf(c, target))
.collect();
match kept.len() {
0 => None,
1 => Some(kept.into_iter().next().unwrap()), // promote single child
n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }),
}
}
}
}
/// Set ratios on the split node addressed by `path` (child indices from root).
/// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the
/// path is invalid or the length does not match the node's child count.
pub fn set_ratios(root: &mut LayoutNode, path: &[u32], ratios: &[f32]) -> bool {
let Some(node) = node_at_mut(root, path) else { return false };
if let LayoutNode::Split { ratios: r, children, .. } = node {
if ratios.len() != children.len() {
return false;
}
*r = normalize_clamp(ratios);
true
} else {
false
}
}
/// Move leaf `src` to sit on `edge` of leaf `target`. Returns the new root.
/// No-op (returns the original) if src == target or either is missing.
pub fn move_leaf(root: LayoutNode, src: &SurfaceId, target: &SurfaceId, edge: spacesh_proto::message::Edge) -> LayoutNode {
use spacesh_proto::message::Edge;
if src == target || !contains(&root, src) || !contains(&root, target) {
return root;
}
let Some(removed) = remove_leaf(root, src) else { return LayoutNode::leaf(src.clone()) };
let (orient, after) = match edge {
Edge::Left => (Orient::H, false),
Edge::Right => (Orient::H, true),
Edge::Top => (Orient::V, false),
Edge::Bottom => (Orient::V, true),
};
let mut root = removed;
split_leaf(&mut root, target, orient, after, src.clone());
root
}
// ---- helpers ----
fn is_leaf(node: &LayoutNode, id: &SurfaceId) -> bool {
matches!(node, LayoutNode::Leaf { surface_id } if surface_id == id)
}
fn contains(node: &LayoutNode, id: &SurfaceId) -> bool {
leaves(node).iter().any(|s| s == id)
}
fn even(n: usize) -> Vec<f32> {
vec![1.0 / n as f32; n]
}
fn normalize_clamp(ratios: &[f32]) -> Vec<f32> {
let clamped: Vec<f32> = ratios.iter().map(|r| r.max(MIN_RATIO)).collect();
let sum: f32 = clamped.iter().sum();
clamped.iter().map(|r| r / sum).collect()
}
fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[u32]) -> Option<&'a mut LayoutNode> {
let mut cur = root;
for &idx in path {
match cur {
LayoutNode::Split { children, .. } => {
cur = children.get_mut(idx as usize)?;
}
LayoutNode::Leaf { .. } => return None,
}
}
Some(cur)
}
#[cfg(test)]
mod tests {
use super::*;
fn sid(s: &str) -> SurfaceId { SurfaceId(s.into()) }
#[test]
fn split_root_leaf_creates_split() {
let mut root = LayoutNode::leaf(sid("s_1"));
assert!(split_leaf(&mut root, &sid("s_1"), Orient::H, true, sid("s_2")));
assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2")]);
}
#[test]
fn split_same_orient_appends_as_sibling() {
let mut root = LayoutNode::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
};
split_leaf(&mut root, &sid("s_2"), Orient::H, true, sid("s_3"));
// 3 children in one row, even ratios.
match &root {
LayoutNode::Split { children, ratios, .. } => {
assert_eq!(children.len(), 3);
assert!((ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
_ => panic!(),
}
assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2"), sid("s_3")]);
}
#[test]
fn remove_promotes_single_child() {
let root = LayoutNode::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
};
let after = remove_leaf(root, &sid("s_2")).unwrap();
assert_eq!(after, LayoutNode::leaf(sid("s_1"))); // split collapsed to the surviving leaf
}
#[test]
fn remove_last_leaf_returns_none() {
let root = LayoutNode::leaf(sid("s_1"));
assert!(remove_leaf(root, &sid("s_1")).is_none());
}
#[test]
fn set_ratios_normalizes_and_clamps() {
let mut root = LayoutNode::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
};
assert!(set_ratios(&mut root, &[], &[0.0, 1.0]));
if let LayoutNode::Split { ratios, .. } = &root {
assert!(ratios[0] >= MIN_RATIO);
assert!((ratios.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
}
#[test]
fn set_ratios_wrong_len_rejected() {
let mut root = LayoutNode::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
};
assert!(!set_ratios(&mut root, &[], &[1.0]));
}
#[test]
fn move_leaf_to_right_of_target() {
let root = LayoutNode::Split {
orient: Orient::V, ratios: vec![0.5, 0.5],
children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))],
};
let after = move_leaf(root, &sid("s_1"), &sid("s_2"), spacesh_proto::message::Edge::Right);
assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]);
}
#[test]
fn move_onto_self_is_noop() {
let root = LayoutNode::leaf(sid("s_1"));
let after = move_leaf(root.clone(), &sid("s_1"), &sid("s_1"), spacesh_proto::message::Edge::Right);
assert_eq!(after, root);
}
}
```
- [ ] **Step 3: Wire the module**
`crates/spacesh-core/src/lib.rs`:
```rust
pub mod grid;
pub mod ops;
pub mod presets;
pub mod snapshot;
pub use grid::GridSurface;
pub use snapshot::Snapshot;
```
(`presets` is Task 5; omit its line + restore in Task 5 if building Task 4 alone.)
- [ ] **Step 4: Run tests**
Run: `cargo test -p spacesh-core ops`
Expected: PASS (8 tests).
- [ ] **Step 5: Commit**
```bash
git add crates/spacesh-core/Cargo.toml crates/spacesh-core/src/ops.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): n-ary tree ops — split, remove+collapse, ratios, move"
```
---
### Task 5: Preset generators
**Files:**
- Create: `crates/spacesh-core/src/presets.rs`
- Modify: `crates/spacesh-core/src/lib.rs` (enable `presets` mod)
- [ ] **Step 1: Write the failing test**
Create `crates/spacesh-core/src/presets.rs`:
```rust
//! The 10 layout presets (DOCS/MAIN.md §8.2). A preset maps a list of surface
//! ids (one per slot, in order) to a LayoutNode. `slot_count` says how many
//! panels the preset needs.
use spacesh_proto::ids::SurfaceId;
use spacesh_proto::layout::{LayoutNode, Orient};
/// Known preset ids and their panel counts.
pub fn slot_count(preset_id: &str) -> Option<usize> {
Some(match preset_id {
"1" => 1,
"2lr" => 2, // 2↔
"2tb" => 2, // 2↕
"2+1" => 3,
"1+2" => 3,
"3" => 3,
"2x2" => 4,
"4" => 4, // single row of 4
"2x3" => 6,
"2x4" => 8,
_ => return None,
})
}
fn leaf(id: &SurfaceId) -> LayoutNode { LayoutNode::leaf(id.clone()) }
fn even(n: usize) -> Vec<f32> { vec![1.0 / n as f32; n] }
fn row(ids: &[SurfaceId]) -> LayoutNode {
LayoutNode::Split { orient: Orient::H, ratios: even(ids.len()), children: ids.iter().map(leaf).collect() }
}
fn col(children: Vec<LayoutNode>) -> LayoutNode {
LayoutNode::Split { orient: Orient::V, ratios: even(children.len()), children }
}
fn rown(children: Vec<LayoutNode>) -> LayoutNode {
LayoutNode::Split { orient: Orient::H, ratios: even(children.len()), children }
}
/// Build the preset tree from exactly `slot_count(preset_id)` ids.
/// Returns None for an unknown id or wrong id count.
pub fn build(preset_id: &str, ids: &[SurfaceId]) -> Option<LayoutNode> {
if slot_count(preset_id)? != ids.len() {
return None;
}
Some(match preset_id {
"1" => leaf(&ids[0]),
"2lr" => row(&ids),
"2tb" => col(vec![leaf(&ids[0]), leaf(&ids[1])]),
// left big column over... 2 stacked on the right.
"2+1" => rown(vec![leaf(&ids[0]), col(vec![leaf(&ids[1]), leaf(&ids[2])])]),
// one big on the left, 2 stacked on the right (mirror naming kept simple).
"1+2" => rown(vec![col(vec![leaf(&ids[0]), leaf(&ids[1])]), leaf(&ids[2])]),
"3" => row(&ids),
"2x2" => col(vec![row(&ids[0..2]), row(&ids[2..4])]),
"4" => row(&ids),
"2x3" => col(vec![row(&ids[0..3]), row(&ids[3..6])]),
"2x4" => col(vec![row(&ids[0..4]), row(&ids[4..8])]),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::leaves;
fn ids(n: usize) -> Vec<SurfaceId> { (0..n).map(|i| SurfaceId(format!("s_{i}"))).collect() }
#[test]
fn all_presets_have_counts() {
for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] {
assert!(slot_count(p).is_some(), "missing count for {p}");
}
assert!(slot_count("nope").is_none());
}
#[test]
fn build_uses_all_ids_in_order() {
for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] {
let n = slot_count(p).unwrap();
let tree = build(p, &ids(n)).unwrap();
assert_eq!(leaves(&tree), ids(n), "preset {p} must place all ids in order");
}
}
#[test]
fn build_rejects_wrong_id_count() {
assert!(build("2x2", &ids(3)).is_none());
assert!(build("bogus", &ids(1)).is_none());
}
#[test]
fn grid_2x2_is_two_rows() {
let tree = build("2x2", &ids(4)).unwrap();
match tree {
LayoutNode::Split { orient: Orient::V, children, .. } => {
assert_eq!(children.len(), 2);
for r in &children {
matches!(r, LayoutNode::Split { orient: Orient::H, .. });
}
}
_ => panic!("2x2 should be a vertical split of two horizontal rows"),
}
}
}
```
- [ ] **Step 2: Run tests**
Run: `cargo test -p spacesh-core presets`
Expected: PASS (4 tests).
- [ ] **Step 3: Commit**
```bash
git add crates/spacesh-core/src/presets.rs crates/spacesh-core/src/lib.rs
git commit -m "feat(core): 10 layout preset generators"
```
---
## Phase 4 — daemon: persistence
### Task 6: StateStore trait + JsonStateStore
**Files:**
- Create: `crates/spaceshd/src/state_store.rs`
- Modify: `crates/spaceshd/src/main.rs` (add `mod state_store;`)
- [ ] **Step 1: Write the failing test**
Create `crates/spaceshd/src/state_store.rs`:
```rust
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use spacesh_proto::workspace::{Group, Workspace};
/// The full persisted snapshot of structure (no live processes).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PersistState {
pub version: u32,
#[serde(default)]
pub groups: Vec<Group>,
#[serde(default)]
pub workspaces: Vec<Workspace>,
}
pub trait StateStore: Send + Sync {
fn load(&self) -> Result<PersistState>;
fn save(&self, state: &PersistState) -> Result<()>;
}
/// JSON file store with atomic write (temp + rename) and corrupt-file backup.
pub struct JsonStateStore {
path: PathBuf,
}
impl JsonStateStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
fn backup_corrupt(&self, ts: u128) {
let bak = self.path.with_extension(format!("corrupt-{ts}"));
let _ = std::fs::rename(&self.path, bak);
}
}
impl StateStore for JsonStateStore {
fn load(&self) -> Result<PersistState> {
if !self.path.exists() {
return Ok(PersistState { version: 1, ..Default::default() });
}
let bytes = std::fs::read(&self.path)?;
match serde_json::from_slice::<PersistState>(&bytes) {
Ok(state) => Ok(state),
Err(_) => {
// Corrupt file: back it up and start fresh.
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
self.backup_corrupt(ts);
Ok(PersistState { version: 1, ..Default::default() })
}
}
}
fn save(&self, state: &PersistState) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(state)?;
std::fs::write(&tmp, &bytes)?;
// fsync the temp file before rename for durability.
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
#[allow(dead_code)]
fn touch(_p: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
use spacesh_proto::ids::WorkspaceId;
fn tmp_file(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("spacesh-state-{name}-{n}.json"));
p
}
fn sample() -> PersistState {
PersistState {
version: 1,
groups: vec![],
workspaces: vec![Workspace {
id: WorkspaceId("w_1".into()),
path: "/tmp/p".into(),
name: "p".into(),
group_id: None,
order: 0,
unread: false,
layout: None,
surfaces: std::collections::HashMap::new(),
}],
}
}
#[test]
fn save_then_load_round_trips() {
let path = tmp_file("roundtrip");
let store = JsonStateStore::new(path.clone());
store.save(&sample()).unwrap();
let back = store.load().unwrap();
assert_eq!(back, sample());
let _ = std::fs::remove_file(path);
}
#[test]
fn missing_file_loads_default() {
let store = JsonStateStore::new(tmp_file("missing"));
let s = store.load().unwrap();
assert_eq!(s.version, 1);
assert!(s.workspaces.is_empty());
}
#[test]
fn corrupt_file_is_backed_up_and_load_returns_default() {
let path = tmp_file("corrupt");
std::fs::write(&path, b"{ this is not valid json").unwrap();
let store = JsonStateStore::new(path.clone());
let s = store.load().unwrap();
assert!(s.workspaces.is_empty());
// original path no longer holds the corrupt bytes (renamed away)
assert!(!path.exists());
let _ = std::fs::remove_file(path);
}
}
```
- [ ] **Step 2: Wire the module**
In `crates/spaceshd/src/main.rs`, add `mod state_store;` with the other `mod` lines.
- [ ] **Step 3: Run tests**
Run: `cargo test -p spaceshd state_store`
Expected: PASS (3 tests).
- [ ] **Step 4: Commit**
```bash
git add crates/spaceshd/src/state_store.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): StateStore trait + atomic JSON store with corrupt-file backup"
```
---
### Task 7: Debounced persist scheduler
**Files:**
- Create: `crates/spaceshd/src/persist.rs`
- Modify: `crates/spaceshd/src/main.rs` (add `mod persist;`)
- [ ] **Step 1: Write the failing test**
Create `crates/spaceshd/src/persist.rs`:
```rust
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
use tokio::time::{Duration, Instant};
use crate::state_store::{PersistState, StateStore};
/// Debounce window: coalesce a burst of dirty signals into one save.
const DEBOUNCE: Duration = Duration::from_millis(500);
/// A handle the registry uses to request a persist. `mark_dirty(state)` records
/// the latest snapshot and (re)arms the debounce timer.
#[derive(Clone)]
pub struct Persister {
tx: mpsc::Sender<PersistState>,
}
impl Persister {
pub fn mark_dirty(&self, state: PersistState) {
// Best-effort; dropping a snapshot is fine because a newer one will arrive.
let _ = self.tx.try_send(state);
}
}
/// Spawn the debounce task. Returns the `Persister` handle.
/// `debounce` is configurable so tests can use a short window.
pub fn spawn(store: Arc<dyn StateStore>, debounce: Duration) -> Persister {
let (tx, mut rx) = mpsc::channel::<PersistState>(64);
tokio::spawn(async move {
let mut latest: Option<PersistState> = None;
let mut deadline: Option<Instant> = None;
loop {
let timer = async {
match deadline {
Some(d) => tokio::time::sleep_until(d).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
msg = rx.recv() => {
match msg {
Some(state) => {
latest = Some(state);
deadline = Some(Instant::now() + debounce);
}
None => {
// channel closed: final flush then exit
if let Some(s) = latest.take() { let _ = store.save(&s); }
break;
}
}
}
_ = timer => {
if let Some(s) = latest.take() { let _ = store.save(&s); }
deadline = None;
}
}
}
});
Persister { tx }
}
#[allow(dead_code)]
fn _unused(_c: &AtomicUsize) {}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct CountingStore {
saves: AtomicUsize,
last: Mutex<Option<PersistState>>,
}
impl StateStore for CountingStore {
fn load(&self) -> anyhow::Result<PersistState> { Ok(PersistState::default()) }
fn save(&self, state: &PersistState) -> anyhow::Result<()> {
self.saves.fetch_add(1, Ordering::SeqCst);
*self.last.lock().unwrap() = Some(state.clone());
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn burst_coalesces_to_one_save() {
let store = Arc::new(CountingStore { saves: AtomicUsize::new(0), last: Mutex::new(None) });
let p = spawn(store.clone(), Duration::from_millis(80));
// Fire several dirty signals rapidly.
for v in 1..=5u32 {
let mut s = PersistState::default();
s.version = v;
p.mark_dirty(s);
tokio::time::sleep(Duration::from_millis(10)).await;
}
// Wait past the debounce window.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(store.saves.load(Ordering::SeqCst), 1, "burst should coalesce to one save");
assert_eq!(store.last.lock().unwrap().as_ref().unwrap().version, 5, "save uses the latest snapshot");
}
}
```
- [ ] **Step 2: Wire the module**
In `crates/spaceshd/src/main.rs`, add `mod persist;`.
- [ ] **Step 3: Run tests**
Run: `cargo test -p spaceshd persist`
Expected: PASS (1 test).
- [ ] **Step 4: Commit**
```bash
git add crates/spaceshd/src/persist.rs crates/spaceshd/src/main.rs
git commit -m "feat(daemon): debounced persist scheduler coalescing bursts into one save"
```
---
## Phase 5 — daemon: registry rewrite
### Task 8: Registry with workspaces, groups, layout trees, running/stopped
**Files:**
- Modify: `crates/spaceshd/src/registry.rs` (substantial rewrite)
- Test: inline `#[cfg(test)]` in `registry.rs`
This rewrite makes the registry own the full structure (`Workspace`/`Group` from proto) plus the live actor map keyed by `SurfaceId`. A surface is `running` iff a live `SurfaceHandle` exists for it; otherwise (present in some workspace's `surfaces` map) it is `stopped`. The registry produces both the `WorkspaceView` list for `status` and the `PersistState` for the store.
- [ ] **Step 1: Write the new registry**
Replace the entire contents of `crates/spaceshd/src/registry.rs`:
```rust
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId};
use spacesh_proto::layout::LayoutNode;
use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView};
use crate::state_store::PersistState;
use crate::surface::SurfaceHandle;
/// Single-threaded owner of structure (workspaces/groups/trees + per-surface
/// specs) and the live actor map. Lives in the server router task.
#[derive(Default)]
pub struct Registry {
counter: AtomicU64,
groups: HashMap<GroupId, Group>,
workspaces: HashMap<WorkspaceId, Workspace>,
by_path: HashMap<String, WorkspaceId>,
/// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped.
live: HashMap<SurfaceId, SurfaceHandle>,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
fn next_id(&self, prefix: &str) -> String {
let n = self.counter.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{n:x}")
}
pub fn new_surface_id(&self) -> SurfaceId {
SurfaceId(self.next_id("s"))
}
// ---- workspaces ----
/// Idempotent by canonicalized path. Returns (workspace_id, created?).
pub fn open_workspace(&mut self, path: PathBuf) -> (WorkspaceId, bool) {
let canonical = path.canonicalize().unwrap_or(path);
let key = canonical.to_string_lossy().to_string();
if let Some(id) = self.by_path.get(&key) {
return (id.clone(), false);
}
let id = WorkspaceId(self.next_id("w"));
let name = canonical.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| key.clone());
let order = self.workspaces.len() as u32;
self.workspaces.insert(id.clone(), Workspace {
id: id.clone(), path: key.clone(), name, group_id: None, order,
unread: false, layout: None, surfaces: HashMap::new(),
});
self.by_path.insert(key, id.clone());
(id, true)
}
pub fn workspace(&self, id: &WorkspaceId) -> Option<&Workspace> {
self.workspaces.get(id)
}
pub fn workspace_mut(&mut self, id: &WorkspaceId) -> Option<&mut Workspace> {
self.workspaces.get_mut(id)
}
pub fn close_workspace(&mut self, id: &WorkspaceId) -> Vec<SurfaceId> {
let Some(ws) = self.workspaces.remove(id) else { return vec![] };
self.by_path.retain(|_, v| v != id);
let ids: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
for sid in &ids {
self.live.remove(sid);
}
ids
}
/// The workspace that owns a surface id, if any.
pub fn workspace_of(&self, sid: &SurfaceId) -> Option<WorkspaceId> {
self.workspaces.values().find(|w| w.surfaces.contains_key(sid)).map(|w| w.id.clone())
}
// ---- surfaces (structure) ----
pub fn add_surface_spec(&mut self, ws: &WorkspaceId, sid: SurfaceId, spec: SurfaceSpec) {
if let Some(w) = self.workspaces.get_mut(ws) {
w.surfaces.insert(sid, spec);
}
}
pub fn surface_spec(&self, sid: &SurfaceId) -> Option<SurfaceSpec> {
self.workspaces.values().find_map(|w| w.surfaces.get(sid).cloned())
}
/// Remove a surface from its workspace's spec map and the tree.
pub fn remove_surface(&mut self, sid: &SurfaceId) {
self.live.remove(sid);
if let Some(ws) = self.workspace_of(sid) {
if let Some(w) = self.workspaces.get_mut(&ws) {
w.surfaces.remove(sid);
w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid));
}
}
}
// ---- live actors ----
pub fn set_live(&mut self, handle: SurfaceHandle) {
self.live.insert(handle.id.clone(), handle);
}
pub fn live(&self, sid: &SurfaceId) -> Option<&SurfaceHandle> {
self.live.get(sid)
}
pub fn mark_stopped(&mut self, sid: &SurfaceId) {
self.live.remove(sid);
}
pub fn is_running(&self, sid: &SurfaceId) -> bool {
self.live.contains_key(sid)
}
// ---- groups ----
pub fn create_group(&mut self, name: String, color: String) -> GroupId {
let id = GroupId(self.next_id("g"));
let order = self.groups.len() as u32;
self.groups.insert(id.clone(), Group { id: id.clone(), name, color, order });
id
}
pub fn group_mut(&mut self, id: &GroupId) -> Option<&mut Group> {
self.groups.get_mut(id)
}
pub fn delete_group(&mut self, id: &GroupId) {
self.groups.remove(id);
for w in self.workspaces.values_mut() {
if w.group_id.as_ref() == Some(id) {
w.group_id = None;
}
}
}
pub fn groups(&self) -> Vec<Group> {
let mut g: Vec<Group> = self.groups.values().cloned().collect();
g.sort_by_key(|x| x.order);
g
}
// ---- views & persistence ----
pub fn workspace_view(&self, id: &WorkspaceId) -> Option<WorkspaceView> {
let w = self.workspaces.get(id)?;
Some(self.to_view(w))
}
fn to_view(&self, w: &Workspace) -> WorkspaceView {
let surfaces = w.surfaces.iter().map(|(sid, spec)| {
(sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid) })
}).collect();
WorkspaceView {
id: w.id.clone(), path: w.path.clone(), name: w.name.clone(),
group_id: w.group_id.clone(), order: w.order, unread: w.unread,
layout: w.layout.clone(), surfaces,
}
}
pub fn status(&self) -> (Vec<Group>, Vec<WorkspaceView>) {
let mut ws: Vec<WorkspaceView> = self.workspaces.values().map(|w| self.to_view(w)).collect();
ws.sort_by_key(|w| w.order);
(self.groups(), ws)
}
pub fn persist_state(&self) -> PersistState {
let mut workspaces: Vec<Workspace> = self.workspaces.values().cloned().collect();
workspaces.sort_by_key(|w| w.order);
PersistState { version: 1, groups: self.groups(), workspaces }
}
/// Replace all structure from a loaded snapshot (cold start). Clears live map.
pub fn restore(&mut self, state: PersistState) {
self.groups = state.groups.into_iter().map(|g| (g.id.clone(), g)).collect();
self.workspaces.clear();
self.by_path.clear();
self.live.clear();
for w in state.workspaces {
self.by_path.insert(w.path.clone(), w.id.clone());
self.workspaces.insert(w.id.clone(), w);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use spacesh_proto::layout::{LayoutNode as LN, Orient};
fn spec() -> SurfaceSpec {
SurfaceSpec { command: "/bin/sh".into(), args: vec![], cwd: "/tmp".into(),
agent_label: None, cols: 80, rows: 24, autostart: false }
}
#[test]
fn open_is_idempotent() {
let mut r = Registry::new();
let (a, c1) = r.open_workspace(std::env::temp_dir());
let (b, c2) = r.open_workspace(std::env::temp_dir());
assert_eq!(a, b);
assert!(c1 && !c2);
}
#[test]
fn surface_running_then_stopped() {
let mut r = Registry::new();
let (ws, _) = r.open_workspace(std::env::temp_dir());
let sid = r.new_surface_id();
r.add_surface_spec(&ws, sid.clone(), spec());
assert!(!r.is_running(&sid)); // spec present, no live actor = stopped
let v = r.workspace_view(&ws).unwrap();
assert_eq!(v.surfaces.get(&sid).unwrap().running, false);
}
#[test]
fn remove_surface_updates_tree() {
let mut r = Registry::new();
let (ws, _) = r.open_workspace(std::env::temp_dir());
let s1 = r.new_surface_id();
let s2 = r.new_surface_id();
r.add_surface_spec(&ws, s1.clone(), spec());
r.add_surface_spec(&ws, s2.clone(), spec());
r.workspace_mut(&ws).unwrap().layout = Some(LN::Split {
orient: Orient::H, ratios: vec![0.5, 0.5],
children: vec![LN::leaf(s1.clone()), LN::leaf(s2.clone())],
});
r.remove_surface(&s2);
let w = r.workspace(&ws).unwrap();
assert!(!w.surfaces.contains_key(&s2));
assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed
}
#[test]
fn restore_round_trips_through_persist_state() {
let mut r = Registry::new();
let (ws, _) = r.open_workspace(std::env::temp_dir());
r.add_surface_spec(&ws, r.new_surface_id(), spec());
let state = r.persist_state();
let mut r2 = Registry::new();
r2.restore(state.clone());
assert_eq!(r2.persist_state().workspaces.len(), state.workspaces.len());
}
#[test]
fn delete_group_ungroups_members() {
let mut r = Registry::new();
let (ws, _) = r.open_workspace(std::env::temp_dir());
let g = r.create_group("prod".into(), "#fff".into());
r.workspace_mut(&ws).unwrap().group_id = Some(g.clone());
r.delete_group(&g);
assert!(r.workspace(&ws).unwrap().group_id.is_none());
}
}
```
- [ ] **Step 2: Note the API break**
This rewrite changes `Registry`'s public API (`open_workspace` now returns `(WorkspaceId, bool)`; `insert_surface`/`surface`/`status` replaced). `server.rs` is updated in Task 10 to match; it will not compile until then. Build the crate's tests for this module in isolation:
Run: `cargo test -p spaceshd registry 2>&1 | head -40`
Expected: the `registry` module compiles and its tests pass **once `server.rs` is also updated** — so this task's green bar comes after Task 10. To check this module alone now, temporarily comment `mod server;` and the surface usages in `main.rs`, run `cargo test -p spaceshd registry`, then restore. (The implementer may instead implement Tasks 810 together and run tests once at the end of Task 10.)
- [ ] **Step 3: Commit**
```bash
git add crates/spaceshd/src/registry.rs
git commit -m "feat(daemon): registry owns workspaces/groups/trees + running/stopped surfaces"
```
---
## Phase 6 — daemon: surface restart
### Task 9: Surface restart support (stopped → running)
**Files:**
- Modify: `crates/spaceshd/src/surface.rs`
The M0+M1 `spawn_surface` already builds an actor from a `PtyHandle`. M2 adds a helper to (re)spawn a surface actor from a `SurfaceSpec` so the server can restart `stopped` panels uniformly. No actor-internals change.
- [ ] **Step 1: Add a spec-driven spawn helper**
Add to `crates/spaceshd/src/surface.rs` (top-level, after imports):
```rust
use spacesh_proto::workspace::SurfaceSpec;
use spacesh_pty::SpawnSpec;
/// Spawn (or restart) a surface actor from a persisted spec. Injects
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
pub fn spawn_from_spec(
id: SurfaceId,
workspace_id: WorkspaceId,
spec: &SurfaceSpec,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
) -> std::io::Result<SurfaceHandle> {
let pty = spacesh_pty::PtyHandle::spawn(SpawnSpec {
command: spec.command.clone(),
args: spec.args.clone(),
cwd: std::path::PathBuf::from(&spec.cwd),
cols: spec.cols,
rows: spec.rows,
env: vec![("SPACESH_SURFACE_ID".into(), id.0.clone())],
})
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, exit_tx))
}
```
- [ ] **Step 2: Add a test**
Add to the `tests` module in `surface.rs`:
```rust
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn spawn_from_spec_runs_the_command() {
let _serial = crate::test_support::serial();
let spec = SurfaceSpec {
command: "/bin/sh".into(),
args: vec!["-c".into(), "printf RESPAWN; sleep 0.3".into()],
cwd: std::env::temp_dir().to_string_lossy().into(),
agent_label: None, cols: 80, rows: 24, autostart: false,
};
let (exit_tx, _rx) = mpsc::unbounded_channel();
let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, exit_tx).unwrap();
let (reply_tx, reply_rx) = oneshot::channel();
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
let mut sub = reply_rx.await.unwrap();
let mut got = String::new();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(b)) = tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await {
got.push_str(&String::from_utf8_lossy(&b));
if got.contains("RESPAWN") { break; }
}
}
assert!(got.contains("RESPAWN"), "got: {got:?}");
}
```
- [ ] **Step 3: Build note**
Run: `cargo build -p spaceshd 2>&1 | head -20`
Expected: compiles (the helper is additive). Tests run green together with Task 10 (server uses the helper). If building now, the `surface` module compiles independently.
- [ ] **Step 4: Commit**
```bash
git add crates/spaceshd/src/surface.rs
git commit -m "feat(daemon): spawn_from_spec to (re)start surfaces from a persisted spec"
```
---
## Phase 7 — daemon: server dispatch + restore
### Task 10: Wire new commands, events, and cold-start restore
**Files:**
- Modify: `crates/spaceshd/src/server.rs` (dispatch + restore + persist hook)
- Modify: `crates/spaceshd/src/main.rs` (build the store + persister + restore on start)
- Test: integration tests in `server.rs`
This is the integration task. It updates `handle_request` for the new `Registry` API and adds the M2 commands; emits the new events; persists after every structural change; and restores structure on cold start.
- [ ] **Step 1: Build the store and persister in the daemon entrypoint**
In `crates/spaceshd/src/main.rs`, change `run_daemon` to construct the store + persister and pass them into `serve`. Replace `run_daemon`:
```rust
async fn run_daemon() -> Result<()> {
let Some(_lock) = lifecycle::acquire_instance_lock()? else {
eprintln!("another spaceshd is already running");
return Ok(());
};
lifecycle::clear_stale_socket()?;
let sock = lifecycle::socket_path()?;
let state_path = lifecycle::spacesh_dir()?.join("state.json");
let store: std::sync::Arc<dyn state_store::StateStore> =
std::sync::Arc::new(state_store::JsonStateStore::new(state_path));
eprintln!("spaceshd listening on {}", sock.display());
server::serve(&sock, store).await
}
```
- [ ] **Step 2: Update `serve` to accept the store, restore, and spawn the persister**
In `crates/spaceshd/src/server.rs`, change the `serve` signature and head:
```rust
use std::sync::Arc;
use std::time::Duration;
use crate::state_store::StateStore;
use crate::persist::{self, Persister};
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>) -> Result<()> {
let listener = UnixListener::bind(socket)?;
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel::<(SurfaceId, i32)>();
let router_for_exit = router_tx.clone();
tokio::spawn(async move {
while let Some((sid, code)) = exit_rx.recv().await {
let _ = router_for_exit.send(ServerMsg::Exit { surface_id: sid, code }).await;
}
});
let persister = persist::spawn(store.clone(), Duration::from_millis(500));
let initial = store.load().unwrap_or_default();
let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx, persister, initial));
let mut next_client: ClientId = 0;
loop {
let (stream, _addr) = listener.accept().await?;
let client_id = next_client;
next_client += 1;
let router_tx = router_tx.clone();
tokio::spawn(handle_client(stream, client_id, router_tx));
if shutdown.is_finished() {
break;
}
}
Ok(())
}
```
- [ ] **Step 3: Restore on router start and add a persist helper**
Change the `router` signature and start it by restoring the snapshot. Replace the `router` function header and registry init:
```rust
async fn router(
mut rx: mpsc::Receiver<ServerMsg>,
router_tx: mpsc::Sender<ServerMsg>,
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
persister: Persister,
initial: crate::state_store::PersistState,
) {
let mut reg = Registry::new();
reg.restore(initial);
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();
// Persist whatever structure was just restored / changed.
let persist = |reg: &Registry| persister.mark_dirty(reg.persist_state());
while let Some(msg) = rx.recv().await {
match msg {
// ... existing arms (ClientConnected/Disconnected/Output) unchanged ...
ServerMsg::Exit { surface_id, code } => {
// Transition running -> stopped; keep panel + tree.
reg.mark_stopped(&surface_id);
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
broadcast_evt(&clients, &evt);
}
ServerMsg::Request { id, cmd, client, out } => {
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &persister).await;
}
}
}
}
```
Keep the `ClientConnected`, `ClientDisconnected`, and `Output` arms exactly as they are today. The `persist` closure above is illustrative — call `persister.mark_dirty(reg.persist_state())` directly inside `handle_request` after each structural mutation (closures borrowing `reg` across the await are awkward; call the method form).
- [ ] **Step 4: Rewrite `handle_request` for the new API + M2 commands**
Replace the whole `handle_request` function. It is large; below is the complete body. Helper `ok`/`err` are unchanged from M0+M1.
```rust
#[allow(clippy::too_many_arguments)]
async fn handle_request(
id: u64,
cmd: Cmd,
client: ClientId,
out: ClientTx,
reg: &mut Registry,
subs: &mut HashMap<SurfaceId, Vec<ClientId>>,
clients: &HashMap<ClientId, ClientTx>,
router_tx: &mpsc::Sender<ServerMsg>,
exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>,
persister: &Persister,
) {
use spacesh_proto::message::{SplitDir, Edge};
use spacesh_proto::layout::{LayoutNode, Orient};
use spacesh_proto::workspace::SurfaceSpec;
match cmd {
Cmd::Open { path } => {
let (ws_id, created) = reg.open_workspace(path.into());
if created {
if let Some(view) = reg.workspace_view(&ws_id) {
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view }));
}
persister.mark_dirty(reg.persist_state());
}
let _ = out.send(ok(id, serde_json::json!({ "workspace_id": ws_id.0 }))).await;
}
Cmd::NewSurface { workspace_id, command, args, cols, rows } => {
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
};
let sid = reg.new_surface_id();
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let spec = SurfaceSpec {
command: shell, args: args.clone(), cwd: ws.path.clone(),
agent_label: command, cols, rows, autostart: false,
};
match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
reg.set_live(handle);
reg.add_surface_spec(&workspace_id, sid.clone(), spec);
// First panel of an empty workspace becomes the root leaf.
if let Some(w) = reg.workspace_mut(&workspace_id) {
if w.layout.is_none() {
w.layout = Some(LayoutNode::leaf(sid.clone()));
}
}
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated {
surface_id: sid.clone(), workspace_id: workspace_id.clone(),
}));
emit_layout(reg, &workspace_id, clients);
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await;
}
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; }
}
}
Cmd::SplitSurface { surface_id, dir, command, args } => {
let Some(ws_id) = reg.workspace_of(&surface_id) else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
};
let ws = reg.workspace(&ws_id).cloned().unwrap();
let new_sid = reg.new_surface_id();
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, exit_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
reg.set_live(handle);
reg.add_surface_spec(&ws_id, new_sid.clone(), spec);
let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V };
if let Some(w) = reg.workspace_mut(&ws_id) {
let mut root = w.layout.take().unwrap_or_else(|| LayoutNode::leaf(surface_id.clone()));
spacesh_core::ops::split_leaf(&mut root, &surface_id, orient, true, new_sid.clone());
w.layout = Some(root);
}
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: ws_id.clone() }));
emit_layout(reg, &ws_id, clients);
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::json!({ "surface_id": new_sid.0 }))).await;
}
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; }
}
}
Cmd::SetRatios { workspace_id, node_path, ratios } => {
let ok_set = reg.workspace_mut(&workspace_id).map(|w| {
if let Some(l) = w.layout.as_mut() {
spacesh_core::ops::set_ratios(l, &node_path, &ratios)
} else { false }
}).unwrap_or(false);
if ok_set {
emit_layout(reg, &workspace_id, clients);
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "BAD_REQUEST", "invalid node_path or ratios")).await;
}
}
Cmd::MoveSurface { surface_id, target_surface_id, edge } => {
let Some(ws_id) = reg.workspace_of(&surface_id) else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
};
if let Some(w) = reg.workspace_mut(&ws_id) {
if let Some(root) = w.layout.take() {
w.layout = Some(spacesh_core::ops::move_leaf(root, &surface_id, &target_surface_id, edge));
}
}
emit_layout(reg, &ws_id, clients);
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::ApplyPreset { workspace_id, preset_id, slots } => {
let Some(count) = spacesh_core::presets::slot_count(&preset_id) else {
let _ = out.send(err(id, "BAD_REQUEST", "unknown preset")).await; return;
};
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return;
};
// Kill current panels of this workspace.
let existing: Vec<SurfaceId> = ws.surfaces.keys().cloned().collect();
for sid in &existing {
if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; }
reg.remove_surface(sid);
subs.remove(sid);
}
// Spawn `count` panels (slots padded/truncated to count).
let mut new_ids = Vec::new();
for i in 0..count {
let slot = slots.get(i);
let new_sid = reg.new_surface_id();
let command = slot.and_then(|s| s.command.clone());
let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
let args = slot.map(|s| s.args.clone()).unwrap_or_default();
let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false };
match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone());
reg.set_live(handle);
reg.add_surface_spec(&workspace_id, new_sid.clone(), spec);
new_ids.push(new_sid);
}
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; }
}
}
if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) {
if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); }
}
for sid in &new_ids {
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() }));
}
emit_layout(reg, &workspace_id, clients);
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::<Vec<_>>() }))).await;
}
Cmd::RestartSurface { surface_id } => {
if reg.is_running(&surface_id) {
let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running
}
let Some(spec) = reg.surface_spec(&surface_id) else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return;
};
let ws_id = reg.workspace_of(&surface_id).unwrap();
match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, exit_tx.clone()) {
Ok(handle) => {
spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone());
reg.set_live(handle);
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() }));
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; }
}
}
Cmd::CloseWorkspace { workspace_id } => {
let ids = reg.close_workspace(&workspace_id);
for sid in &ids { subs.remove(sid); }
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceClosed { workspace_id: workspace_id.clone() }));
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::SetWorkspaceMeta { workspace_id, name, group_id, unread, order } => {
let found = reg.workspace_mut(&workspace_id).map(|w| {
if let Some(n) = name { w.name = n; }
if let Some(g) = group_id { w.group_id = g; }
if let Some(u) = unread { w.unread = u; }
if let Some(o) = order { w.order = o; }
}).is_some();
if found {
if let Some(view) = reg.workspace_view(&workspace_id) {
broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view }));
}
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await;
}
}
Cmd::CreateGroup { name, color } => {
let gid = reg.create_group(name, color);
broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() }));
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::json!({ "group_id": gid.0 }))).await;
}
Cmd::SetGroup { group_id, name, color, order } => {
let found = reg.group_mut(&group_id).map(|g| {
if let Some(n) = name { g.name = n; }
if let Some(c) = color { g.color = c; }
if let Some(o) = order { g.order = o; }
}).is_some();
if found {
broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() }));
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "group")).await;
}
}
Cmd::DeleteGroup { group_id } => {
reg.delete_group(&group_id);
broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() }));
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Input { surface_id, bytes } => {
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else {
let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await; return;
};
if let Some(s) = reg.live(&surface_id) {
let _ = s.tx.send(crate::surface::SurfaceMsg::Input(decoded)).await;
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Resize { surface_id, cols, rows } => {
if let Some(s) = reg.live(&surface_id) {
let _ = s.tx.send(crate::surface::SurfaceMsg::Resize { cols, rows }).await;
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Attach { surface_id } => {
if let Some(s) = reg.live(&surface_id) {
let (reply_tx, reply_rx) = oneshot::channel();
if s.tx.send(crate::surface::SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() {
if let Ok((snap, _sub)) = reply_rx.await {
subs.entry(surface_id.clone()).or_default().push(client);
let _ = out.send(ok(id, serde_json::json!({
"snapshot": snap.ansi, "cols": snap.cols, "rows": snap.rows,
"cursor_row": snap.cursor_row, "cursor_col": snap.cursor_col,
}))).await;
return;
}
}
let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
} else {
// stopped panel: no live stream, return an empty snapshot so the GUI shows the restart overlay.
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await;
}
}
Cmd::Detach { surface_id } => {
if let Some(list) = subs.get_mut(&surface_id) { list.retain(|c| *c != client); }
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Focus { surface_id: _ } => { let _ = out.send(ok(id, serde_json::Value::Null)).await; }
Cmd::Close { surface_id } => {
if reg.surface_spec(&surface_id).is_some() {
if let Some(h) = reg.live(&surface_id) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; }
let ws_id = reg.workspace_of(&surface_id);
reg.remove_surface(&surface_id);
subs.remove(&surface_id);
broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() }));
if let Some(ws_id) = ws_id { emit_layout(reg, &ws_id, clients); }
persister.mark_dirty(reg.persist_state());
let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else {
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
}
}
Cmd::Status => {
let (groups, workspaces) = reg.status();
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
}
Cmd::Shutdown => {
let _ = out.send(ok(id, serde_json::Value::Null)).await;
std::process::exit(0);
}
}
}
/// Emit a `layout_changed` event for a workspace's current tree.
fn emit_layout(reg: &Registry, ws_id: &WorkspaceId, clients: &HashMap<ClientId, ClientTx>) {
if let Some(w) = reg.workspace(ws_id) {
broadcast_evt(clients, &Envelope::Evt(Evt::LayoutChanged {
workspace_id: ws_id.clone(), layout: w.layout.clone(),
}));
}
}
```
Update the imports at the top of `server.rs` to include the new proto items used: `use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId};` (and keep `codec`, `base64::Engine`).
- [ ] **Step 2: Update the existing M0+M1 server tests for the new API**
Existing tests call the old `req`/helpers — those still work (wire-level). But `unknown_surface_returns_not_found` sends `Input` to `s_nope`; with the new code that returns `NOT_FOUND` (live lookup) — still passes. `open_new_surface_attach_streams_output` and `reattach_returns_snapshot_with_prior_output`: `status`/`open`/`new_surface`/`attach` wire shapes are unchanged for those flows, so they pass. Keep `tempdir_path`, `wait_for_socket`, the `serial()` guard, and the `multi_thread` flavor. The only change: tests that call `serve(&sock)` must now pass a store — update those call sites:
```rust
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; });
```
Apply to all three existing integration tests (each constructs `dir`/`sock` already).
- [ ] **Step 3: Add M2 integration tests**
Append to the `tests` module in `server.rs`:
```rust
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_preset_builds_tree_and_status_reports_it() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::ApplyPreset {
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
preset_id: "2x2".into(),
slots: vec![],
}).await;
let ids = res_data(&r)["surface_ids"].as_array().unwrap();
assert_eq!(ids.len(), 4);
let r = req(&mut s, 3, Cmd::Status).await;
let wss = res_data(&r)["workspaces"].as_array().unwrap();
let w0 = wss.iter().find(|w| w["id"] == ws).unwrap();
assert!(w0["layout"].is_object(), "layout tree present");
assert!(w0["layout"].to_string().contains("split"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cold_restart_restores_structure_stopped() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let state_path = dir.join("state.json");
let sock = dir.join("sock");
let ws;
{
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
req(&mut s, 2, Cmd::ApplyPreset {
workspace_id: spacesh_proto::WorkspaceId(ws.clone()), preset_id: "2tb".into(), slots: vec![],
}).await;
// allow debounce (500ms) to flush state.json
tokio::time::sleep(tokio::time::Duration::from_millis(900)).await;
}
// "cold start": new store on the same state file, new socket.
let sock_b = dir.join("sock2");
let store_b: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let sb2 = sock_b.clone();
tokio::spawn(async move { let _ = serve(&sock_b, store_b).await; });
wait_for_socket(&sb2).await;
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
let r = req(&mut s2, 1, Cmd::Status).await;
let wss = res_data(&r)["workspaces"].as_array().unwrap();
let w0 = wss.iter().find(|w| w["id"] == ws).expect("workspace restored");
let surfaces = w0["surfaces"].as_object().unwrap();
assert_eq!(surfaces.len(), 2, "2tb panels restored");
for (_id, sv) in surfaces {
assert_eq!(sv["running"], false, "restored panels are stopped");
}
}
```
- [ ] **Step 4: Run the full suite (3×) to confirm green & non-flaky**
Run: `cargo test --workspace > /tmp/m2.log 2>&1; echo EXIT=$?` — repeat 3×. All three must be 0.
Expected: all crate tests pass, including the new layout/preset/state_store/persist/registry/server tests.
- [ ] **Step 5: Commit**
```bash
git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs crates/spaceshd/src/registry.rs crates/spaceshd/src/surface.rs
git commit -m "feat(daemon): M2 command dispatch, layout events, cold-start restore, persistence wiring"
```
---
## Phase 8 — app: layout engine & sidebar
### Task 11: TS types + bridge for M2
**Files:**
- Create: `app/src/layoutTypes.ts`
- Modify: `app/src/socketBridge.ts`
- [ ] **Step 1: Layout TS types**
Create `app/src/layoutTypes.ts`:
```ts
export type Orient = "h" | "v";
export type LayoutNode =
| { leaf: { surface_id: string } }
| { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } };
export interface SurfaceView {
spec: {
command: string;
args: string[];
cwd: string;
agent_label: string | null;
cols: number;
rows: number;
autostart: boolean;
};
running: boolean;
}
export interface Group {
id: string;
name: string;
color: string;
order: number;
}
export interface WorkspaceView {
id: string;
path: string;
name: string;
group_id: string | null;
order: number;
unread: boolean;
layout: LayoutNode | null;
surfaces: Record<string, SurfaceView>;
}
export function leafIds(node: LayoutNode | null): string[] {
if (!node) return [];
if ("leaf" in node) return [node.leaf.surface_id];
return node.split.children.flatMap(leafIds);
}
```
- [ ] **Step 2: Extend the bridge**
Append to `app/src/socketBridge.ts` (and update `getStatus` + event types):
```ts
import type { Group, WorkspaceView, LayoutNode } from "./layoutTypes";
export interface StatusResult {
groups: Group[];
workspaces: WorkspaceView[];
}
export async function getStatusFull(): Promise<StatusResult> {
return await invoke<StatusResult>("status");
}
export async function splitSurface(surfaceId: string, dir: "right" | "down", command?: string, args: string[] = []): Promise<string> {
const data = await invoke<{ surface_id: string }>("split_surface", { surfaceId, dir, command: command ?? null, args });
return data.surface_id;
}
export async function setRatios(workspaceId: string, nodePath: number[], ratios: number[]): Promise<void> {
await invoke("set_ratios", { workspaceId, nodePath, ratios });
}
export async function moveSurface(surfaceId: string, targetSurfaceId: string, edge: "left" | "right" | "top" | "bottom"): Promise<void> {
await invoke("move_surface", { surfaceId, targetSurfaceId, edge });
}
export async function applyPreset(workspaceId: string, presetId: string, slots: { command?: string; args?: string[] }[]): Promise<string[]> {
const data = await invoke<{ surface_ids: string[] }>("apply_preset", {
workspaceId, presetId,
slots: slots.map((s) => ({ command: s.command ?? null, args: s.args ?? [] })),
});
return data.surface_ids;
}
export async function restartSurface(surfaceId: string): Promise<void> {
await invoke("restart_surface", { surfaceId });
}
export async function closeWorkspaceCmd(workspaceId: string): Promise<void> {
await invoke("close_workspace", { workspaceId });
}
export async function setWorkspaceMeta(workspaceId: string, meta: { name?: string; groupId?: string | null; unread?: boolean; order?: number }): Promise<void> {
await invoke("set_workspace_meta", {
workspaceId,
name: meta.name ?? null,
groupId: meta.groupId === undefined ? null : meta.groupId,
unread: meta.unread ?? null,
order: meta.order ?? null,
});
}
export async function createGroup(name: string, color: string): Promise<string> {
const data = await invoke<{ group_id: string }>("create_group", { name, color });
return data.group_id;
}
export async function closeSurfaceCmd(surfaceId: string): Promise<void> {
await invoke("close_surface", { surfaceId });
}
```
Note: the Tauri command for `set_workspace_meta`'s `group_id: Option<Option<GroupId>>` — the bridge command in `bridge.rs` (Task 13) maps the JS `groupId` (string | null | undefined) accordingly. Keep `getStatus` (M0+M1) for back-compat or migrate callers to `getStatusFull`.
- [ ] **Step 3: Type-check**
Run: `cd app && npm run build`
Expected: PASS (tsc + vite). (Components using these come in Tasks 1214; this step only checks the new types/bridge compile.)
- [ ] **Step 4: Commit**
```bash
git add app/src/layoutTypes.ts app/src/socketBridge.ts
git commit -m "feat(app): M2 layout TS types + bridge commands"
```
---
### Task 12: LayoutEngine — recursive render + splitter resize + stopped overlay
**Files:**
- Create: `app/src/LayoutEngine.tsx`
- [ ] **Step 1: Implement LayoutEngine**
Create `app/src/LayoutEngine.tsx`:
```tsx
import { useRef } from "react";
import { TerminalView } from "./TerminalView";
import type { LayoutNode } from "./layoutTypes";
import { setRatios, restartSurface } from "./socketBridge";
interface Props {
workspaceId: string;
layout: LayoutNode | null;
/** surface_id -> running flag, from the latest status/events. */
running: Record<string, boolean>;
}
export function LayoutEngine({ workspaceId, layout, running }: Props) {
if (!layout) {
return <div style={{ color: "#666", padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
}
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} />;
}
function Node({ workspaceId, node, path, running }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean> }) {
if ("leaf" in node) {
const id = node.leaf.surface_id;
if (running[id] === false) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", background: "#0A0D12", color: "#8B97A6", flexDirection: "column", gap: 10 }}>
<div style={{ fontFamily: "monospace", fontSize: 13 }}>Process exited</div>
<button onClick={() => void restartSurface(id)} style={{ padding: "6px 14px" }}> Restart</button>
</div>
);
}
return <TerminalView key={id} surfaceId={id} />;
}
const { orient, ratios, children } = node.split;
const dir = orient === "h" ? "row" : "column";
return (
<div style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
{children.map((child, i) => (
<Pane key={i} grow={ratios[i] ?? 1} isLast={i === children.length - 1} orient={orient}
onResize={(deltaFrac) => {
const next = [...ratios];
next[i] = Math.max(0.05, next[i] + deltaFrac);
next[i + 1] = Math.max(0.05, next[i + 1] - deltaFrac);
void setRatios(workspaceId, path, next);
}}>
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} />
</Pane>
))}
</div>
);
}
function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const startDrag = (e: React.MouseEvent) => {
e.preventDefault();
const parent = ref.current?.parentElement;
if (!parent) return;
const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
const start = orient === "h" ? e.clientX : e.clientY;
let last = start;
const move = (ev: MouseEvent) => {
const cur = orient === "h" ? ev.clientX : ev.clientY;
const delta = (cur - last) / total;
last = cur;
onResize(delta);
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
return (
<>
<div ref={ref} style={{ flexGrow: grow, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "hidden", position: "relative" }}>
{children}
</div>
{!isLast && (
<div onMouseDown={startDrag}
style={{
flex: "0 0 4px",
cursor: orient === "h" ? "col-resize" : "row-resize",
background: "#232A33",
}} />
)}
</>
);
}
```
Note: `setRatios` is throttled at the daemon/visual level by React batching; for smoother drag the implementer may add a ~30ms throttle, but per-move calls are acceptable for the slice (resize is occasional). The daemon clamps and normalizes ratios authoritatively, and `layout_changed` re-syncs.
- [ ] **Step 2: Type-check**
Run: `cd app && npm run build`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add app/src/LayoutEngine.tsx
git commit -m "feat(app): LayoutEngine — recursive split render, splitter resize, stopped overlay"
```
---
### Task 13: Tauri bridge commands for M2
**Files:**
- Modify: `app/src-tauri/src/bridge.rs`
- Modify: `app/src-tauri/src/lib.rs` (register new handlers)
- [ ] **Step 1: Add the command wrappers**
Append to `app/src-tauri/src/bridge.rs` (before the existing `close_surface` or alongside the other `#[tauri::command]` fns), each mirroring the M0+M1 pattern (`state.request(Cmd::...).await``data_of`):
```rust
use spacesh_proto::message::{SplitDir, Edge, PresetSlot};
use spacesh_proto::ids::{GroupId, WorkspaceId};
#[tauri::command]
pub async fn split_surface(state: BridgeState<'_>, surface_id: String, dir: String, command: Option<String>, args: Vec<String>) -> Result<Value, String> {
let dir = if dir == "down" { SplitDir::Down } else { SplitDir::Right };
data_of(state.request(Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir, command, args }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn set_ratios(state: BridgeState<'_>, workspace_id: String, node_path: Vec<u32>, ratios: Vec<f32>) -> Result<Value, String> {
data_of(state.request(Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path, ratios }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn move_surface(state: BridgeState<'_>, surface_id: String, target_surface_id: String, edge: String) -> Result<Value, String> {
let edge = match edge.as_str() { "left" => Edge::Left, "top" => Edge::Top, "bottom" => Edge::Bottom, _ => Edge::Right };
data_of(state.request(Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target_surface_id), edge }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_id: String, slots: Vec<PresetSlot>) -> Result<Value, String> {
data_of(state.request(Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id, slots }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Result<Value, String> {
data_of(state.request(Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option<String>, group_id: Option<String>, unread: Option<bool>, order: Option<u32>) -> Result<Value, String> {
// group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup".
let gid = match group_id {
None => None,
Some(s) if s.is_empty() => Some(None),
Some(s) => Some(Some(GroupId(s))),
};
data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result<Value, String> {
data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?)
}
```
`PresetSlot` derives `Deserialize`, so Tauri can accept it directly from JS (`{ command, args }`).
- [ ] **Step 2: Register the handlers**
In `app/src-tauri/src/lib.rs`, add the new commands to `tauri::generate_handler![...]`:
```rust
bridge::split_surface,
bridge::set_ratios,
bridge::move_surface,
bridge::apply_preset,
bridge::restart_surface,
bridge::close_workspace,
bridge::set_workspace_meta,
bridge::create_group,
```
- [ ] **Step 3: Build**
Run: `cd app/src-tauri && cargo build`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs
git commit -m "feat(app): tauri bridge commands for M2 (split/ratios/move/preset/restart/groups/meta)"
```
---
### Task 14: Sidebar, PresetPicker, Wizard, App rewire
**Files:**
- Create: `app/src/Sidebar.tsx`, `app/src/PresetPicker.tsx`, `app/src/Wizard.tsx`
- Modify: `app/src/App.tsx`
- [ ] **Step 1: PresetPicker**
Create `app/src/PresetPicker.tsx`:
```tsx
export const PRESETS: { id: string; label: string; slots: number }[] = [
{ id: "1", label: "1", slots: 1 },
{ id: "2lr", label: "2↔", slots: 2 },
{ id: "2tb", label: "2↕", slots: 2 },
{ id: "2+1", label: "2+1", slots: 3 },
{ id: "1+2", label: "1+2", slots: 3 },
{ id: "3", label: "3", slots: 3 },
{ id: "2x2", label: "2×2", slots: 4 },
{ id: "4", label: "4", slots: 4 },
{ id: "2x3", label: "2×3", slots: 6 },
{ id: "2x4", label: "2×4", slots: 8 },
];
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{PRESETS.map((p) => (
<button key={p.id} onClick={() => onSelect(p.id)}
style={{
padding: "6px 10px", borderRadius: 6, fontFamily: "monospace", fontSize: 12,
background: p.id === selected ? "#1A2029" : "transparent",
border: p.id === selected ? "1px solid #4C8DFF" : "1px solid #232A33",
color: p.id === selected ? "#E6EDF3" : "#8B97A6", cursor: "pointer",
}}>
{p.label}
</button>
))}
</div>
);
}
```
- [ ] **Step 2: Wizard**
Create `app/src/Wizard.tsx`:
```tsx
import { useState } from "react";
import { PresetPicker, PRESETS } from "./PresetPicker";
import { openWorkspace, applyPreset } from "./socketBridge";
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
const [path, setPath] = useState(".");
const [preset, setPreset] = useState("2x2");
const [agents, setAgents] = useState<string[]>([]);
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
const agentChoices = ["shell", "claude", "codex", "gemini"];
async function create() {
const ws = await openWorkspace(path);
const slotSpecs = Array.from({ length: slots }, (_, i) => {
const a = agents[i] ?? "shell";
return a === "shell" ? {} : { command: a };
});
await applyPreset(ws, preset, slotSpecs);
onDone(ws);
}
return (
<div style={{ position: "fixed", inset: 0, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
<input value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
{Array.from({ length: slots }, (_, i) => (
<select key={i} value={agents[i] ?? "shell"} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
))}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
<button onClick={() => void create()} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>Create workspace</button>
</div>
</div>
</div>
);
}
```
- [ ] **Step 3: Sidebar**
Create `app/src/Sidebar.tsx`:
```tsx
import type { Group, WorkspaceView } from "./layoutTypes";
export function Sidebar({
groups, workspaces, activeId, onSelect, onNew,
}: {
groups: Group[];
workspaces: WorkspaceView[];
activeId: string | null;
onSelect: (id: string) => void;
onNew: () => void;
}) {
const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
const ungrouped = byGroup(null);
const row = (w: WorkspaceView) => (
<div key={w.id} onClick={() => onSelect(w.id)}
style={{
display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer",
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
color: w.id === activeId ? "#E6EDF3" : "#8B97A6",
}}>
<span style={{ width: 10, height: 10, borderRadius: "50%", border: "2px solid #5A6573" }} />
<span style={{ flex: 1 }}>{w.name}</span>
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />}
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span>
</div>
);
return (
<div style={{ width: 248, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", overflowY: "auto" }}>
<button onClick={onNew} style={{ width: "100%", padding: 8, marginBottom: 16, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 7 }}>+ New workspace</button>
{groups.sort((a, b) => a.order - b.order).map((g) => (
<div key={g.id} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "0 4px", marginBottom: 4 }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
<span style={{ fontFamily: "Inter", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: "#8B97A6" }}>{g.name.toUpperCase()}</span>
</div>
{byGroup(g.id).map(row)}
</div>
))}
{ungrouped.length > 0 && <div style={{ marginTop: 8 }}>{ungrouped.map(row)}</div>}
</div>
);
}
```
(Drag-reorder: wire HTML5 drag handlers calling `setWorkspaceMeta({ order, groupId })` / `setGroup({ order })`. Kept out of the minimal component above to stay focused; add as a follow-up step if time allows — the model + commands already support it.)
- [ ] **Step 4: Rewire App.tsx around workspaces + LayoutEngine**
Replace `app/src/App.tsx`:
```tsx
import { useEffect, useState, useCallback } from "react";
import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar";
import { PresetPicker } from "./PresetPicker";
import { Wizard } from "./Wizard";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
import type { Group, WorkspaceView } from "./layoutTypes";
export function App() {
const [groups, setGroups] = useState<Group[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [running, setRunning] = useState<Record<string, boolean>>({});
const [wizard, setWizard] = useState(false);
const refresh = useCallback(async () => {
const st = await getStatusFull();
setGroups(st.groups);
setWorkspaces(st.workspaces);
const run: Record<string, boolean> = {};
st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; }));
setRunning(run);
if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id);
}, [activeId]);
useEffect(() => {
void refresh();
const unlisten = onDaemonEvent(() => { void refresh(); });
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); });
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
}, [refresh]);
const active = workspaces.find((w) => w.id === activeId) ?? null;
return (
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={setActiveId} onNew={() => setWizard(true)} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && (
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
<PresetPicker selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
</div>
)}
<div style={{ flex: 1, minHeight: 0 }}>
{active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} />
: <div style={{ color: "#666", padding: 24 }}>No workspace create one to begin.</div>}
</div>
</div>
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
</div>
);
}
```
- [ ] **Step 5: Build + type-check**
Run: `cd app && npm run build && cd src-tauri && cargo build`
Expected: both PASS.
- [ ] **Step 6: Commit**
```bash
git add app/src/Sidebar.tsx app/src/PresetPicker.tsx app/src/Wizard.tsx app/src/App.tsx
git commit -m "feat(app): sidebar, preset picker, wizard, App rewired around workspaces + LayoutEngine"
```
---
## Definition of Done
- [ ] `cargo test --workspace` — green & non-flaky across 3 consecutive runs.
- [ ] `cd app && npm run build` and `cd app/src-tauri && cargo build` — both clean.
- [ ] **Manual** (`npm run tauri dev`): new workspace via wizard with a preset; split a panel (`⌘⇧T`); drag a splitter to resize; apply a preset from the toolbar; close a panel and watch the tree collapse; quit the daemon (`pkill spaceshd`) and relaunch the app → workspaces + layout restored, panels show "stopped" with Restart; restart a stopped panel.
## Notes for the implementer
- **Tasks 810 compile together.** The registry rewrite (Task 8) breaks `server.rs` until Task 10 updates it. Implement 8 → 9 → 10 and run `cargo test --workspace` at the end of Task 10. Don't expect a green bar between 8 and 10.
- **Test robustness:** every new socket/PTY integration test uses `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` AND `let _serial = crate::test_support::serial();` as the first line — same pattern that de-flaked M1.
- **Persistence timing in tests:** the debounce is 500ms; the cold-restart test sleeps 900ms before tearing down so `state.json` is flushed. Don't shorten below ~700ms.
- **Single fan-out path preserved:** new surfaces (new_surface/split/preset/restart) all go through `spawn_from_spec``spawn_output_bridge` → router → client, exactly like M0+M1. Don't add a second output path.
- **Out of slice:** status rings colored by agent state (M3), auto-unread from events (M3), zoom/search/diff/notifications (M5), remote (M6), auth (separate). The status ring in Sidebar is a static placeholder.
- **Two documented partials within M2** (model + protocol fully support both; only the wiring is deferred to keep the slice focused — pick them up if time allows):
1. **Cold-start autostart honoring.** `SurfaceSpec.autostart` is persisted and restored, but the router does not auto-`restart_surface` autostart panels on cold start (default is off and no M2 UI sets it true, so the path is untestable end-to-end this slice). To finish: after `reg.restore(initial)` in `router`, iterate restored surfaces with `spec.autostart == true` and spawn each via `spawn_from_spec` + `spawn_output_bridge` + `set_live`, emitting `surface_restarted`.
2. **Sidebar drag-reorder UI.** The full command surface is wired in the bridge — `set_workspace_meta { order, group_id }`, `set_group { name, color, order }`, `delete_group`, `create_group` — so workspace and group reordering/editing are functional via command today. The deferred piece is only the GUI affordance: the `Sidebar` component renders groups/order/unread/count but does not yet emit HTML5 drag handlers. To finish: add `draggable` rows + `onDrop` calling `setWorkspaceMeta`/`setGroup` with the new order.
```