Files
spaceshell/DOCS/superpowers/plans/2026-06-09-spacesh-m2.md
T
2026-06-09 21:08:40 +07:00

102 KiB
Raw Blame History

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:

#[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:

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:

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
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:

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
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:

    #[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:

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:

/// 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):

    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:

    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
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]:

spacesh-proto = { path = "../spacesh-proto" }
  • Step 2: Write the failing tests

Create crates/spacesh-core/src/ops.rs:

//! 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:

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
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:

//! 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
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:

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
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:

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
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:

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

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:

    #[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
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:

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:

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:

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.

#[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:

        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:

    #[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
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:

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

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
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:

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
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::...).awaitdata_of):

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![...]:

            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
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:

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:

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:

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:

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
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_specspawn_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. set_workspace_meta { order, group_id } and set_group { order } exist and are wired in the bridge; the Sidebar component renders groups/order but does not yet emit HTML5 drag handlers. To finish: add draggable rows + onDrop calling setWorkspaceMeta/setGroup with the new order. Reordering is fully functional via command today; only the drag affordance is missing.