Skip to main content

agentmux_srv/reducer/
layout.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9/// Phase E.4 (Option A) — set the focused layout-node id on a tab.
10/// Errors (non-fatal) if the tab is unknown to the reducer; no-op
11/// short-circuit when the value is already current. Empty `node_id`
12/// clears the field. Bumps the version only on real changes so a
13/// burst of identical sets doesn't churn the event stream.
14pub(super) fn handle_set_focused_node(state: &mut State, tab_id: String, node_id: String) -> Vec<Event> {
15    let Some(tab) = state.tabs.get_mut(&tab_id) else {
16        let v = state.bump_version();
17        return vec![Event::Error {
18            code: ErrorCode::InvalidCommand,
19            message: format!("SetFocusedNode: unknown tab {}", tab_id),
20            fatal: false,
21            version: v,
22        }];
23    };
24    if tab.focused_node_id == node_id {
25        return Vec::new();
26    }
27    tab.focused_node_id = node_id.clone();
28    let v = state.bump_version();
29    vec![Event::FocusedNodeChanged {
30        tab_id,
31        node_id,
32        version: v,
33    }]
34}
35
36/// Phase E.4 (Option A) — set the magnified layout-node id on a tab.
37/// Same shape as `handle_set_focused_node`. Empty `node_id` is the
38/// toggle-off / clear case.
39pub(super) fn handle_set_magnified_node(state: &mut State, tab_id: String, node_id: String) -> Vec<Event> {
40    let Some(tab) = state.tabs.get_mut(&tab_id) else {
41        let v = state.bump_version();
42        return vec![Event::Error {
43            code: ErrorCode::InvalidCommand,
44            message: format!("SetMagnifiedNode: unknown tab {}", tab_id),
45            fatal: false,
46            version: v,
47        }];
48    };
49    if tab.magnified_node_id == node_id {
50        return Vec::new();
51    }
52    tab.magnified_node_id = node_id.clone();
53    let v = state.bump_version();
54    vec![Event::MagnifiedNodeChanged {
55        tab_id,
56        node_id,
57        version: v,
58    }]
59}
60
61pub(super) fn handle_layout_clear(
62    state: &mut State,
63    tab_id: String,
64    correlation_id: String,
65) -> Vec<Event> {
66    let Some(tab) = state.tabs.get_mut(&tab_id) else {
67        let v = state.bump_version();
68        return vec![Event::Error {
69            code: ErrorCode::InvalidCommand,
70            message: format!("LayoutClear: unknown tab {}", tab_id),
71            fatal: false,
72            version: v,
73        }];
74    };
75    tab.rootnode = None;
76    tab.focused_node_id = String::new();
77    tab.magnified_node_id = String::new();
78    let v = state.bump_version();
79    vec![Event::LayoutCleared {
80        tab_id,
81        correlation_id,
82        version: v,
83    }]
84}
85
86pub(super) fn handle_layout_set_tree(
87    state: &mut State,
88    tab_id: String,
89    new_tree: Option<agentmux_common::LayoutNode>,
90    correlation_id: String,
91    slices: Option<agentmux_common::LayoutClientSlices>,
92) -> Vec<Event> {
93    // Computed before the `state.tabs` borrow below — `blocks` and `tabs`
94    // are disjoint fields, but an owned snapshot avoids any doubt.
95    let live_blocks: std::collections::HashSet<String> = state.blocks.keys().cloned().collect();
96    let Some(tab) = state.tabs.get_mut(&tab_id) else {
97        let v = state.bump_version();
98        return vec![Event::Error {
99            code: ErrorCode::InvalidCommand,
100            message: format!("LayoutSetTree: unknown tab {}", tab_id),
101            fatal: false,
102            version: v,
103        }];
104    };
105    tab.rootnode = new_tree.clone();
106    // SPEC_864 Phase 2 — a full-row push carries focus/magnify in `slices`
107    // (REPLACE semantics; empty string = clear). leaforder /
108    // pending_backend_actions are not modeled in TabRecord — they ride the
109    // event through to the persist subscriber untouched.
110    if let Some(s) = &slices {
111        tab.focused_node_id = s.focused_node_id.clone();
112        tab.magnified_node_id = s.magnified_node_id.clone();
113    }
114    // when the tree is wiped, focused/
115    // magnified ids would point at non-existent nodes. Match
116    // `handle_layout_clear`'s contract for the empty-tree case.
117    if new_tree.is_none() {
118        tab.focused_node_id = String::new();
119        tab.magnified_node_id = String::new();
120    }
121    // Referential-integrity enforcement (see
122    // `backend::layout::prune_dangling_block_refs`'s doc comment): this is
123    // the exact vector — a wholesale tree push — that let a stale frontend
124    // copy resurrect a deleted block's leaf
125    // (INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md).
126    // Re-derive `new_tree`/the event from the now-authoritative (possibly
127    // pruned) `tab.rootnode` rather than the caller's original value, so
128    // what gets persisted matches what the reducer actually holds — a
129    // pruned `tab.rootnode` with a stale `new_tree` in the emitted event
130    // would just move the divergence downstream instead of closing it.
131    let pruned = crate::backend::layout::prune_dangling_block_refs(&mut tab.rootnode, &live_blocks);
132    if pruned > 0 {
133        reconcile_focus_magnify(tab);
134        tracing::warn!(
135            tab_id = %tab_id,
136            pruned,
137            "reducer: LayoutSetTree pruned dangling layout leaf/leaves referencing since-deleted blocks"
138        );
139    }
140    // Minimize-lock enforcement (same write-point-invariant shape as the
141    // prune above; SPEC_LAYOUT_MINIMIZE_LOCKED_STATE_REDESIGN_2026_07_16.md):
142    // a pushed tree that resized a minimized node gets snapped back here.
143    let snapped = crate::backend::layout::enforce_minimized_locks(&mut tab.rootnode);
144    if snapped > 0 {
145        tracing::warn!(
146            tab_id = %tab_id,
147            snapped,
148            "reducer: LayoutSetTree snapped minimize-locked node size(s) back to their locked values"
149        );
150    }
151    // Layout doctor (issue #2179): loudly attribute any surviving structural
152    // corruption to this write instead of letting it persist silently.
153    let violations = crate::backend::layout::validate_layout_invariants(&tab.rootnode);
154    if !violations.is_empty() {
155        tracing::error!(
156            tab_id = %tab_id,
157            violations = ?violations,
158            "layout-doctor: invariant violation(s) in tree persisted by LayoutSetTree"
159        );
160    }
161    let new_tree = tab.rootnode.clone();
162    let v = state.bump_version();
163    vec![Event::LayoutTreeReplaced {
164        tab_id,
165        new_tree,
166        correlation_id,
167        slices,
168        version: v,
169    }]
170}
171
172pub(super) fn handle_layout_insert_node(
173    state: &mut State,
174    tab_id: String,
175    node: agentmux_common::LayoutNode,
176    parent_id: Option<String>,
177    index: Option<usize>,
178    focus_after: bool,
179    magnify_after: bool,
180    correlation_id: String,
181) -> Vec<Event> {
182    let Some(tab) = state.tabs.get_mut(&tab_id) else {
183        let v = state.bump_version();
184        return vec![Event::Error {
185            code: ErrorCode::InvalidCommand,
186            message: format!("LayoutInsertNode: unknown tab {}", tab_id),
187            fatal: false,
188            version: v,
189        }];
190    };
191    // Three insert paths, in priority order:
192    //   1. Empty tree → promote new node to root.
193    //   2. parent_id given → insert under that specific parent at
194    //      `index` (or append if None). If parent_id is given but
195    //      doesn't resolve to a group node, REJECT with
196    //      Event::Error rather than fall back to the heuristic.
197    //      A silent-fallback path is a consistency hole: the
198    //      emitted event would echo the requested parent_id while
199    //      the actual mutation
200    //      went elsewhere, diverging the persist subscriber and
201    //      replay consumers from the reducer.
202    //   3. parent_id None → `findNextInsertLocation` heuristic.
203    let empty_tree = tab.rootnode.is_none();
204    if empty_tree {
205        // Empty-tree promotion must reject any explicit `parent_id`/
206        // `index` for the same divergence reason as the explicit-
207        // parent path below — the event would echo a target the
208        // tree cannot resolve, and a replay consumer or the persist
209        // subscriber would diverge from the reducer. Per
210        // `srv-phase-e4b-formal-spec-2026-05-03.md` §7.1, both
211        // fields must be `None` for empty-tree promote.
212        if parent_id.is_some() || index.is_some() {
213            let v = state.bump_version();
214            return vec![Event::Error {
215                code: ErrorCode::InvalidCommand,
216                message: format!(
217                    "LayoutInsertNode: empty tree cannot honour explicit parent_id={:?} / index={:?} (tab {})",
218                    parent_id, index, tab_id
219                ),
220                fatal: false,
221                version: v,
222            }];
223        }
224        tab.rootnode = Some(node.clone());
225    } else if let Some(pid) = parent_id.as_deref() {
226        let root = tab.rootnode.as_mut().expect("non-empty checked above");
227        match crate::backend::layout::find_node_by_id_mut(root, pid) {
228            // Minimize-locked parents (a dissolved column has `data: None`,
229            // so it would otherwise satisfy the group-node arm below) cannot
230            // host inserts — minimized is a locked state
231            // (SPEC_LAYOUT_MINIMIZE_LOCKED_STATE_REDESIGN_2026_07_16.md).
232            Some(parent_node) if crate::backend::layout::is_effectively_minimized(parent_node) => {
233                let v = state.bump_version();
234                return vec![Event::Error {
235                    code: ErrorCode::InvalidCommand,
236                    message: format!(
237                        "LayoutInsertNode: parent_id {:?} is minimize-locked, cannot host inserts (tab {})",
238                        pid, tab_id
239                    ),
240                    fatal: false,
241                    version: v,
242                }];
243            }
244            Some(parent_node) if parent_node.data.is_none() => {
245                let len = parent_node.children.len();
246                let target = index.map(|i| i.min(len)).unwrap_or(len);
247                parent_node.children.insert(target, node.clone());
248            }
249            Some(_) => {
250                // parent_id resolves to a leaf — can't host children.
251                let v = state.bump_version();
252                return vec![Event::Error {
253                    code: ErrorCode::InvalidCommand,
254                    message: format!(
255                        "LayoutInsertNode: parent_id {:?} is a leaf node, cannot host children (tab {})",
256                        pid, tab_id
257                    ),
258                    fatal: false,
259                    version: v,
260                }];
261            }
262            None => {
263                let v = state.bump_version();
264                return vec![Event::Error {
265                    code: ErrorCode::InvalidCommand,
266                    message: format!(
267                        "LayoutInsertNode: parent_id {:?} not found in tree (tab {})",
268                        pid, tab_id
269                    ),
270                    fatal: false,
271                    version: v,
272                }];
273            }
274        }
275    } else {
276        let root = tab.rootnode.as_mut().expect("non-empty checked above");
277        crate::backend::layout::insert_node(root, node.clone());
278    }
279    // honour focus_after / magnify_after.
280    // The schema documents these as the side effects callers rely on
281    // for "insert + activate" flows; ignoring them desyncs the snapshot
282    // from the event the caller's handler observed.
283    //
284    // a magnified node must also be the
285    // focused one. Without this, a `magnify_after=true,
286    // focus_after=false` insert leaves `focused_node_id` pointing at
287    // the prior pane while `magnified_node_id` points at the new one
288    // — a UI invariant violation (frontend treats magnify-implies-
289    // focus). Treat magnify as implying focus.
290    if focus_after || magnify_after {
291        tab.focused_node_id = node.id.clone();
292    }
293    if magnify_after {
294        tab.magnified_node_id = node.id.clone();
295    }
296    let v = state.bump_version();
297    vec![Event::LayoutNodeInserted {
298        tab_id,
299        node,
300        // pass the command's
301        // parent_id / index through to the event so subscribers see
302        // what the caller asked for, not a hardcoded `None, None`.
303        // The pure helper currently uses the `findNextInsertLocation`
304        // heuristic and ignores these hints — but the event is the
305        // record of what was *requested*; subscribers can correlate
306        // with the resulting tree by inspecting `node` itself.
307        parent_id,
308        index,
309        correlation_id,
310        version: v,
311    }]
312}
313
314pub(super) fn handle_layout_delete_node(
315    state: &mut State,
316    tab_id: String,
317    node_id: String,
318    correlation_id: String,
319) -> Vec<Event> {
320    let Some(tab) = state.tabs.get_mut(&tab_id) else {
321        let v = state.bump_version();
322        return vec![Event::Error {
323            code: ErrorCode::InvalidCommand,
324            message: format!("LayoutDeleteNode: unknown tab {}", tab_id),
325            fatal: false,
326            version: v,
327        }];
328    };
329    // Snapshot pre-delete focus/magnify so we can both detect
330    // direct-target hits AND post-walk for indirect orphaning.
331    let pre_focused = tab.focused_node_id.clone();
332    let pre_magnified = tab.magnified_node_id.clone();
333    let Some(root) = tab.rootnode.as_mut() else {
334        // Empty tree — nothing to delete; idempotent no-op (no event).
335        return Vec::new();
336    };
337    // `backend::layout::delete_node` leaves
338    // root deletion to the caller (returns Ok(()) with the root
339    // unmodified). Detect the root case here and clear the tree
340    // wholesale so the reducer state matches the
341    // `LayoutNodeDeleted` event we emit.
342    if root.id == node_id {
343        tab.rootnode = None;
344    } else if let Err(e) = crate::backend::layout::delete_node(root, &node_id) {
345        let v = state.bump_version();
346        return vec![Event::Error {
347            code: ErrorCode::InvalidCommand,
348            message: format!("LayoutDeleteNode: {} (tab {})", e, tab_id),
349            fatal: false,
350            version: v,
351        }];
352    }
353
354    // Two ways focus/magnify ids can reference nodes that no longer
355    // exist in the tree post-delete; both must be cleared:
356    //   - `delete_recursive` collapse-sole-child rewrites a
357    //        parent's id to the promoted child's id. If
358    //        focused/magnified was the parent's original id, that
359    //        id is gone from the tree even though the same physical
360    //        node remains.
361    //   - Deleting a container removes all descendants. If
362    //        focused/magnified was a descendant, it's gone too.
363    // Direct-target match (`pre_focused == node_id`) doesn't catch
364    // either case. Reconcile by walking the post-delete tree and
365    // clearing any focus/magnify id that no longer resolves.
366    let (was_focused, was_magnified) = clear_dangling_focus_magnify(tab, &pre_focused, &pre_magnified);
367
368    // SPEC_864 site #6 — carry the resulting tree so the persist
369    // subscriber can write db_layout without re-running the algebra
370    // (same single-writer shape as the 7 structural arms). A delete is
371    // the one structural op that can legitimately EMPTY the tree
372    // (root-orphan case), so `tree_cleared` disambiguates a real clear
373    // from a version-skewed sender's absent field.
374    let new_tree = tab.rootnode.clone();
375    let tree_cleared = new_tree.is_none();
376    let v = state.bump_version();
377    vec![Event::LayoutNodeDeleted {
378        tab_id,
379        node_id,
380        new_tree,
381        tree_cleared,
382        was_focused,
383        was_magnified,
384        correlation_id,
385        version: v,
386    }]
387}
388
389/// Clear the tab's focus/magnify ids when they no longer resolve to a node
390/// in its (already-edited) tree, given their values from BEFORE the edit.
391/// Returns `(was_focused, was_magnified)`: whether each one was cleared.
392/// Shared by every edit that can remove a leaf — a node delete, and a pane
393/// tab move that empties its source pane — see `handle_layout_delete_node`
394/// for the two ways an id can dangle after a delete.
395fn clear_dangling_focus_magnify(
396    tab: &mut crate::state::TabRecord,
397    pre_focused: &str,
398    pre_magnified: &str,
399) -> (bool, bool) {
400    let id_resolves = |id: &str| -> bool {
401        if id.is_empty() {
402            return true;
403        }
404        match tab.rootnode.as_ref() {
405            None => false,
406            Some(root) => crate::backend::layout::find_node_by_id(root, id).is_some(),
407        }
408    };
409    let was_focused = !pre_focused.is_empty() && !id_resolves(pre_focused);
410    let was_magnified = !pre_magnified.is_empty() && !id_resolves(pre_magnified);
411    if was_focused {
412        tab.focused_node_id = String::new();
413    }
414    if was_magnified {
415        tab.magnified_node_id = String::new();
416    }
417    (was_focused, was_magnified)
418}
419
420/// SPEC_864 site #6 — delete the layout node holding `block_id`.
421/// Resolution happens here because the reducer owns the tree; the
422/// caller (the `delete_block` saga) only knows the block id.
423///
424/// Unresolved block → silent idempotent no-op (empty event vec), NOT an
425/// error: every `delete_block` saga run dispatches this, and a block
426/// may legitimately have no layout node (the frontend's own delete
427/// flow already pushed a pruned tree via `LayoutSetTree`, the block
428/// was floating/never laid out, or the tab tree is empty).
429pub(super) fn handle_layout_delete_node_by_block(
430    state: &mut State,
431    tab_id: String,
432    block_id: String,
433    correlation_id: String,
434) -> Vec<Event> {
435    // One tab of a stacked pane: remove just that member and keep the pane
436    // and its other tabs. Deleting the whole leaf (the old behaviour, via
437    // `find_node_id_by_block` on the visible block) took every sibling tab
438    // with it. SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.4.
439    if let Some(root) = state.tabs.get_mut(&tab_id).and_then(|tab| tab.rootnode.as_mut()) {
440        if crate::backend::layout::remove_stack_member(root, &block_id) {
441            let new_tree = state.tabs.get(&tab_id).and_then(|tab| tab.rootnode.clone());
442            let v = state.bump_version();
443            return vec![Event::LayoutTreeReplaced {
444                tab_id,
445                new_tree,
446                correlation_id,
447                // Tree only: focus, magnify, leaf order and the pending
448                // action queue are left exactly as they are.
449                slices: None,
450                version: v,
451            }];
452        }
453    }
454    let node_id = match state
455        .tabs
456        .get(&tab_id)
457        .and_then(|tab| tab.rootnode.as_ref())
458        .and_then(|root| crate::backend::layout::find_leaf_containing_block(root, &block_id))
459        .map(|leaf| leaf.id.clone())
460    {
461        Some(id) => id,
462        // Unknown tab, empty tree, or block not in the tree — no-op.
463        None => return Vec::new(),
464    };
465    handle_layout_delete_node(state, tab_id, node_id, correlation_id)
466}
467
468/// SPEC_864 Phase 4 — append backend actions to a tab's
469/// `pendingbackendactions` queue. The reducer does NOT model the queue
470/// in `TabRecord` (it is a transient backend→frontend mailbox the
471/// frontend drains via REPLACE-clear slices, not layout-tree state), so
472/// this arm only validates and passes the payload through; the persist
473/// subscriber APPENDs it to `LayoutState.pendingbackendactions`.
474///
475/// `actions` must be a non-empty JSON array of `LayoutActionData`
476/// objects (the type lives in `agentmux-srv::backend::obj`, not the
477/// common crate — hence the raw-value carriage).
478pub(super) fn handle_layout_queue_backend_actions(
479    state: &mut State,
480    tab_id: String,
481    actions: serde_json::Value,
482    correlation_id: String,
483) -> Vec<Event> {
484    if !state.tabs.contains_key(&tab_id) {
485        return unknown_tab(state, "LayoutQueueBackendActions", &tab_id);
486    }
487    match actions.as_array() {
488        Some(arr) if !arr.is_empty() => {}
489        _ => {
490            return op_error(
491                state,
492                format!(
493                    "LayoutQueueBackendActions: actions must be a non-empty JSON array (tab {})",
494                    tab_id
495                ),
496            )
497        }
498    }
499    let v = state.bump_version();
500    vec![Event::LayoutBackendActionsQueued {
501        tab_id,
502        actions,
503        correlation_id,
504        version: v,
505    }]
506}
507
508// ── Phase 3 — remaining structural arms ─────────────────────────────────────
509//
510// Each arm resolves the tab, calls the existing pure fn in `backend::layout`,
511// runs `balance_node` (matching the frontend's post-action normalize so the
512// reducer's tree equals what the frontend would produce), reconciles any
513// dangling focus/magnify, and emits the granular event. The pure fns and the
514// commands/events already exist; this wires them.
515
516/// `Event::Error` for an unknown tab.
517// ── Pane tabs (SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2) ─────────
518
519/// Apply a pane-tab edit to the tab's tree and emit the tree-only
520/// `LayoutTreeReplaced` (no slices: focus, magnify, leaf order and the
521/// pending-action queue are untouched). `edit` returns false when it found
522/// nothing to act on.
523fn stack_tree_edit(
524    state: &mut State,
525    op: &str,
526    tab_id: String,
527    correlation_id: String,
528    not_found: String,
529    edit: impl FnOnce(&mut agentmux_common::LayoutNode) -> bool,
530) -> Vec<Event> {
531    let applied = match state.tabs.get_mut(&tab_id) {
532        None => return unknown_tab(state, op, &tab_id),
533        Some(tab) => tab.rootnode.as_mut().is_some_and(edit),
534    };
535    if !applied {
536        return op_error(state, format!("{op}: {not_found} (tab {tab_id})"));
537    }
538    let new_tree = state.tabs.get(&tab_id).and_then(|tab| tab.rootnode.clone());
539    let violations = crate::backend::layout::validate_layout_invariants(&new_tree);
540    if !violations.is_empty() {
541        tracing::error!(tab_id = %tab_id, op, violations = ?violations, "layout-doctor: invariant violation(s) after a pane-tab edit");
542    }
543    let v = state.bump_version();
544    vec![Event::LayoutTreeReplaced {
545        tab_id,
546        new_tree,
547        correlation_id,
548        slices: None,
549        version: v,
550    }]
551}
552
553pub(super) fn handle_layout_stack_push(
554    state: &mut State,
555    tab_id: String,
556    target_block_id: String,
557    block_id: String,
558    activate: bool,
559    correlation_id: String,
560) -> Vec<Event> {
561    // Only a block that lives in this tab may join one of its panes.
562    if state.blocks.get(&block_id).map(|b| b.tab_id.as_str()) != Some(tab_id.as_str()) {
563        return op_error(state, format!("LayoutStackPush: block {block_id} is not in tab {tab_id}"));
564    }
565    let not_found = format!("no pane holds block {target_block_id}");
566    stack_tree_edit(state, "LayoutStackPush", tab_id, correlation_id, not_found, |root| {
567        crate::backend::layout::push_stack_member(root, &target_block_id, &block_id, activate)
568    })
569}
570
571pub(super) fn handle_layout_stack_activate(
572    state: &mut State,
573    tab_id: String,
574    block_id: String,
575    correlation_id: String,
576) -> Vec<Event> {
577    let not_found = format!("no pane holds block {block_id}");
578    stack_tree_edit(state, "LayoutStackActivate", tab_id, correlation_id, not_found, |root| {
579        crate::backend::layout::activate_stack_member(root, &block_id)
580    })
581}
582
583/// Create a block and place it in a pane's tab stack in one step. Placement
584/// is validated FIRST: if no pane holds `target_block_id`, nothing is created
585/// — a block never exists without a pane.
586pub(super) fn handle_create_block_in_stack(
587    state: &mut State,
588    tab_id: String,
589    target_block_id: String,
590    meta: serde_json::Value,
591    activate: bool,
592) -> Vec<Event> {
593    let Some(tab) = state.tabs.get(&tab_id) else {
594        return unknown_tab(state, "CreateBlockInStack", &tab_id);
595    };
596    let placeable = tab
597        .rootnode
598        .as_ref()
599        .is_some_and(|root| crate::backend::layout::find_leaf_containing_block(root, &target_block_id).is_some());
600    if !placeable {
601        return op_error(
602            state,
603            format!("CreateBlockInStack: no pane holds block {target_block_id} (tab {tab_id})"),
604        );
605    }
606    let mut events = super::block::handle_create_block(state, tab_id.clone(), meta);
607    let Some(block_id) = events.iter().find_map(|e| match e {
608        Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
609        _ => None,
610    }) else {
611        return events; // the create itself failed; its Error event says why
612    };
613    events.extend(handle_layout_stack_push(state, tab_id, target_block_id, block_id, activate, String::new()));
614    events
615}
616
617/// Reorder `block_id` within its own pane, or move it into a different
618/// pane — a same-leaf reorder never changes which member is visible unless
619/// `activate` is set. Refuses (an `Error` event, tree untouched) when either
620/// block doesn't exist in the tab's tree.
621///
622/// Moving a pane's ONLY tab into another pane empties it, and the emptied
623/// pane is removed (`move_stack_member`). That one case can leave focus or
624/// magnify pointing at a node that's gone, so instead of the tree-only
625/// `LayoutTreeReplaced` it's reported exactly like a node delete: focus and
626/// magnify reconciled, and a `LayoutNodeDeleted` for the source leaf that
627/// carries the full resulting tree (the tab's new member included). No block
628/// is deleted either way — the moved block lives on in its new pane.
629/// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1,
630/// SPEC_PANE_TAB_DRAG_LANDING_FLASH_AND_LAST_TAB_CLOSE_2026_09_24.md §4.3.
631pub(super) fn handle_layout_stack_move(
632    state: &mut State,
633    tab_id: String,
634    block_id: String,
635    target_block_id: String,
636    position: agentmux_common::StackMovePosition,
637    activate: bool,
638    correlation_id: String,
639) -> Vec<Event> {
640    let not_found = format!("block {block_id} or {target_block_id} not found");
641    let emptied_source = state
642        .tabs
643        .get(&tab_id)
644        .and_then(|tab| tab.rootnode.as_ref())
645        .and_then(|root| crate::backend::layout::source_leaf_emptied_by_move(root, &block_id, &target_block_id));
646    let Some(source_node_id) = emptied_source else {
647        return stack_tree_edit(state, "LayoutStackMove", tab_id, correlation_id, not_found, |root| {
648            crate::backend::layout::move_stack_member(root, &block_id, &target_block_id, position, activate)
649        });
650    };
651
652    // The emptied-pane case. `source_leaf_emptied_by_move` only returns Some
653    // for a tab that exists with a tree, so both lookups below succeed.
654    let tab = state.tabs.get_mut(&tab_id).expect("resolved above");
655    let pre_focused = tab.focused_node_id.clone();
656    let pre_magnified = tab.magnified_node_id.clone();
657    let applied = tab.rootnode.as_mut().is_some_and(|root| {
658        crate::backend::layout::move_stack_member(root, &block_id, &target_block_id, position, activate)
659    });
660    if !applied {
661        return op_error(state, format!("LayoutStackMove: {not_found} (tab {tab_id})"));
662    }
663    let (was_focused, was_magnified) = clear_dangling_focus_magnify(tab, &pre_focused, &pre_magnified);
664    let new_tree = tab.rootnode.clone();
665    let violations = crate::backend::layout::validate_layout_invariants(&new_tree);
666    if !violations.is_empty() {
667        tracing::error!(tab_id = %tab_id, op = "LayoutStackMove", violations = ?violations, "layout-doctor: invariant violation(s) after a pane-tab edit");
668    }
669    let v = state.bump_version();
670    vec![Event::LayoutNodeDeleted {
671        tab_id,
672        node_id: source_node_id,
673        new_tree,
674        // The destination leaf is still there, so the tree is never empty.
675        tree_cleared: false,
676        was_focused,
677        was_magnified,
678        correlation_id,
679        version: v,
680    }]
681}
682
683fn unknown_tab(state: &mut State, op: &str, tab_id: &str) -> Vec<Event> {
684    let v = state.bump_version();
685    vec![Event::Error {
686        code: ErrorCode::InvalidCommand,
687        message: format!("{}: unknown tab {}", op, tab_id),
688        fatal: false,
689        version: v,
690    }]
691}
692
693/// `Event::Error` for an operation failure (pure-fn or balance error).
694fn op_error(state: &mut State, message: String) -> Vec<Event> {
695    let v = state.bump_version();
696    vec![Event::Error {
697        code: ErrorCode::InvalidCommand,
698        message,
699        fatal: false,
700        version: v,
701    }]
702}
703
704/// Clear focused/magnified ids that no longer resolve in the (post-op,
705/// post-balance) tree. `balance_node`'s single-leaf collapse can rewrite a
706/// parent's id and structural ops can remove nodes, so every structural arm
707/// reconciles — same intent as `handle_layout_delete_node`'s post-walk.
708fn reconcile_focus_magnify(tab: &mut crate::state::TabRecord) {
709    let resolves = |id: &str| -> bool {
710        if id.is_empty() {
711            return true;
712        }
713        match tab.rootnode.as_ref() {
714            Some(root) => crate::backend::layout::find_node_by_id(root, id).is_some(),
715            None => false,
716        }
717    };
718    let drop_focus = !resolves(&tab.focused_node_id);
719    let drop_magnify = !resolves(&tab.magnified_node_id);
720    if drop_focus {
721        tab.focused_node_id.clear();
722    }
723    if drop_magnify {
724        tab.magnified_node_id.clear();
725    }
726}
727
728pub(super) fn handle_layout_move_node(
729    state: &mut State,
730    tab_id: String,
731    node_id: String,
732    new_parent_id: String,
733    index: usize,
734    correlation_id: String,
735) -> Vec<Event> {
736    if let Err(events) = apply_atomic(state, &tab_id, "LayoutMoveNode", |root| {
737        crate::backend::layout::move_node(root, &node_id, &new_parent_id, index)
738    }) {
739        return events;
740    }
741    let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
742    reconcile_focus_magnify(tab);
743    let new_tree = tab.rootnode.clone();
744    let v = state.bump_version();
745    vec![Event::LayoutNodeMoved {
746        tab_id,
747        new_tree,
748        node_id,
749        new_parent_id,
750        index,
751        correlation_id,
752        version: v,
753    }]
754}
755
756pub(super) fn handle_layout_swap_nodes(
757    state: &mut State,
758    tab_id: String,
759    node1_id: String,
760    node2_id: String,
761    correlation_id: String,
762) -> Vec<Event> {
763    if let Err(events) = apply_atomic(state, &tab_id, "LayoutSwapNodes", |root| {
764        crate::backend::layout::swap_nodes(root, &node1_id, &node2_id)
765    }) {
766        return events;
767    }
768    let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
769    reconcile_focus_magnify(tab);
770    let new_tree = tab.rootnode.clone();
771    let v = state.bump_version();
772    vec![Event::LayoutNodesSwapped {
773        tab_id,
774        new_tree,
775        node1_id,
776        node2_id,
777        correlation_id,
778        version: v,
779    }]
780}
781
782pub(super) fn handle_layout_resize_nodes(
783    state: &mut State,
784    tab_id: String,
785    ops: Vec<agentmux_common::ResizeOp>,
786    correlation_id: String,
787) -> Vec<Event> {
788    if let Err(events) = apply_atomic(state, &tab_id, "LayoutResizeNodes", |root| {
789        crate::backend::layout::resize_nodes(root, &ops)
790    }) {
791        return events;
792    }
793    let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
794    reconcile_focus_magnify(tab);
795    let new_tree = tab.rootnode.clone();
796    let v = state.bump_version();
797    vec![Event::LayoutNodesResized {
798        tab_id,
799        new_tree,
800        ops,
801        correlation_id,
802        version: v,
803    }]
804}
805
806pub(super) fn handle_layout_replace_node(
807    state: &mut State,
808    tab_id: String,
809    target_id: String,
810    new_node: agentmux_common::LayoutNode,
811    focus_after: bool,
812    correlation_id: String,
813) -> Vec<Event> {
814    let new_id = new_node.id.clone();
815    let new_node_event = new_node.clone();
816    if let Err(events) = apply_atomic(state, &tab_id, "LayoutReplaceNode", |root| {
817        crate::backend::layout::replace_node(root, &target_id, new_node)
818    }) {
819        return events;
820    }
821    let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
822    if focus_after {
823        tab.focused_node_id = new_id;
824    }
825    reconcile_focus_magnify(tab);
826    let new_tree = tab.rootnode.clone();
827    let v = state.bump_version();
828    vec![Event::LayoutNodeReplaced {
829        tab_id,
830        new_tree,
831        target_id,
832        new_node: new_node_event,
833        correlation_id,
834        version: v,
835    }]
836}
837
838pub(super) fn handle_layout_split_horizontal(
839    state: &mut State,
840    tab_id: String,
841    target_id: String,
842    new_node: agentmux_common::LayoutNode,
843    position: agentmux_common::SplitPosition,
844    focus_after: bool,
845    correlation_id: String,
846) -> Vec<Event> {
847    let new_id = new_node.id.clone();
848    let new_node_event = new_node.clone();
849    if let Err(events) = apply_atomic(state, &tab_id, "LayoutSplitHorizontal", |root| {
850        crate::backend::layout::split_horizontal(root, &target_id, new_node, position)
851    }) {
852        return events;
853    }
854    let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
855    if focus_after {
856        tab.focused_node_id = new_id;
857    }
858    reconcile_focus_magnify(tab);
859    let new_tree = tab.rootnode.clone();
860    let v = state.bump_version();
861    vec![Event::LayoutSplitHorizontalApplied {
862        tab_id,
863        new_tree,
864        target_id,
865        new_node: new_node_event,
866        position,
867        correlation_id,
868        version: v,
869    }]
870}
871
872pub(super) fn handle_layout_split_vertical(
873    state: &mut State,
874    tab_id: String,
875    target_id: String,
876    new_node: agentmux_common::LayoutNode,
877    position: agentmux_common::SplitPosition,
878    focus_after: bool,
879    correlation_id: String,
880) -> Vec<Event> {
881    let new_id = new_node.id.clone();
882    let new_node_event = new_node.clone();
883    if let Err(events) = apply_atomic(state, &tab_id, "LayoutSplitVertical", |root| {
884        crate::backend::layout::split_vertical(root, &target_id, new_node, position)
885    }) {
886        return events;
887    }
888    let tab = state.tabs.get_mut(&tab_id).expect("tab present after apply_atomic");
889    if focus_after {
890        tab.focused_node_id = new_id;
891    }
892    reconcile_focus_magnify(tab);
893    let new_tree = tab.rootnode.clone();
894    let v = state.bump_version();
895    vec![Event::LayoutSplitVerticalApplied {
896        tab_id,
897        new_tree,
898        target_id,
899        new_node: new_node_event,
900        position,
901        correlation_id,
902        version: v,
903    }]
904}
905
906pub(super) fn handle_layout_insert_node_at_index(
907    state: &mut State,
908    tab_id: String,
909    node: agentmux_common::LayoutNode,
910    index_arr: Vec<usize>,
911    focus_after: bool,
912    magnify_after: bool,
913    correlation_id: String,
914) -> Vec<Event> {
915    let new_id = node.id.clone();
916    let node_event = node.clone();
917    let live_blocks: std::collections::HashSet<String> = state.blocks.keys().cloned().collect();
918    let Some(tab) = state.tabs.get_mut(&tab_id) else {
919        return unknown_tab(state, "LayoutInsertNodeAtIndex", &tab_id);
920    };
921    // Build the new tree on a clone, balance, and commit only on success
922    // (atomic — no partial mutation on error). Matches the frontend oracle:
923    // `insertNodeAtIndex` PROMOTES the node to root when the tree is empty
924    // (frontend/layout/lib/layoutTree.ts), so strong-reducer authority
925    // promotes too rather than rejecting — `index_arr` is moot on an empty
926    // tree. (Replay stays consistent: the same insert-at-index semantics
927    // promote on empty; the event documents the request.)
928    let working_result: Result<agentmux_common::LayoutNode, String> = match tab.rootnode.as_ref() {
929        Some(root) => {
930            let mut w = root.clone();
931            crate::backend::layout::insert_node_at_index(&mut w, node, &index_arr)
932                .map(|_| w)
933                .map_err(|e| format!("LayoutInsertNodeAtIndex: {} (tab {})", e, tab_id))
934        }
935        None => Ok(node),
936    };
937    let mut working = match working_result {
938        Ok(w) => w,
939        Err(msg) => return op_error(state, msg),
940    };
941    if let Err(e) = crate::backend::layout::balance_node(&mut working) {
942        return op_error(
943            state,
944            format!("LayoutInsertNodeAtIndex balance: {} (tab {})", e, tab_id),
945        );
946    }
947    tab.rootnode = Some(working);
948    // magnify implies focus (frontend invariant; see handle_layout_insert_node).
949    if focus_after || magnify_after {
950        tab.focused_node_id = new_id.clone();
951    }
952    if magnify_after {
953        tab.magnified_node_id = new_id;
954    }
955    // Referential-integrity enforcement, same choke point as
956    // `handle_layout_set_tree` — see `prune_dangling_block_refs`'s doc
957    // comment. The caller-supplied `node` could carry a stale `block_id`.
958    let pruned = crate::backend::layout::prune_dangling_block_refs(&mut tab.rootnode, &live_blocks);
959    if pruned > 0 {
960        tracing::warn!(
961            tab_id = %tab_id,
962            pruned,
963            "reducer: LayoutInsertNodeAtIndex pruned dangling layout leaf/leaves referencing since-deleted blocks"
964        );
965    }
966    // Minimize-lock enforcement, same choke point (see handle_layout_set_tree).
967    let snapped = crate::backend::layout::enforce_minimized_locks(&mut tab.rootnode);
968    if snapped > 0 {
969        tracing::warn!(
970            tab_id = %tab_id,
971            snapped,
972            "reducer: LayoutInsertNodeAtIndex snapped minimize-locked node size(s) back to their locked values"
973        );
974    }
975    // Layout doctor (issue #2179), same rationale as handle_layout_set_tree.
976    let violations = crate::backend::layout::validate_layout_invariants(&tab.rootnode);
977    if !violations.is_empty() {
978        tracing::error!(
979            tab_id = %tab_id,
980            violations = ?violations,
981            "layout-doctor: invariant violation(s) in tree persisted by LayoutInsertNodeAtIndex"
982        );
983    }
984    reconcile_focus_magnify(tab);
985    let new_tree = tab.rootnode.clone();
986    let v = state.bump_version();
987    vec![Event::LayoutNodeInsertedAtIndex {
988        tab_id,
989        new_tree,
990        node: node_event,
991        index_arr,
992        correlation_id,
993        version: v,
994    }]
995}
996
997/// Apply a fallible structural mutation to a tab's layout tree atomically:
998/// operate on a *clone*, run `balance_node`, and commit back to
999/// `tab.rootnode` only if both succeed. On any error the tab's tree is left
1000/// untouched — no partial mutation is visible to a later snapshot or to the
1001/// next operation. [codex P2 #1868]
1002///
1003/// On error, returns the `Event::Error` vec the caller should return verbatim
1004/// (unknown tab, empty tree, op failure, or balance failure). On success,
1005/// returns `Ok(())`; the caller re-fetches the tab for focus/magnify
1006/// reconciliation and to emit the granular event.
1007fn apply_atomic<F>(
1008    state: &mut State,
1009    tab_id: &str,
1010    op: &str,
1011    f: F,
1012) -> Result<(), Vec<Event>>
1013where
1014    F: FnOnce(&mut agentmux_common::LayoutNode) -> Result<(), crate::backend::layout::LayoutError>,
1015{
1016    let Some(tab) = state.tabs.get_mut(tab_id) else {
1017        return Err(unknown_tab(state, op, tab_id));
1018    };
1019    let Some(current) = tab.rootnode.as_ref() else {
1020        return Err(op_error(state, format!("{}: empty tree (tab {})", op, tab_id)));
1021    };
1022    let mut working = current.clone();
1023    if let Err(e) = f(&mut working) {
1024        return Err(op_error(state, format!("{}: {} (tab {})", op, e, tab_id)));
1025    }
1026    if let Err(e) = crate::backend::layout::balance_node(&mut working) {
1027        return Err(op_error(state, format!("{} balance: {} (tab {})", op, e, tab_id)));
1028    }
1029    tab.rootnode = Some(working);
1030    // Referential-integrity enforcement, same choke point as
1031    // `handle_layout_set_tree` — see `prune_dangling_block_refs`'s doc
1032    // comment. `replace_node`/`split_horizontal`/`split_vertical` accept a
1033    // caller-supplied new node that could carry a stale `block_id`; this
1034    // also opportunistically cleans up any pre-existing dangling leaf a
1035    // move/swap/resize just happened to touch, for free. Every caller of
1036    // `apply_atomic` already calls `reconcile_focus_magnify` on its own
1037    // next line, so a pruned focused/magnified id is handled there — no
1038    // need to duplicate that call here.
1039    let live_blocks: std::collections::HashSet<String> = state.blocks.keys().cloned().collect();
1040    let tab = state.tabs.get_mut(tab_id).expect("tab present — just written above");
1041    let pruned = crate::backend::layout::prune_dangling_block_refs(&mut tab.rootnode, &live_blocks);
1042    if pruned > 0 {
1043        tracing::warn!(
1044            tab_id = %tab_id,
1045            op,
1046            pruned,
1047            "reducer: pruned dangling layout leaf/leaves referencing since-deleted blocks"
1048        );
1049    }
1050    // Minimize-lock enforcement, same choke point (see handle_layout_set_tree).
1051    // The per-op guards (`LayoutError::NodeLocked`) reject ops that *target* a
1052    // locked node; this pass additionally corrects indirect damage — e.g. a
1053    // legitimate resize pair whose unit redistribution squeezed a locked
1054    // sibling through `balance_node`'s rebuild.
1055    let snapped = crate::backend::layout::enforce_minimized_locks(&mut tab.rootnode);
1056    if snapped > 0 {
1057        tracing::warn!(
1058            tab_id = %tab_id,
1059            op,
1060            snapped,
1061            "reducer: snapped minimize-locked node size(s) back to their locked values"
1062        );
1063    }
1064    // Layout doctor (issue #2179), same rationale as handle_layout_set_tree.
1065    let violations = crate::backend::layout::validate_layout_invariants(&tab.rootnode);
1066    if !violations.is_empty() {
1067        tracing::error!(
1068            tab_id = %tab_id,
1069            op,
1070            violations = ?violations,
1071            "layout-doctor: invariant violation(s) in tree persisted by structural op"
1072        );
1073    }
1074    Ok(())
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080    use crate::reducer::test_support::*;
1081    use crate::reducer::update;
1082    use agentmux_common::ipc::Command;
1083
1084    // ---------------------------------------------------------------
1085    // Phase E.4 (Option A) — SetFocusedNode / SetMagnifiedNode
1086    // ---------------------------------------------------------------
1087
1088    #[test]
1089    fn set_focused_node_round_trip_emits_event_and_updates_state() {
1090        let mut state = State::default();
1091        let ws_id = create_workspace(&mut state, "w");
1092        let tab_id = create_tab(&mut state, &ws_id, "t1");
1093        assert_eq!(state.tabs[&tab_id].focused_node_id, "");
1094        let events = update(
1095            &mut state,
1096            Command::SetFocusedNode {
1097                tab_id: tab_id.clone(),
1098                node_id: "node-7".into(),
1099            },
1100            &ctx(2),
1101        );
1102        assert_eq!(events.len(), 1);
1103        match &events[0] {
1104            Event::FocusedNodeChanged { tab_id: t, node_id, .. } => {
1105                assert_eq!(t, &tab_id);
1106                assert_eq!(node_id, "node-7");
1107            }
1108            other => panic!("expected FocusedNodeChanged, got {:?}", other),
1109        }
1110        assert_eq!(state.tabs[&tab_id].focused_node_id, "node-7");
1111    }
1112
1113    #[test]
1114    fn set_focused_node_no_op_when_value_unchanged() {
1115        let mut state = State::default();
1116        let ws_id = create_workspace(&mut state, "w");
1117        let tab_id = create_tab(&mut state, &ws_id, "t1");
1118        let _ = update(
1119            &mut state,
1120            Command::SetFocusedNode {
1121                tab_id: tab_id.clone(),
1122                node_id: "node-1".into(),
1123            },
1124            &ctx(2),
1125        );
1126        let version_before = state.event_version;
1127        let events = update(
1128            &mut state,
1129            Command::SetFocusedNode {
1130                tab_id,
1131                node_id: "node-1".into(),
1132            },
1133            &ctx(3),
1134        );
1135        assert!(events.is_empty(), "no-op should emit no events");
1136        assert_eq!(state.event_version, version_before, "no version bump on no-op");
1137    }
1138
1139    #[test]
1140    fn set_focused_node_unknown_tab_emits_error() {
1141        let mut state = State::default();
1142        let events = update(
1143            &mut state,
1144            Command::SetFocusedNode {
1145                tab_id: "ghost-tab".into(),
1146                node_id: "node-1".into(),
1147            },
1148            &ctx(1),
1149        );
1150        assert_eq!(events.len(), 1);
1151        assert!(
1152            matches!(&events[0], Event::Error { .. }),
1153            "expected Event::Error, got {:?}",
1154            events[0]
1155        );
1156    }
1157
1158    #[test]
1159    fn set_magnified_node_round_trip_emits_event_and_updates_state() {
1160        let mut state = State::default();
1161        let ws_id = create_workspace(&mut state, "w");
1162        let tab_id = create_tab(&mut state, &ws_id, "t1");
1163        let events = update(
1164            &mut state,
1165            Command::SetMagnifiedNode {
1166                tab_id: tab_id.clone(),
1167                node_id: "node-9".into(),
1168            },
1169            &ctx(2),
1170        );
1171        assert_eq!(events.len(), 1);
1172        match &events[0] {
1173            Event::MagnifiedNodeChanged { tab_id: t, node_id, .. } => {
1174                assert_eq!(t, &tab_id);
1175                assert_eq!(node_id, "node-9");
1176            }
1177            other => panic!("expected MagnifiedNodeChanged, got {:?}", other),
1178        }
1179        assert_eq!(state.tabs[&tab_id].magnified_node_id, "node-9");
1180    }
1181
1182    #[test]
1183    fn set_magnified_node_no_op_when_value_unchanged() {
1184        let mut state = State::default();
1185        let ws_id = create_workspace(&mut state, "w");
1186        let tab_id = create_tab(&mut state, &ws_id, "t1");
1187        let _ = update(
1188            &mut state,
1189            Command::SetMagnifiedNode {
1190                tab_id: tab_id.clone(),
1191                node_id: "node-2".into(),
1192            },
1193            &ctx(2),
1194        );
1195        let version_before = state.event_version;
1196        let events = update(
1197            &mut state,
1198            Command::SetMagnifiedNode {
1199                tab_id,
1200                node_id: "node-2".into(),
1201            },
1202            &ctx(3),
1203        );
1204        assert!(events.is_empty());
1205        assert_eq!(state.event_version, version_before);
1206    }
1207
1208    #[test]
1209    fn set_magnified_node_clear_with_empty_node_id() {
1210        let mut state = State::default();
1211        let ws_id = create_workspace(&mut state, "w");
1212        let tab_id = create_tab(&mut state, &ws_id, "t1");
1213        // Magnify a node first.
1214        let _ = update(
1215            &mut state,
1216            Command::SetMagnifiedNode {
1217                tab_id: tab_id.clone(),
1218                node_id: "node-3".into(),
1219            },
1220            &ctx(2),
1221        );
1222        assert_eq!(state.tabs[&tab_id].magnified_node_id, "node-3");
1223        // Now clear with empty node_id (toggle-off semantics).
1224        let events = update(
1225            &mut state,
1226            Command::SetMagnifiedNode {
1227                tab_id: tab_id.clone(),
1228                node_id: String::new(),
1229            },
1230            &ctx(3),
1231        );
1232        assert_eq!(events.len(), 1);
1233        match &events[0] {
1234            Event::MagnifiedNodeChanged { node_id, .. } => assert_eq!(node_id, ""),
1235            other => panic!("expected MagnifiedNodeChanged with empty node_id, got {:?}", other),
1236        }
1237        assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
1238    }
1239
1240    #[test]
1241    fn set_magnified_node_unknown_tab_emits_error() {
1242        let mut state = State::default();
1243        let events = update(
1244            &mut state,
1245            Command::SetMagnifiedNode {
1246                tab_id: "ghost-tab".into(),
1247                node_id: "node-1".into(),
1248            },
1249            &ctx(1),
1250        );
1251        assert_eq!(events.len(), 1);
1252        assert!(matches!(&events[0], Event::Error { .. }));
1253    }
1254
1255    // ---- Phase E.7 — property tests for reducer arm invariants ----
1256    //
1257    // Drives randomized sequences of valid commands through `update`
1258    // and asserts cross-arm invariants the unit tests above only
1259    // touch on per-arm. Catches regressions where an individual arm
1260    // looks correct in isolation but interacts with sibling arms in
1261    // a way that violates the reducer's whole-state contract.
1262    //
1263    // Invariants asserted:
1264    //   1. Version monotonicity: every event's version strictly
1265    //      increases across the sequence (no duplicates, no gaps in
1266    //      the wrong direction).
1267    //   2. Referential integrity: every tab in `state.tabs` has a
1268    //      `workspace_id` that exists in `state.workspaces`; every
1269    //      block in `state.blocks` has a `tab_id` that exists in
1270    //      `state.tabs`; every workspace's `tab_ids` references real
1271    //      tabs; every tab's `block_ids` references real blocks.
1272    //   3. Cascade integrity: after a `DeleteWorkspace`, no tab
1273    //      remains in `state.tabs` with that workspace_id, and no
1274    //      block remains in `state.blocks` whose tab was in that
1275    //      workspace.
1276    //   4. Active-tab validity: every workspace's `active_tab_id`
1277    //      is either `None` or points at a tab present in its own
1278    //      `tab_ids`.
1279
1280    use proptest::prelude::*;
1281
1282    /// Higher-level operations the property tests pick from. Each
1283    /// resolves to one or more `Command` invocations against the
1284    /// current state. We can't generate `Command`s directly because
1285    /// IDs are reducer-generated; pick from existing IDs instead.
1286    #[derive(Debug, Clone)]
1287    enum PropOp {
1288        CreateWorkspace,
1289        CreateTab,
1290        CreateBlock,
1291        DeleteTab,
1292        DeleteBlock,
1293        DeleteWorkspace,
1294    }
1295
1296    fn op_strategy() -> impl Strategy<Value = PropOp> {
1297        // Bias toward "constructive" ops so sequences accumulate
1298        // state rather than churn empty. Each Just is one variant;
1299        // proptest weights via `prop_oneof![weight => strat, …]`.
1300        prop_oneof![
1301            4 => Just(PropOp::CreateWorkspace),
1302            4 => Just(PropOp::CreateTab),
1303            3 => Just(PropOp::CreateBlock),
1304            1 => Just(PropOp::DeleteTab),
1305            1 => Just(PropOp::DeleteBlock),
1306            1 => Just(PropOp::DeleteWorkspace),
1307        ]
1308    }
1309
1310    /// Apply one PropOp; returns the events produced (which may be
1311    /// empty if the op was a no-op like "delete from empty pool").
1312    fn apply_prop_op(state: &mut State, op: PropOp, conn_id: u64) -> Vec<Event> {
1313        match op {
1314            PropOp::CreateWorkspace => update(
1315                state,
1316                Command::CreateWorkspace { name: format!("ws-{}", conn_id) },
1317                &ctx(conn_id),
1318            ),
1319            PropOp::CreateTab => {
1320                let target_ws = state.workspaces.keys().next().cloned();
1321                match target_ws {
1322                    Some(workspace_id) => update(
1323                        state,
1324                        Command::CreateTab {
1325                            workspace_id,
1326                            name: format!("tab-{}", conn_id),
1327                        },
1328                        &ctx(conn_id),
1329                    ),
1330                    None => Vec::new(),
1331                }
1332            }
1333            PropOp::CreateBlock => {
1334                let target_tab = state.tabs.keys().next().cloned();
1335                match target_tab {
1336                    Some(tab_id) => update(
1337                        state,
1338                        Command::CreateBlock { tab_id, meta: serde_json::Value::Null },
1339                        &ctx(conn_id),
1340                    ),
1341                    None => Vec::new(),
1342                }
1343            }
1344            PropOp::DeleteTab => {
1345                if let Some((tab_id, tab)) = state.tabs.iter().next() {
1346                    let cmd = Command::DeleteTab {
1347                        workspace_id: tab.workspace_id.clone(),
1348                        tab_id: tab_id.clone(),
1349                        // Proptest exercises both guarded + unguarded
1350                        // paths; force=true here ensures cascade
1351                        // invariants are tested without the guard
1352                        // short-circuiting the operation.
1353                        force: true,
1354                    };
1355                    update(state, cmd, &ctx(conn_id))
1356                } else {
1357                    Vec::new()
1358                }
1359            }
1360            PropOp::DeleteBlock => {
1361                if let Some((block_id, block)) = state.blocks.iter().next() {
1362                    let cmd = Command::DeleteBlock {
1363                        tab_id: block.tab_id.clone(),
1364                        block_id: block_id.clone(),
1365                    };
1366                    update(state, cmd, &ctx(conn_id))
1367                } else {
1368                    Vec::new()
1369                }
1370            }
1371            PropOp::DeleteWorkspace => {
1372                if let Some(workspace_id) = state.workspaces.keys().next().cloned() {
1373                    update(
1374                        state,
1375                        Command::DeleteWorkspace { workspace_id, force: false },
1376                        &ctx(conn_id),
1377                    )
1378                } else {
1379                    Vec::new()
1380                }
1381            }
1382        }
1383    }
1384
1385    /// Verify all four reducer-state invariants at once. Panics
1386    /// (proptest catches and shrinks) if any is violated.
1387    fn assert_invariants(state: &State) {
1388        // (2) Tabs reference real workspaces.
1389        for (tab_id, tab) in &state.tabs {
1390            assert!(
1391                state.workspaces.contains_key(&tab.workspace_id),
1392                "tab {} references unknown workspace {}",
1393                tab_id,
1394                tab.workspace_id
1395            );
1396        }
1397        // (2) Blocks reference real tabs.
1398        for (block_id, block) in &state.blocks {
1399            assert!(
1400                state.tabs.contains_key(&block.tab_id),
1401                "block {} references unknown tab {}",
1402                block_id,
1403                block.tab_id
1404            );
1405        }
1406        // (2) Workspace.tab_ids references real tabs.
1407        for (workspace_id, ws) in &state.workspaces {
1408            for tab_id in &ws.tab_ids {
1409                assert!(
1410                    state.tabs.contains_key(tab_id),
1411                    "workspace {} tab_ids contains unknown tab {}",
1412                    workspace_id,
1413                    tab_id
1414                );
1415            }
1416        }
1417        // (2) Tab.block_ids references real blocks.
1418        for (tab_id, tab) in &state.tabs {
1419            for block_id in &tab.block_ids {
1420                assert!(
1421                    state.blocks.contains_key(block_id),
1422                    "tab {} block_ids contains unknown block {}",
1423                    tab_id,
1424                    block_id
1425                );
1426            }
1427        }
1428        // (4) Active-tab validity.
1429        for (workspace_id, ws) in &state.workspaces {
1430            if let Some(active) = &ws.active_tab_id {
1431                assert!(
1432                    ws.tab_ids.iter().any(|t| t == active),
1433                    "workspace {} active_tab_id {} not in its tab_ids",
1434                    workspace_id,
1435                    active
1436                );
1437            }
1438        }
1439    }
1440
1441    proptest! {
1442        #![proptest_config(ProptestConfig {
1443            cases: 64,
1444            ..ProptestConfig::default()
1445        })]
1446
1447        /// Apply a random sequence of valid ops. After each op,
1448        /// referential integrity + active-tab validity hold; across
1449        /// the whole sequence, version is strictly monotonic for any
1450        /// emitted events.
1451        #[test]
1452        fn invariants_hold_across_random_sequences(ops in prop::collection::vec(op_strategy(), 0..40)) {
1453            let mut state = State::default();
1454            let mut last_version: u64 = 0;
1455            for (i, op) in ops.into_iter().enumerate() {
1456                let events = apply_prop_op(&mut state, op, (i + 1) as u64);
1457                for ev in &events {
1458                    let v = extract_version(ev);
1459                    prop_assert!(
1460                        v > last_version,
1461                        "version {} not strictly greater than previous {} (event {:?})",
1462                        v,
1463                        last_version,
1464                        ev
1465                    );
1466                    last_version = v;
1467                }
1468                assert_invariants(&state);
1469            }
1470        }
1471
1472        /// Cascade integrity — explicit setup-then-delete pattern.
1473        /// Build a non-trivial graph (workspace + tabs + blocks),
1474        /// delete the workspace, assert NO surviving entities
1475        /// reference the deleted workspace.
1476        #[test]
1477        fn delete_workspace_cascades_cleanly(
1478            tab_count in 1usize..6,
1479            blocks_per_tab in 0usize..4,
1480        ) {
1481            let mut state = State::default();
1482            // Create workspace.
1483            let ws_events = update(
1484                &mut state,
1485                Command::CreateWorkspace { name: "ws".into() },
1486                &ctx(1),
1487            );
1488            let ws_id = ws_events
1489                .iter()
1490                .find_map(|e| match e {
1491                    Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
1492                    _ => None,
1493                })
1494                .unwrap();
1495            // Create tabs + blocks under it. We don't keep the tab
1496            // IDs around — the cascade-after-delete assertions below
1497            // check the WHOLE-state collections (state.tabs.is_empty()
1498            // etc.), so per-tab IDs aren't needed. (reagent P2 #627.)
1499            for _ in 0..tab_count {
1500                let evs = update(
1501                    &mut state,
1502                    Command::CreateTab {
1503                        workspace_id: ws_id.clone(),
1504                        name: "t".into(),
1505                    },
1506                    &ctx(2),
1507                );
1508                let tid = evs
1509                    .iter()
1510                    .find_map(|e| match e {
1511                        Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1512                        _ => None,
1513                    })
1514                    .unwrap();
1515                for _ in 0..blocks_per_tab {
1516                    let _ = update(
1517                        &mut state,
1518                        Command::CreateBlock {
1519                            tab_id: tid.clone(),
1520                            meta: serde_json::Value::Null,
1521                        },
1522                        &ctx(3),
1523                    );
1524                }
1525            }
1526            // Sanity: counts match.
1527            prop_assert_eq!(state.workspaces.len(), 1);
1528            prop_assert_eq!(state.tabs.len(), tab_count);
1529            prop_assert_eq!(state.blocks.len(), tab_count * blocks_per_tab);
1530            // Delete the workspace.
1531            let _ = update(
1532                &mut state,
1533                Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
1534                &ctx(4),
1535            );
1536            // Cascade — workspaces, tabs, blocks should all be empty.
1537            prop_assert!(state.workspaces.is_empty());
1538            prop_assert!(state.tabs.is_empty());
1539            prop_assert!(
1540                state.blocks.is_empty(),
1541                "blocks should cascade-delete with their tabs; got {} survivors",
1542                state.blocks.len()
1543            );
1544            // And invariants hold on the empty state.
1545            assert_invariants(&state);
1546        }
1547    }
1548
1549    // ── Phase E.4.B Phase 5 — layout reducer arms ─────────────────
1550    //
1551    // Tests for the 4 arms shipped in this PR. All arms share the
1552    // same shape (lookup tab → mutate `tab.rootnode` via pure helper
1553    // → emit Event::Layout*); the unit tests below verify state
1554    // mutation and event shape per arm. The pure helpers themselves
1555    // have their own ~40 tests in `crates/srv/src/backend/layout/`.
1556
1557    fn leaf_node(id: &str, block_id: &str) -> agentmux_common::LayoutNode {
1558        agentmux_common::LayoutNode {
1559            id: id.to_string(),
1560            size: 1.0,
1561            data: Some(agentmux_common::LayoutNodeData {
1562                block_id: block_id.to_string(),
1563                ..Default::default()
1564            }),
1565            ..Default::default()
1566        }
1567    }
1568
1569    fn fresh_tab() -> (State, String) {
1570        let mut state = State::default();
1571        let ws = create_workspace(&mut state, "w");
1572        let tab = create_tab(&mut state, &ws, "t");
1573        (state, tab)
1574    }
1575
1576    /// Registers a minimal `BlockRecord` for `block_id` in `state.blocks`,
1577    /// so a test-constructed leaf referencing it survives
1578    /// `prune_dangling_block_refs` (added alongside `LayoutSetTree` and the
1579    /// `apply_atomic`-routed arms — see
1580    /// `docs/investigations/INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md`).
1581    /// Real callers always have a live `BlockRecord` before a layout leaf
1582    /// can reference it (`CreateBlock` populates `state.blocks` before any
1583    /// layout insert for that block can be dispatched — verified via
1584    /// `server::service::object`'s create-block handler); these
1585    /// lower-level reducer tests construct trees directly and need this
1586    /// explicit seed to match that real precondition.
1587    fn seed_block(state: &mut State, tab_id: &str, block_id: &str) {
1588        state.blocks.insert(
1589            block_id.to_string(),
1590            crate::state::BlockRecord {
1591                block_id: block_id.to_string(),
1592                tab_id: tab_id.to_string(),
1593            },
1594        );
1595    }
1596
1597    #[test]
1598    fn layout_clear_wipes_rootnode_focus_magnify_and_emits_event() {
1599        let (mut state, tab_id) = fresh_tab();
1600        // Pre-load some state.
1601        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n1", "b1"));
1602        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n1".into();
1603        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n1".into();
1604
1605        let events = update(
1606            &mut state,
1607            Command::LayoutClear {
1608                tab_id: tab_id.clone(),
1609                correlation_id: "corr-1".into(),
1610            },
1611            &ctx(1),
1612        );
1613
1614        assert_eq!(events.len(), 1);
1615        assert!(matches!(
1616            &events[0],
1617            Event::LayoutCleared { correlation_id, .. } if correlation_id == "corr-1"
1618        ));
1619        let tab = &state.tabs[&tab_id];
1620        assert!(tab.rootnode.is_none(), "rootnode wiped");
1621        assert_eq!(tab.focused_node_id, "");
1622        assert_eq!(tab.magnified_node_id, "");
1623    }
1624
1625    #[test]
1626    fn layout_queue_backend_actions_passes_through_and_emits_event() {
1627        let (mut state, tab_id) = fresh_tab();
1628        let actions = serde_json::json!([{
1629            "actiontype": "insert",
1630            "actionid": "a1",
1631            "blockid": "b1",
1632            "nodesize": null,
1633            "nodesizefraction": null,
1634            "indexarr": null,
1635            "focused": true,
1636            "magnified": false,
1637            "ephemeral": false,
1638            "targetblockid": "",
1639            "position": "",
1640        }]);
1641        let events = update(
1642            &mut state,
1643            Command::LayoutQueueBackendActions {
1644                tab_id: tab_id.clone(),
1645                actions: actions.clone(),
1646                correlation_id: "corr-q1".into(),
1647            },
1648            &ctx(1),
1649        );
1650        assert_eq!(events.len(), 1);
1651        match &events[0] {
1652            Event::LayoutBackendActionsQueued {
1653                tab_id: ev_tab_id,
1654                actions: ev_actions,
1655                correlation_id,
1656                ..
1657            } => {
1658                assert_eq!(ev_tab_id, &tab_id);
1659                assert_eq!(ev_actions, &actions);
1660                assert_eq!(correlation_id, "corr-q1");
1661            }
1662            other => panic!("expected LayoutBackendActionsQueued, got {other:?}"),
1663        }
1664    }
1665
1666    #[test]
1667    fn layout_queue_backend_actions_unknown_tab_emits_error() {
1668        let mut state = State::default();
1669        let events = update(
1670            &mut state,
1671            Command::LayoutQueueBackendActions {
1672                tab_id: "nope".into(),
1673                actions: serde_json::json!([{}]),
1674                correlation_id: "corr".into(),
1675            },
1676            &ctx(1),
1677        );
1678        assert_eq!(events.len(), 1);
1679        assert!(matches!(&events[0], Event::Error { .. }));
1680    }
1681
1682    #[test]
1683    fn layout_queue_backend_actions_empty_array_emits_error() {
1684        let (mut state, tab_id) = fresh_tab();
1685        let events = update(
1686            &mut state,
1687            Command::LayoutQueueBackendActions {
1688                tab_id,
1689                actions: serde_json::json!([]),
1690                correlation_id: "corr".into(),
1691            },
1692            &ctx(1),
1693        );
1694        assert_eq!(events.len(), 1);
1695        assert!(matches!(&events[0], Event::Error { .. }));
1696    }
1697
1698    #[test]
1699    fn layout_clear_unknown_tab_emits_error() {
1700        let mut state = State::default();
1701        let events = update(
1702            &mut state,
1703            Command::LayoutClear {
1704                tab_id: "nope".into(),
1705                correlation_id: "corr".into(),
1706            },
1707            &ctx(1),
1708        );
1709        assert!(matches!(&events[0], Event::Error { code: ErrorCode::InvalidCommand, .. }));
1710    }
1711
1712    #[test]
1713    fn layout_set_tree_replaces_rootnode_wholesale() {
1714        let (mut state, tab_id) = fresh_tab();
1715        seed_block(&mut state, &tab_id, "b1");
1716        seed_block(&mut state, &tab_id, "b2");
1717        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("old", "b1"));
1718
1719        let new_tree = Some(leaf_node("new", "b2"));
1720        let events = update(
1721            &mut state,
1722            Command::LayoutSetTree {
1723                tab_id: tab_id.clone(),
1724                new_tree: new_tree.clone(),
1725                correlation_id: "corr-set".into(),
1726                slices: None,
1727            },
1728            &ctx(1),
1729        );
1730
1731        assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
1732        assert_eq!(state.tabs[&tab_id].rootnode, new_tree);
1733    }
1734
1735    #[test]
1736    fn layout_set_tree_to_none_clears_rootnode() {
1737        let (mut state, tab_id) = fresh_tab();
1738        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n", "b"));
1739
1740        let _ = update(
1741            &mut state,
1742            Command::LayoutSetTree {
1743                tab_id: tab_id.clone(),
1744                new_tree: None,
1745                correlation_id: "corr".into(),
1746                slices: None,
1747            },
1748            &ctx(1),
1749        );
1750        assert!(state.tabs[&tab_id].rootnode.is_none());
1751    }
1752
1753    /// End-to-end regression for
1754    /// docs/investigations/INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md:
1755    /// a wholesale tree push — the exact vector a stale frontend copy used
1756    /// to resurrect a deleted block's leaf through — must self-heal
1757    /// instead of persisting the dangling reference. Only "ba" is
1758    /// registered as live; "gone" is not (simulating a block deleted
1759    /// before this push landed, e.g. from a stale frontend LayoutModel).
1760    #[test]
1761    fn layout_set_tree_self_heals_a_dangling_block_ref_in_the_pushed_tree() {
1762        let (mut state, tab_id) = fresh_tab();
1763        seed_block(&mut state, &tab_id, "ba");
1764
1765        let pushed_tree = agentmux_common::LayoutNode {
1766            id: "root".into(),
1767            children: vec![leaf_node("a", "ba"), leaf_node("b", "gone")],
1768            ..Default::default()
1769        };
1770        let events = update(
1771            &mut state,
1772            Command::LayoutSetTree {
1773                tab_id: tab_id.clone(),
1774                new_tree: Some(pushed_tree),
1775                correlation_id: "corr".into(),
1776                slices: None,
1777            },
1778            &ctx(1),
1779        );
1780
1781        assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
1782        assert!(tree_has(&state, &tab_id, "a"), "live block's leaf survives");
1783        assert!(!tree_has(&state, &tab_id, "b"), "dangling leaf pruned, not persisted");
1784        // Event::LayoutTreeReplaced's own new_tree must reflect the healed
1785        // tree too, not the caller's original — this is what the persist
1786        // subscriber writes to db_layout, so a pruned reducer state with a
1787        // stale event would just move the divergence downstream.
1788        if let Event::LayoutTreeReplaced { new_tree, .. } = &events[0] {
1789            let root = new_tree.as_ref().unwrap();
1790            assert!(
1791                !root.children.iter().any(|c| c.id == "b"),
1792                "emitted event's new_tree must already reflect the prune"
1793            );
1794        }
1795    }
1796
1797    /// Minimize-lock enforcement at the set-tree choke point
1798    /// (SPEC_LAYOUT_MINIMIZE_LOCKED_STATE_REDESIGN_2026_07_16.md): a pushed
1799    /// tree in which a minimized node's size was tampered with (e.g. a
1800    /// resize that slipped past a stale frontend's guards) must be snapped
1801    /// back to its recorded locked size, with the delta repaid to the
1802    /// unlocked sibling, before the tree is persisted.
1803    #[test]
1804    fn layout_set_tree_snaps_tampered_minimized_size_back_to_its_lock() {
1805        let (mut state, tab_id) = fresh_tab();
1806        seed_block(&mut state, &tab_id, "b1");
1807        seed_block(&mut state, &tab_id, "b2");
1808
1809        let mut minimized = leaf_node("min", "b1");
1810        minimized.size = 120.0; // tampered: locked value is 33.0
1811        minimized
1812            .extra
1813            .insert("minimizedSize".into(), serde_json::json!(200.0));
1814        minimized
1815            .extra
1816            .insert("minimizedLockedSize".into(), serde_json::json!(33.0));
1817        let mut sibling = leaf_node("sib", "b2");
1818        sibling.size = 280.0;
1819        let pushed_tree = agentmux_common::LayoutNode {
1820            id: "root".into(),
1821            children: vec![minimized, sibling],
1822            ..Default::default()
1823        };
1824
1825        let events = update(
1826            &mut state,
1827            Command::LayoutSetTree {
1828                tab_id: tab_id.clone(),
1829                new_tree: Some(pushed_tree),
1830                correlation_id: "corr".into(),
1831                slices: None,
1832            },
1833            &ctx(1),
1834        );
1835
1836        assert!(matches!(&events[0], Event::LayoutTreeReplaced { .. }));
1837        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
1838        assert_eq!(root.children[0].size, 33.0, "tampered size snapped back to lock");
1839        assert_eq!(root.children[1].size, 367.0, "delta repaid to the unlocked sibling");
1840        // The emitted event's tree must reflect the healed sizes too — it is
1841        // what the persist subscriber writes to db_layout.
1842        if let Event::LayoutTreeReplaced { new_tree, .. } = &events[0] {
1843            assert_eq!(new_tree.as_ref().unwrap().children[0].size, 33.0);
1844        }
1845    }
1846
1847    /// Minimize-lock guard on the explicit-parent insert path (reagent P1,
1848    /// PR #2180): a dissolved column has `data: None`, so without its own
1849    /// locked check it would satisfy the "is a group node" arm and accept
1850    /// the insert — letting an agent/API caller seed a full pane inside a
1851    /// header strip.
1852    #[test]
1853    fn layout_insert_node_rejects_minimize_locked_parent() {
1854        let (mut state, tab_id) = fresh_tab();
1855        seed_block(&mut state, &tab_id, "b1");
1856        seed_block(&mut state, &tab_id, "b2");
1857        seed_block(&mut state, &tab_id, "b3");
1858
1859        let mut l1 = leaf_node("l1", "b1");
1860        l1.extra.insert("minimizedSize".into(), serde_json::json!(200.0));
1861        let mut l2 = leaf_node("l2", "b2");
1862        l2.extra.insert("minimizedSize".into(), serde_json::json!(200.0));
1863        let mut dissolved = agentmux_common::LayoutNode {
1864            id: "colA".into(),
1865            children: vec![l1, l2],
1866            ..Default::default()
1867        };
1868        dissolved
1869            .extra
1870            .insert("columnDissolve".into(), serde_json::json!({"targetColumnId": "root"}));
1871        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(agentmux_common::LayoutNode {
1872            id: "root".into(),
1873            children: vec![dissolved, leaf_node("content", "b3")],
1874            ..Default::default()
1875        });
1876
1877        let events = update(
1878            &mut state,
1879            Command::LayoutInsertNode {
1880                tab_id: tab_id.clone(),
1881                node: leaf_node("new", "b3"),
1882                parent_id: Some("colA".into()),
1883                index: None,
1884                focus_after: false,
1885                magnify_after: false,
1886                correlation_id: "corr-locked".into(),
1887            },
1888            &ctx(1),
1889        );
1890
1891        assert!(matches!(&events[0], Event::Error { code: ErrorCode::InvalidCommand, .. }));
1892        // Dissolved column untouched.
1893        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
1894        assert_eq!(root.children[0].children.len(), 2);
1895    }
1896
1897    /// SPEC_864 Phase 2 — a slice-carrying full-row push applies focus/
1898    /// magnify to the TabRecord (empty = clear) and echoes the slices on
1899    /// the emitted event for the persist subscriber.
1900    #[test]
1901    fn layout_set_tree_with_slices_applies_focus_magnify() {
1902        let (mut state, tab_id) = fresh_tab();
1903        seed_block(&mut state, &tab_id, "b1");
1904        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "stale".into();
1905
1906        let events = update(
1907            &mut state,
1908            Command::LayoutSetTree {
1909                tab_id: tab_id.clone(),
1910                new_tree: Some(leaf_node("n1", "b1")),
1911                correlation_id: "corr".into(),
1912                slices: Some(agentmux_common::LayoutClientSlices {
1913                    leaforder: None,
1914                    focused_node_id: "n1".into(),
1915                    magnified_node_id: String::new(),
1916                    pending_backend_actions: None,
1917                }),
1918            },
1919            &ctx(1),
1920        );
1921
1922        assert_eq!(state.tabs[&tab_id].focused_node_id, "n1");
1923        assert!(
1924            state.tabs[&tab_id].magnified_node_id.is_empty(),
1925            "empty slice value clears stale magnify"
1926        );
1927        assert!(matches!(
1928            &events[0],
1929            Event::LayoutTreeReplaced { slices: Some(_), .. }
1930        ));
1931    }
1932
1933    /// Empty-tree contract wins over slices: wiping the tree clears focus/
1934    /// magnify even if the (skewed) push carried non-empty ids.
1935    #[test]
1936    fn layout_set_tree_none_tree_overrides_slice_focus() {
1937        let (mut state, tab_id) = fresh_tab();
1938        let _ = update(
1939            &mut state,
1940            Command::LayoutSetTree {
1941                tab_id: tab_id.clone(),
1942                new_tree: None,
1943                correlation_id: "corr".into(),
1944                slices: Some(agentmux_common::LayoutClientSlices {
1945                    leaforder: None,
1946                    focused_node_id: "dangling".into(),
1947                    magnified_node_id: "dangling".into(),
1948                    pending_backend_actions: None,
1949                }),
1950            },
1951            &ctx(1),
1952        );
1953        assert!(state.tabs[&tab_id].focused_node_id.is_empty());
1954        assert!(state.tabs[&tab_id].magnified_node_id.is_empty());
1955    }
1956
1957    #[test]
1958    fn layout_insert_node_into_empty_tree_promotes_to_root() {
1959        let (mut state, tab_id) = fresh_tab();
1960        let node = leaf_node("first", "b1");
1961        let events = update(
1962            &mut state,
1963            Command::LayoutInsertNode {
1964                tab_id: tab_id.clone(),
1965                node: node.clone(),
1966                parent_id: None,
1967                index: None,
1968                focus_after: false,
1969                magnify_after: false,
1970                correlation_id: "corr-ins".into(),
1971            },
1972            &ctx(1),
1973        );
1974        assert!(matches!(&events[0], Event::LayoutNodeInserted { .. }));
1975        assert_eq!(state.tabs[&tab_id].rootnode.as_ref().map(|n| n.id.as_str()), Some("first"));
1976    }
1977
1978    #[test]
1979    fn layout_insert_node_into_existing_tree_uses_helper() {
1980        let (mut state, tab_id) = fresh_tab();
1981        // Pre-load a single-leaf tree.
1982        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("root", "b1"));
1983        let new_node = leaf_node("added", "b2");
1984        let events = update(
1985            &mut state,
1986            Command::LayoutInsertNode {
1987                tab_id: tab_id.clone(),
1988                node: new_node,
1989                parent_id: None,
1990                index: None,
1991                focus_after: false,
1992                magnify_after: false,
1993                correlation_id: "corr".into(),
1994            },
1995            &ctx(1),
1996        );
1997        assert!(matches!(&events[0], Event::LayoutNodeInserted { .. }));
1998        // Helper turned the leaf root into a group with both leaves;
1999        // exact shape is the helper's contract — we just assert the
2000        // tree changed and contains both block ids.
2001        let root = state.tabs[&tab_id].rootnode.as_ref().expect("rootnode set");
2002        let collected = collect_block_ids(root);
2003        assert!(collected.contains(&"b1".to_string()));
2004        assert!(collected.contains(&"b2".to_string()));
2005    }
2006
2007    fn collect_block_ids(node: &agentmux_common::LayoutNode) -> Vec<String> {
2008        let mut ids = Vec::new();
2009        if let Some(d) = &node.data {
2010            if !d.block_id.is_empty() {
2011                ids.push(d.block_id.clone());
2012            }
2013        }
2014        for c in &node.children {
2015            ids.extend(collect_block_ids(c));
2016        }
2017        ids
2018    }
2019
2020    #[test]
2021    fn layout_delete_node_on_empty_tree_is_noop() {
2022        let (mut state, tab_id) = fresh_tab();
2023        let events = update(
2024            &mut state,
2025            Command::LayoutDeleteNode {
2026                tab_id: tab_id.clone(),
2027                node_id: "ghost".into(),
2028                correlation_id: "corr".into(),
2029            },
2030            &ctx(1),
2031        );
2032        assert!(events.is_empty(), "no event for delete on empty tree");
2033        assert!(state.tabs[&tab_id].rootnode.is_none());
2034    }
2035
2036    #[test]
2037    fn layout_set_tree_to_none_also_clears_focused_and_magnified() {
2038        // empty-tree set must match
2039        // `LayoutClear`'s contract — focused/magnified ids would
2040        // otherwise dangle past the wipe.
2041        let (mut state, tab_id) = fresh_tab();
2042        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("n", "b"));
2043        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n".into();
2044        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n".into();
2045        let _ = update(
2046            &mut state,
2047            Command::LayoutSetTree {
2048                tab_id: tab_id.clone(),
2049                new_tree: None,
2050                correlation_id: "corr".into(),
2051                slices: None,
2052            },
2053            &ctx(1),
2054        );
2055        let tab = &state.tabs[&tab_id];
2056        assert!(tab.rootnode.is_none());
2057        assert_eq!(tab.focused_node_id, "");
2058        assert_eq!(tab.magnified_node_id, "");
2059    }
2060
2061    #[test]
2062    fn layout_set_tree_with_some_preserves_focused_and_magnified() {
2063        // Symmetry guard: Some(new_tree) must NOT clear focused/
2064        // magnified — caller may have set them deliberately.
2065        let (mut state, tab_id) = fresh_tab();
2066        seed_block(&mut state, &tab_id, "b");
2067        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "n".into();
2068        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "n".into();
2069        let _ = update(
2070            &mut state,
2071            Command::LayoutSetTree {
2072                tab_id: tab_id.clone(),
2073                new_tree: Some(leaf_node("n", "b")),
2074                correlation_id: "corr".into(),
2075                slices: None,
2076            },
2077            &ctx(1),
2078        );
2079        let tab = &state.tabs[&tab_id];
2080        assert_eq!(tab.focused_node_id, "n");
2081        assert_eq!(tab.magnified_node_id, "n");
2082    }
2083
2084    #[test]
2085    fn layout_insert_node_honours_focus_after() {
2086        // focus_after=true must update
2087        // focused_node_id so the snapshot matches the event.
2088        let (mut state, tab_id) = fresh_tab();
2089        let node = leaf_node("new", "b1");
2090        let _ = update(
2091            &mut state,
2092            Command::LayoutInsertNode {
2093                tab_id: tab_id.clone(),
2094                node,
2095                parent_id: None,
2096                index: None,
2097                focus_after: true,
2098                magnify_after: false,
2099                correlation_id: "corr".into(),
2100            },
2101            &ctx(1),
2102        );
2103        assert_eq!(state.tabs[&tab_id].focused_node_id, "new");
2104        assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
2105    }
2106
2107    #[test]
2108    fn layout_insert_node_magnify_after_implies_focus() {
2109        // magnify-implies-focus. Even when
2110        // focus_after=false, setting magnify_after=true must also
2111        // update focused_node_id so it doesn't dangle on the prior
2112        // pane (UI invariant: a magnified pane is the focused pane).
2113        let (mut state, tab_id) = fresh_tab();
2114        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "prev".into();
2115        let node = leaf_node("new", "b1");
2116        let _ = update(
2117            &mut state,
2118            Command::LayoutInsertNode {
2119                tab_id: tab_id.clone(),
2120                node,
2121                parent_id: None,
2122                index: None,
2123                focus_after: false,
2124                magnify_after: true,
2125                correlation_id: "corr".into(),
2126            },
2127            &ctx(1),
2128        );
2129        assert_eq!(
2130            state.tabs[&tab_id].focused_node_id, "new",
2131            "magnify_after must imply focus_after"
2132        );
2133        assert_eq!(state.tabs[&tab_id].magnified_node_id, "new");
2134    }
2135
2136    #[test]
2137    fn layout_insert_node_honours_explicit_parent_id_and_index() {
2138        // with parent_id given, insert at
2139        // that node at the requested index instead of running the
2140        // heuristic helper.
2141        let (mut state, tab_id) = fresh_tab();
2142        let mut group = leaf_node("group", "");
2143        group.data = None;
2144        group.children = vec![leaf_node("a", "ba"), leaf_node("c", "bc")];
2145        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2146
2147        let _ = update(
2148            &mut state,
2149            Command::LayoutInsertNode {
2150                tab_id: tab_id.clone(),
2151                node: leaf_node("b", "bb"),
2152                parent_id: Some("group".into()),
2153                index: Some(1), // between a and c
2154                focus_after: false,
2155                magnify_after: false,
2156                correlation_id: "corr".into(),
2157            },
2158            &ctx(1),
2159        );
2160
2161        let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2162        let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2163        assert_eq!(ids, vec!["a", "b", "c"], "explicit index honoured");
2164    }
2165
2166    #[test]
2167    fn layout_insert_node_index_clamps_when_out_of_range() {
2168        // Out-of-range index clamps to the end (matches frontend
2169        // `findNextInsertLocation` defensive semantics).
2170        let (mut state, tab_id) = fresh_tab();
2171        let mut group = leaf_node("group", "");
2172        group.data = None;
2173        group.children = vec![leaf_node("a", "ba")];
2174        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
2175
2176        let _ = update(
2177            &mut state,
2178            Command::LayoutInsertNode {
2179                tab_id: tab_id.clone(),
2180                node: leaf_node("b", "bb"),
2181                parent_id: Some("group".into()),
2182                index: Some(99),
2183                focus_after: false,
2184                magnify_after: false,
2185                correlation_id: "corr".into(),
2186            },
2187            &ctx(1),
2188        );
2189        let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2190        let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2191        assert_eq!(ids, vec!["a", "b"], "out-of-range index clamps to end");
2192    }
2193
2194    // ── Phase 3 — the 7 remaining structural arms ──────────────────────────
2195    fn group_node(id: &str, children: Vec<agentmux_common::LayoutNode>) -> agentmux_common::LayoutNode {
2196        agentmux_common::LayoutNode {
2197            id: id.to_string(),
2198            size: 10.0,
2199            children,
2200            ..Default::default()
2201        }
2202    }
2203
2204    fn tree_has(state: &State, tab_id: &str, node_id: &str) -> bool {
2205        state.tabs[tab_id].rootnode.as_ref().is_some_and(|r| {
2206            crate::backend::layout::find_node_by_id(r, node_id).is_some()
2207        })
2208    }
2209
2210    #[test]
2211    fn layout_move_node_reparents_and_emits_event() {
2212        let (mut state, tab_id) = fresh_tab();
2213        seed_block(&mut state, &tab_id, "ba");
2214        seed_block(&mut state, &tab_id, "bb");
2215        seed_block(&mut state, &tab_id, "bc");
2216        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group_node(
2217            "root",
2218            vec![
2219                group_node("g1", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]),
2220                leaf_node("c", "bc"),
2221            ],
2222        ));
2223        let events = update(
2224            &mut state,
2225            Command::LayoutMoveNode {
2226                tab_id: tab_id.clone(),
2227                node_id: "c".into(),
2228                new_parent_id: "g1".into(),
2229                index: 0,
2230                correlation_id: "corr".into(),
2231            },
2232            &ctx(1),
2233        );
2234        assert!(matches!(&events[0], Event::LayoutNodeMoved { .. }));
2235        assert!(tree_has(&state, &tab_id, "c"), "c still in tree");
2236        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2237        assert!(
2238            !root.children.iter().any(|ch| ch.id == "c"),
2239            "c reparented out of root's direct children"
2240        );
2241    }
2242
2243    #[test]
2244    fn layout_swap_nodes_swaps_positions() {
2245        let (mut state, tab_id) = fresh_tab();
2246        seed_block(&mut state, &tab_id, "ba");
2247        seed_block(&mut state, &tab_id, "bb");
2248        state.tabs.get_mut(&tab_id).unwrap().rootnode =
2249            Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2250        let events = update(
2251            &mut state,
2252            Command::LayoutSwapNodes {
2253                tab_id: tab_id.clone(),
2254                node1_id: "a".into(),
2255                node2_id: "b".into(),
2256                correlation_id: "corr".into(),
2257            },
2258            &ctx(1),
2259        );
2260        assert!(matches!(&events[0], Event::LayoutNodesSwapped { .. }));
2261        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2262        let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2263        assert_eq!(ids, vec!["b", "a"], "positions swapped");
2264    }
2265
2266    #[test]
2267    fn layout_resize_nodes_applies_sizes() {
2268        let (mut state, tab_id) = fresh_tab();
2269        seed_block(&mut state, &tab_id, "ba");
2270        seed_block(&mut state, &tab_id, "bb");
2271        state.tabs.get_mut(&tab_id).unwrap().rootnode =
2272            Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2273        let events = update(
2274            &mut state,
2275            Command::LayoutResizeNodes {
2276                tab_id: tab_id.clone(),
2277                ops: vec![
2278                    agentmux_common::ResizeOp { node_id: "a".into(), size: 30.0 },
2279                    agentmux_common::ResizeOp { node_id: "b".into(), size: 70.0 },
2280                ],
2281                correlation_id: "corr".into(),
2282            },
2283            &ctx(1),
2284        );
2285        assert!(matches!(&events[0], Event::LayoutNodesResized { .. }));
2286        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2287        let a = crate::backend::layout::find_node_by_id(root, "a").unwrap();
2288        assert_eq!(a.size, 30.0);
2289    }
2290
2291    #[test]
2292    fn layout_replace_node_swaps_in_new_and_honours_focus() {
2293        let (mut state, tab_id) = fresh_tab();
2294        seed_block(&mut state, &tab_id, "ba");
2295        seed_block(&mut state, &tab_id, "bb");
2296        seed_block(&mut state, &tab_id, "bx");
2297        state.tabs.get_mut(&tab_id).unwrap().rootnode =
2298            Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2299        let events = update(
2300            &mut state,
2301            Command::LayoutReplaceNode {
2302                tab_id: tab_id.clone(),
2303                target_id: "a".into(),
2304                new_node: leaf_node("x", "bx"),
2305                focus_after: true,
2306                correlation_id: "corr".into(),
2307            },
2308            &ctx(1),
2309        );
2310        assert!(matches!(&events[0], Event::LayoutNodeReplaced { .. }));
2311        assert!(tree_has(&state, &tab_id, "x"));
2312        assert!(!tree_has(&state, &tab_id, "a"), "a replaced");
2313        assert_eq!(state.tabs[&tab_id].focused_node_id, "x");
2314    }
2315
2316    #[test]
2317    fn layout_replace_with_malformed_node_is_atomic() {
2318        // A new_node with neither data nor children fails balance AFTER
2319        // replace_node has swapped it in. The arm must leave the tab's tree
2320        // untouched (operate on a clone, commit only on success) and emit
2321        // only Error — no partial mutation. [codex P2 #1868]
2322        let (mut state, tab_id) = fresh_tab();
2323        let original = group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]);
2324        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(original.clone());
2325        let malformed = agentmux_common::LayoutNode {
2326            id: "bad".into(),
2327            // BOTH data and children violates leaf-XOR-branch → balance_node
2328            // returns Err(InvalidNode) after replace_node has swapped it in.
2329            data: Some(agentmux_common::LayoutNodeData {
2330                block_id: "x".into(),
2331                ..Default::default()
2332            }),
2333            children: vec![leaf_node("inner", "bi")],
2334            ..Default::default()
2335        };
2336        let events = update(
2337            &mut state,
2338            Command::LayoutReplaceNode {
2339                tab_id: tab_id.clone(),
2340                target_id: "a".into(),
2341                new_node: malformed,
2342                focus_after: false,
2343                correlation_id: "c".into(),
2344            },
2345            &ctx(1),
2346        );
2347        assert!(matches!(&events[0], Event::Error { .. }));
2348        assert_eq!(
2349            state.tabs[&tab_id].rootnode,
2350            Some(original),
2351            "tree left untouched on error (atomic)"
2352        );
2353    }
2354
2355    #[test]
2356    fn layout_split_horizontal_wraps_root_and_focuses_new() {
2357        let (mut state, tab_id) = fresh_tab();
2358        seed_block(&mut state, &tab_id, "ba");
2359        seed_block(&mut state, &tab_id, "bx");
2360        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("a", "ba"));
2361        let events = update(
2362            &mut state,
2363            Command::LayoutSplitHorizontal {
2364                tab_id: tab_id.clone(),
2365                target_id: "a".into(),
2366                new_node: leaf_node("x", "bx"),
2367                position: agentmux_common::SplitPosition::After,
2368                focus_after: true,
2369                correlation_id: "corr".into(),
2370            },
2371            &ctx(1),
2372        );
2373        assert!(matches!(&events[0], Event::LayoutSplitHorizontalApplied { .. }));
2374        assert!(tree_has(&state, &tab_id, "a"));
2375        assert!(tree_has(&state, &tab_id, "x"));
2376        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2377        assert!(root.data.is_none(), "root became a group");
2378        assert_eq!(root.children.len(), 2);
2379        assert_eq!(state.tabs[&tab_id].focused_node_id, "x");
2380    }
2381
2382    #[test]
2383    fn layout_split_vertical_wraps_root() {
2384        let (mut state, tab_id) = fresh_tab();
2385        seed_block(&mut state, &tab_id, "ba");
2386        seed_block(&mut state, &tab_id, "bx");
2387        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("a", "ba"));
2388        let events = update(
2389            &mut state,
2390            Command::LayoutSplitVertical {
2391                tab_id: tab_id.clone(),
2392                target_id: "a".into(),
2393                new_node: leaf_node("x", "bx"),
2394                position: agentmux_common::SplitPosition::Before,
2395                focus_after: false,
2396                correlation_id: "corr".into(),
2397            },
2398            &ctx(1),
2399        );
2400        assert!(matches!(&events[0], Event::LayoutSplitVerticalApplied { .. }));
2401        assert!(tree_has(&state, &tab_id, "a"));
2402        assert!(tree_has(&state, &tab_id, "x"));
2403        assert_eq!(state.tabs[&tab_id].rootnode.as_ref().unwrap().children.len(), 2);
2404    }
2405
2406    #[test]
2407    fn layout_insert_node_at_index_inserts_after_path() {
2408        let (mut state, tab_id) = fresh_tab();
2409        seed_block(&mut state, &tab_id, "ba");
2410        seed_block(&mut state, &tab_id, "bb");
2411        seed_block(&mut state, &tab_id, "bx");
2412        state.tabs.get_mut(&tab_id).unwrap().rootnode =
2413            Some(group_node("root", vec![leaf_node("a", "ba"), leaf_node("b", "bb")]));
2414        let events = update(
2415            &mut state,
2416            Command::LayoutInsertNodeAtIndex {
2417                tab_id: tab_id.clone(),
2418                node: leaf_node("x", "bx"),
2419                index_arr: vec![0],
2420                focus_after: false,
2421                magnify_after: false,
2422                correlation_id: "corr".into(),
2423            },
2424            &ctx(1),
2425        );
2426        assert!(matches!(&events[0], Event::LayoutNodeInsertedAtIndex { .. }));
2427        let root = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2428        let ids: Vec<_> = root.children.iter().map(|c| c.id.as_str()).collect();
2429        assert_eq!(ids, vec!["a", "x", "b"], "inserted after index 0");
2430    }
2431
2432    #[test]
2433    fn layout_insert_node_at_index_into_empty_promotes_to_root() {
2434        // Matches the frontend oracle (insertNodeAtIndex promotes to root on
2435        // an empty tree, in layoutTree.ts) — not a reject.
2436        let (mut state, tab_id) = fresh_tab();
2437        seed_block(&mut state, &tab_id, "b");
2438        let events = update(
2439            &mut state,
2440            Command::LayoutInsertNodeAtIndex {
2441                tab_id: tab_id.clone(),
2442                node: leaf_node("only", "b"),
2443                index_arr: vec![0],
2444                focus_after: false,
2445                magnify_after: false,
2446                correlation_id: "corr".into(),
2447            },
2448            &ctx(1),
2449        );
2450        assert!(matches!(&events[0], Event::LayoutNodeInsertedAtIndex { .. }));
2451        assert_eq!(
2452            state.tabs[&tab_id].rootnode.as_ref().map(|n| n.id.as_str()),
2453            Some("only"),
2454            "empty-tree insert-at-index promotes the node to root"
2455        );
2456    }
2457
2458    #[test]
2459    fn layout_move_node_unknown_tab_errors() {
2460        let mut state = State::default();
2461        let events = update(
2462            &mut state,
2463            Command::LayoutMoveNode {
2464                tab_id: "nope".into(),
2465                node_id: "a".into(),
2466                new_parent_id: "b".into(),
2467                index: 0,
2468                correlation_id: "c".into(),
2469            },
2470            &ctx(1),
2471        );
2472        assert!(matches!(
2473            &events[0],
2474            Event::Error { code: ErrorCode::InvalidCommand, .. }
2475        ));
2476    }
2477
2478    #[test]
2479    fn layout_swap_nodes_empty_tree_errors() {
2480        let (mut state, tab_id) = fresh_tab();
2481        let events = update(
2482            &mut state,
2483            Command::LayoutSwapNodes {
2484                tab_id: tab_id.clone(),
2485                node1_id: "a".into(),
2486                node2_id: "b".into(),
2487                correlation_id: "c".into(),
2488            },
2489            &ctx(1),
2490        );
2491        assert!(matches!(&events[0], Event::Error { .. }));
2492    }
2493
2494    #[test]
2495    fn layout_insert_node_into_empty_tree_with_explicit_parent_id_emits_error() {
2496        // empty-tree
2497        // promotion must reject explicit parent_id — otherwise the
2498        // event echoes a target that subscribers can't resolve.
2499        let (mut state, tab_id) = fresh_tab();
2500        let events = update(
2501            &mut state,
2502            Command::LayoutInsertNode {
2503                tab_id: tab_id.clone(),
2504                node: leaf_node("first", "b1"),
2505                parent_id: Some("does-not-exist".into()),
2506                index: None,
2507                focus_after: false,
2508                magnify_after: false,
2509                correlation_id: "corr".into(),
2510            },
2511            &ctx(1),
2512        );
2513        assert!(matches!(
2514            &events[0],
2515            Event::Error { code: ErrorCode::InvalidCommand, .. }
2516        ));
2517        assert!(
2518            state.tabs[&tab_id].rootnode.is_none(),
2519            "tree must stay empty on rejection"
2520        );
2521    }
2522
2523    #[test]
2524    fn layout_insert_node_into_empty_tree_with_explicit_index_emits_error() {
2525        // Same rationale but with `index` only — the spec §7.1
2526        // requires both fields be `None` for empty-tree promote.
2527        let (mut state, tab_id) = fresh_tab();
2528        let events = update(
2529            &mut state,
2530            Command::LayoutInsertNode {
2531                tab_id: tab_id.clone(),
2532                node: leaf_node("first", "b1"),
2533                parent_id: None,
2534                index: Some(0),
2535                focus_after: false,
2536                magnify_after: false,
2537                correlation_id: "corr".into(),
2538            },
2539            &ctx(1),
2540        );
2541        assert!(matches!(
2542            &events[0],
2543            Event::Error { code: ErrorCode::InvalidCommand, .. }
2544        ));
2545        assert!(state.tabs[&tab_id].rootnode.is_none());
2546    }
2547
2548    #[test]
2549    fn layout_insert_node_with_unknown_parent_id_emits_error() {
2550        // silent fallback to heuristic
2551        // diverges the event from the actual mutation. Reject
2552        // explicit-but-invalid parent_id with Event::Error so
2553        // subscribers (especially the persist subscriber, future)
2554        // see a consistent record.
2555        let (mut state, tab_id) = fresh_tab();
2556        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("only", "b1"));
2557
2558        let events = update(
2559            &mut state,
2560            Command::LayoutInsertNode {
2561                tab_id: tab_id.clone(),
2562                node: leaf_node("added", "b2"),
2563                parent_id: Some("does-not-exist".into()),
2564                index: None,
2565                focus_after: false,
2566                magnify_after: false,
2567                correlation_id: "corr".into(),
2568            },
2569            &ctx(1),
2570        );
2571        assert!(matches!(
2572            &events[0],
2573            Event::Error { code: ErrorCode::InvalidCommand, .. }
2574        ));
2575        // Tree must be unchanged.
2576        let root = state.tabs[&tab_id].rootnode.as_ref().expect("root");
2577        assert_eq!(root.id, "only");
2578        assert!(root.children.is_empty());
2579    }
2580
2581    #[test]
2582    fn layout_insert_node_with_leaf_parent_id_emits_error() {
2583        // parent_id resolves to a leaf (has data) — leaf can't host
2584        // children, so treat as invalid the same as a missing parent.
2585        let (mut state, tab_id) = fresh_tab();
2586        let mut root = leaf_node("group", "");
2587        root.data = None;
2588        root.children = vec![leaf_node("a", "ba")];
2589        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2590
2591        let events = update(
2592            &mut state,
2593            Command::LayoutInsertNode {
2594                tab_id: tab_id.clone(),
2595                node: leaf_node("b", "bb"),
2596                parent_id: Some("a".into()), // leaf, not a group
2597                index: None,
2598                focus_after: false,
2599                magnify_after: false,
2600                correlation_id: "corr".into(),
2601            },
2602            &ctx(1),
2603        );
2604        assert!(matches!(
2605            &events[0],
2606            Event::Error { code: ErrorCode::InvalidCommand, .. }
2607        ));
2608    }
2609
2610    #[test]
2611    fn layout_insert_node_with_neither_flag_leaves_state_alone() {
2612        // Anti-vacuity guard: confirm the false-flag path is the
2613        // baseline (otherwise the focus_after/magnify_after tests
2614        // wouldn't be measuring anything).
2615        let (mut state, tab_id) = fresh_tab();
2616        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "prev".into();
2617        let _ = update(
2618            &mut state,
2619            Command::LayoutInsertNode {
2620                tab_id: tab_id.clone(),
2621                node: leaf_node("new", "b1"),
2622                parent_id: None,
2623                index: None,
2624                focus_after: false,
2625                magnify_after: false,
2626                correlation_id: "corr".into(),
2627            },
2628            &ctx(1),
2629        );
2630        assert_eq!(state.tabs[&tab_id].focused_node_id, "prev");
2631    }
2632
2633    #[test]
2634    fn layout_delete_node_on_root_clears_the_tree() {
2635        // backend::layout::delete_node leaves
2636        // root deletion to the caller. Without the root-detection
2637        // branch, we'd emit LayoutNodeDeleted while rootnode still
2638        // contains the supposedly-deleted tree.
2639        let (mut state, tab_id) = fresh_tab();
2640        state.tabs.get_mut(&tab_id).unwrap().rootnode =
2641            Some(leaf_node("solitary-root", "b1"));
2642        let events = update(
2643            &mut state,
2644            Command::LayoutDeleteNode {
2645                tab_id: tab_id.clone(),
2646                node_id: "solitary-root".into(),
2647                correlation_id: "corr".into(),
2648            },
2649            &ctx(1),
2650        );
2651        assert!(matches!(&events[0], Event::LayoutNodeDeleted { .. }));
2652        assert!(
2653            state.tabs[&tab_id].rootnode.is_none(),
2654            "root deletion must wipe the tree"
2655        );
2656    }
2657
2658    // ── SPEC_864 site #6 — LayoutDeleteNodeByBlock + new_tree carriage ──────
2659
2660    #[test]
2661    fn layout_delete_node_by_block_resolves_and_carries_new_tree() {
2662        let (mut state, tab_id) = fresh_tab();
2663        let mut root = leaf_node("group", "");
2664        root.data = None;
2665        root.children = vec![
2666            leaf_node("a", "b1"),
2667            leaf_node("b", "b2"),
2668            leaf_node("c", "b3"),
2669        ];
2670        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2671
2672        let events = update(
2673            &mut state,
2674            Command::LayoutDeleteNodeByBlock {
2675                tab_id: tab_id.clone(),
2676                block_id: "b2".into(),
2677                correlation_id: "corr".into(),
2678            },
2679            &ctx(1),
2680        );
2681        match &events[0] {
2682            Event::LayoutNodeDeleted {
2683                node_id,
2684                new_tree,
2685                tree_cleared,
2686                ..
2687            } => {
2688                assert_eq!(node_id, "b", "must resolve block b2 to node b");
2689                assert!(!*tree_cleared, "non-root delete must not claim a clear");
2690                let tree = new_tree.as_ref().expect("non-root delete keeps a tree");
2691                assert!(
2692                    crate::backend::layout::find_node_id_by_block(tree, "b2").is_none(),
2693                    "deleted block must be gone from the event's tree"
2694                );
2695                assert!(
2696                    crate::backend::layout::find_node_id_by_block(tree, "b1").is_some(),
2697                    "sibling blocks must survive"
2698                );
2699                // The event's tree IS the reducer's post-delete tree — the
2700                // single-writer contract the persist subscriber relies on.
2701                assert_eq!(Some(tree), state.tabs[&tab_id].rootnode.as_ref());
2702            }
2703            other => panic!("expected LayoutNodeDeleted, got {:?}", other),
2704        }
2705    }
2706
2707    fn stacked_leaf(id: &str, stack: &[&str], visible: &str) -> agentmux_common::LayoutNode {
2708        let mut leaf = leaf_node(id, visible);
2709        let data = leaf.data.as_mut().unwrap();
2710        data.block_stack = stack.iter().map(|s| s.to_string()).collect();
2711        data.active_block_id = visible.to_string();
2712        leaf
2713    }
2714
2715    fn only_leaf_data(state: &State, tab_id: &str) -> agentmux_common::LayoutNodeData {
2716        state.tabs[tab_id].rootnode.as_ref().unwrap().data.clone().unwrap()
2717    }
2718
2719    fn delete_by_block(state: &mut State, tab_id: &str, block_id: &str) -> Vec<Event> {
2720        update(
2721            state,
2722            Command::LayoutDeleteNodeByBlock {
2723                tab_id: tab_id.to_string(),
2724                block_id: block_id.to_string(),
2725                correlation_id: String::new(),
2726            },
2727            &ctx(1),
2728        )
2729    }
2730
2731    // SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §2.2 / §3.4: deleting one
2732    // tab of a stacked pane removes that tab only — never the pane and its
2733    // sibling tabs (which left their blocks, agents included, with no pane).
2734
2735    #[test]
2736    fn layout_delete_node_by_block_removes_only_a_background_stack_member() {
2737        let (mut state, tab_id) = fresh_tab();
2738        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["agent", "term"], "agent"));
2739
2740        let events = delete_by_block(&mut state, &tab_id, "term");
2741
2742        assert!(matches!(events[0], Event::LayoutTreeReplaced { slices: None, .. }), "got {:?}", events);
2743        let data = only_leaf_data(&state, &tab_id);
2744        assert_eq!(data.block_stack, vec!["agent".to_string()]);
2745        assert_eq!(data.block_id, "agent", "the visible tab is untouched");
2746    }
2747
2748    #[test]
2749    fn layout_delete_node_by_block_on_the_visible_member_activates_the_right_neighbour() {
2750        let (mut state, tab_id) = fresh_tab();
2751        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a", "b", "c"], "b"));
2752
2753        delete_by_block(&mut state, &tab_id, "b");
2754
2755        let data = only_leaf_data(&state, &tab_id);
2756        assert_eq!(data.block_stack, vec!["a".to_string(), "c".to_string()]);
2757        assert_eq!(data.block_id, "c");
2758        assert_eq!(data.active_block_id, "c");
2759    }
2760
2761    #[test]
2762    fn layout_delete_node_by_block_on_the_rightmost_visible_member_activates_the_new_last() {
2763        let (mut state, tab_id) = fresh_tab();
2764        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a", "b"], "b"));
2765
2766        delete_by_block(&mut state, &tab_id, "b");
2767
2768        let data = only_leaf_data(&state, &tab_id);
2769        assert_eq!(data.block_stack, vec!["a".to_string()]);
2770        assert_eq!(data.block_id, "a");
2771    }
2772
2773    #[test]
2774    fn layout_delete_node_by_block_on_the_last_stack_member_removes_the_pane() {
2775        let (mut state, tab_id) = fresh_tab();
2776        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a"], "a"));
2777
2778        let events = delete_by_block(&mut state, &tab_id, "a");
2779
2780        assert!(matches!(events[0], Event::LayoutNodeDeleted { .. }), "got {:?}", events);
2781        assert!(state.tabs[&tab_id].rootnode.is_none());
2782    }
2783
2784    // ── Pane tabs (SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2) ──────
2785
2786    /// §2.1: the block is created AND placed in one reducer step — there is
2787    /// no state in which it exists without a pane.
2788    #[test]
2789    fn create_block_in_stack_creates_and_places_in_one_step() {
2790        let (mut state, tab_id) = fresh_tab();
2791        seed_block(&mut state, &tab_id, "anchor");
2792        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("pane", "anchor"));
2793
2794        let events = update(
2795            &mut state,
2796            Command::CreateBlockInStack {
2797                tab_id: tab_id.clone(),
2798                target_block_id: "anchor".into(),
2799                meta: serde_json::json!({ "view": "agent" }),
2800                activate: true,
2801            },
2802            &ctx(1),
2803        );
2804        let new_id = events
2805            .iter()
2806            .find_map(|e| match e {
2807                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
2808                _ => None,
2809            })
2810            .expect("block created");
2811        assert!(
2812            events.iter().any(|e| matches!(e, Event::LayoutTreeReplaced { slices: None, .. })),
2813            "placed in the same step: {events:?}"
2814        );
2815        assert!(state.blocks.contains_key(&new_id));
2816        assert!(state.tabs[&tab_id].block_ids.contains(&new_id));
2817        let data = state.tabs[&tab_id].rootnode.as_ref().unwrap().data.clone().unwrap();
2818        assert_eq!(data.block_stack, vec!["anchor".to_string(), new_id.clone()]);
2819        assert_eq!(data.block_id, new_id);
2820    }
2821
2822    #[test]
2823    fn create_block_in_stack_creates_nothing_when_no_pane_holds_the_target() {
2824        let (mut state, tab_id) = fresh_tab();
2825        seed_block(&mut state, &tab_id, "anchor");
2826        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("pane", "anchor"));
2827        let blocks_before = state.blocks.len();
2828        let tab_blocks_before = state.tabs[&tab_id].block_ids.clone();
2829
2830        let events = update(
2831            &mut state,
2832            Command::CreateBlockInStack {
2833                tab_id: tab_id.clone(),
2834                target_block_id: "missing".into(),
2835                meta: serde_json::Value::Null,
2836                activate: true,
2837            },
2838            &ctx(1),
2839        );
2840        assert!(matches!(events.as_slice(), [Event::Error { .. }]), "got {events:?}");
2841        assert_eq!(state.blocks.len(), blocks_before, "no block without a pane");
2842        assert_eq!(state.tabs[&tab_id].block_ids, tab_blocks_before, "nothing added to the tab");
2843    }
2844
2845    #[test]
2846    fn layout_stack_push_rejects_a_block_from_another_tab() {
2847        let (mut state, tab_id) = fresh_tab();
2848        seed_block(&mut state, &tab_id, "anchor");
2849        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("pane", "anchor"));
2850        let events = update(
2851            &mut state,
2852            Command::LayoutStackPush {
2853                tab_id: tab_id.clone(),
2854                target_block_id: "anchor".into(),
2855                block_id: "stranger".into(),
2856                activate: true,
2857                correlation_id: String::new(),
2858            },
2859            &ctx(1),
2860        );
2861        assert!(matches!(events.as_slice(), [Event::Error { .. }]), "got {events:?}");
2862        assert!(state.tabs[&tab_id].rootnode.as_ref().unwrap().data.as_ref().unwrap().block_stack.is_empty());
2863    }
2864
2865    #[test]
2866    fn layout_stack_activate_switches_the_visible_tab() {
2867        let (mut state, tab_id) = fresh_tab();
2868        seed_block(&mut state, &tab_id, "a");
2869        seed_block(&mut state, &tab_id, "b");
2870        let mut leaf = leaf_node("pane", "a");
2871        leaf.data.as_mut().unwrap().block_stack = vec!["a".into(), "b".into()];
2872        leaf.data.as_mut().unwrap().active_block_id = "a".into();
2873        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf);
2874
2875        let events = update(
2876            &mut state,
2877            Command::LayoutStackActivate {
2878                tab_id: tab_id.clone(),
2879                block_id: "b".into(),
2880                correlation_id: String::new(),
2881            },
2882            &ctx(1),
2883        );
2884        assert!(matches!(events.as_slice(), [Event::LayoutTreeReplaced { .. }]), "got {events:?}");
2885        let data = state.tabs[&tab_id].rootnode.as_ref().unwrap().data.clone().unwrap();
2886        assert_eq!((data.block_id.as_str(), data.active_block_id.as_str()), ("b", "b"));
2887    }
2888
2889    // ── LayoutStackMove (SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1) ────
2890
2891    #[test]
2892    fn layout_stack_move_reorders_within_the_same_pane() {
2893        let (mut state, tab_id) = fresh_tab();
2894        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(stacked_leaf("pane", &["a", "b", "c"], "a"));
2895
2896        let events = update(
2897            &mut state,
2898            Command::LayoutStackMove {
2899                tab_id: tab_id.clone(),
2900                block_id: "c".into(),
2901                target_block_id: "a".into(),
2902                position: agentmux_common::StackMovePosition::Before,
2903                activate: false,
2904                correlation_id: String::new(),
2905            },
2906            &ctx(1),
2907        );
2908        assert!(matches!(events.as_slice(), [Event::LayoutTreeReplaced { slices: None, .. }]), "got {events:?}");
2909        let data = only_leaf_data(&state, &tab_id);
2910        assert_eq!(data.block_stack, vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2911        assert_eq!(data.block_id, "a", "not activated, and reorder alone must not switch the visible tab");
2912    }
2913
2914    #[test]
2915    fn layout_stack_move_moves_a_tab_into_a_different_pane() {
2916        let (mut state, tab_id) = fresh_tab();
2917        seed_block(&mut state, &tab_id, "a");
2918        seed_block(&mut state, &tab_id, "b");
2919        seed_block(&mut state, &tab_id, "c");
2920        let root = agentmux_common::LayoutNode {
2921            id: "root".into(),
2922            children: vec![stacked_leaf("src", &["a", "b"], "a"), leaf_node("dst", "c")],
2923            ..Default::default()
2924        };
2925        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
2926
2927        let events = update(
2928            &mut state,
2929            Command::LayoutStackMove {
2930                tab_id: tab_id.clone(),
2931                block_id: "b".into(),
2932                target_block_id: "c".into(),
2933                position: agentmux_common::StackMovePosition::End,
2934                activate: true,
2935                correlation_id: String::new(),
2936            },
2937            &ctx(1),
2938        );
2939        assert!(matches!(events.as_slice(), [Event::LayoutTreeReplaced { slices: None, .. }]), "got {events:?}");
2940        let tree = state.tabs[&tab_id].rootnode.as_ref().unwrap();
2941        let src = tree.children[0].data.as_ref().unwrap();
2942        assert_eq!(src.block_stack, vec!["a".to_string()], "removed from the source pane");
2943        let dst = tree.children[1].data.as_ref().unwrap();
2944        assert_eq!(dst.block_stack, vec!["c".to_string(), "b".to_string()]);
2945        assert_eq!((dst.block_id.as_str(), dst.active_block_id.as_str()), ("b", "b"));
2946    }
2947
2948    // Moving a pane's ONLY tab empties the pane, which is removed. Reported
2949    // like a node delete so focus/magnify on the gone pane are reconciled.
2950    // SPEC_PANE_TAB_DRAG_LANDING_FLASH_AND_LAST_TAB_CLOSE_2026_09_24.md §4.3.
2951    fn lone_src_and_stacked_dst(state: &mut State, tab_id: &str) {
2952        for b in ["a", "c", "d", "z"] {
2953            seed_block(state, tab_id, b);
2954        }
2955        state.tabs.get_mut(tab_id).unwrap().rootnode = Some(agentmux_common::LayoutNode {
2956            id: "root".into(),
2957            children: vec![leaf_node("src", "a"), stacked_leaf("dst", &["c", "d"], "c"), leaf_node("other", "z")],
2958            ..Default::default()
2959        });
2960    }
2961
2962    fn move_lone_a_to_d(state: &mut State, tab_id: &str) -> Vec<Event> {
2963        update(
2964            state,
2965            Command::LayoutStackMove {
2966                tab_id: tab_id.to_string(),
2967                block_id: "a".into(),
2968                target_block_id: "d".into(),
2969                position: agentmux_common::StackMovePosition::End,
2970                activate: true,
2971                correlation_id: String::new(),
2972            },
2973            &ctx(1),
2974        )
2975    }
2976
2977    #[test]
2978    fn layout_stack_move_of_a_panes_only_tab_removes_the_emptied_pane() {
2979        let (mut state, tab_id) = fresh_tab();
2980        lone_src_and_stacked_dst(&mut state, &tab_id);
2981        let blocks_before = state.blocks.len();
2982
2983        let events = move_lone_a_to_d(&mut state, &tab_id);
2984        let tree = state.tabs[&tab_id].rootnode.clone().unwrap();
2985        match events.as_slice() {
2986            [Event::LayoutNodeDeleted { node_id, new_tree, tree_cleared, .. }] => {
2987                assert_eq!(node_id, "src", "the emptied source pane");
2988                assert_eq!(new_tree.as_ref(), Some(&tree), "carries the full post-move tree for persistence");
2989                assert!(!tree_cleared);
2990            }
2991            other => panic!("expected one LayoutNodeDeleted, got {other:?}"),
2992        }
2993        let ids: Vec<&str> = tree.children.iter().map(|c| c.id.as_str()).collect();
2994        assert_eq!(ids, vec!["dst", "other"]);
2995        let dst = tree.children[0].data.as_ref().unwrap();
2996        assert_eq!(dst.block_stack, vec!["c".to_string(), "d".to_string(), "a".to_string()]);
2997        assert_eq!((dst.block_id.as_str(), dst.active_block_id.as_str()), ("a", "a"));
2998        assert_eq!(state.blocks.len(), blocks_before, "a move: no block is deleted");
2999    }
3000
3001    #[test]
3002    fn layout_stack_move_of_a_panes_only_tab_clears_focus_and_magnify_on_the_gone_pane() {
3003        let (mut state, tab_id) = fresh_tab();
3004        lone_src_and_stacked_dst(&mut state, &tab_id);
3005        {
3006            let tab = state.tabs.get_mut(&tab_id).unwrap();
3007            tab.focused_node_id = "src".into();
3008            tab.magnified_node_id = "src".into();
3009        }
3010        let events = move_lone_a_to_d(&mut state, &tab_id);
3011        assert!(
3012            matches!(events.as_slice(), [Event::LayoutNodeDeleted { was_focused: true, was_magnified: true, .. }]),
3013            "got {events:?}"
3014        );
3015        let tab = &state.tabs[&tab_id];
3016        assert!(tab.focused_node_id.is_empty() && tab.magnified_node_id.is_empty());
3017    }
3018
3019    #[test]
3020    fn layout_stack_move_of_a_panes_only_tab_keeps_focus_on_a_pane_that_survives() {
3021        let (mut state, tab_id) = fresh_tab();
3022        lone_src_and_stacked_dst(&mut state, &tab_id);
3023        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "other".into();
3024        let events = move_lone_a_to_d(&mut state, &tab_id);
3025        assert!(
3026            matches!(events.as_slice(), [Event::LayoutNodeDeleted { was_focused: false, was_magnified: false, .. }]),
3027            "got {events:?}"
3028        );
3029        assert_eq!(state.tabs[&tab_id].focused_node_id, "other");
3030    }
3031
3032    #[test]
3033    fn layout_delete_node_by_block_unknown_block_is_silent_noop() {
3034        // Every delete_block saga run dispatches this command; a block
3035        // with no layout node (frontend already pruned, never laid out,
3036        // empty tab) must be a silent no-op — no Event::Error, no
3037        // version churn, tree untouched.
3038        let (mut state, tab_id) = fresh_tab();
3039        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("only", "b1"));
3040
3041        let events = update(
3042            &mut state,
3043            Command::LayoutDeleteNodeByBlock {
3044                tab_id: tab_id.clone(),
3045                block_id: "not-in-tree".into(),
3046                correlation_id: String::new(),
3047            },
3048            &ctx(1),
3049        );
3050        assert!(events.is_empty(), "unresolved block must be a silent no-op");
3051        assert_eq!(state.tabs[&tab_id].rootnode.as_ref().unwrap().id, "only");
3052    }
3053
3054    #[test]
3055    fn layout_delete_node_by_block_root_orphan_sets_tree_cleared() {
3056        // Deleting the last block empties the tree — the one structural
3057        // op where new_tree=None is legitimate. tree_cleared=true is what
3058        // lets the persist subscriber distinguish this from a
3059        // version-skewed sender's absent field.
3060        let (mut state, tab_id) = fresh_tab();
3061        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(leaf_node("solo", "b1"));
3062
3063        let events = update(
3064            &mut state,
3065            Command::LayoutDeleteNodeByBlock {
3066                tab_id: tab_id.clone(),
3067                block_id: "b1".into(),
3068                correlation_id: String::new(),
3069            },
3070            &ctx(1),
3071        );
3072        match &events[0] {
3073            Event::LayoutNodeDeleted {
3074                new_tree,
3075                tree_cleared,
3076                ..
3077            } => {
3078                assert!(new_tree.is_none());
3079                assert!(*tree_cleared, "root-orphan delete must mark the clear as real");
3080            }
3081            other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3082        }
3083        assert!(state.tabs[&tab_id].rootnode.is_none());
3084    }
3085
3086    #[test]
3087    fn layout_delete_node_clears_magnified_when_target_was_magnified() {
3088        // magnified must be cleared
3089        // alongside focused; same staleness concern.
3090        let (mut state, tab_id) = fresh_tab();
3091        let mut root = leaf_node("group", "");
3092        root.data = None;
3093        root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3094        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3095        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "a".into();
3096        let _ = update(
3097            &mut state,
3098            Command::LayoutDeleteNode {
3099                tab_id: tab_id.clone(),
3100                node_id: "a".into(),
3101                correlation_id: "corr".into(),
3102            },
3103            &ctx(1),
3104        );
3105        assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
3106    }
3107
3108    #[test]
3109    fn layout_node_deleted_event_carries_was_magnified() {
3110        // subscribers need
3111        // the was_magnified field to refresh their UI when the
3112        // magnified node is deleted.
3113        let (mut state, tab_id) = fresh_tab();
3114        let mut root = leaf_node("group", "");
3115        root.data = None;
3116        root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3117        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3118        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "a".into();
3119
3120        let events = update(
3121            &mut state,
3122            Command::LayoutDeleteNode {
3123                tab_id: tab_id.clone(),
3124                node_id: "a".into(),
3125                correlation_id: "corr".into(),
3126            },
3127            &ctx(1),
3128        );
3129        match &events[0] {
3130            Event::LayoutNodeDeleted { was_magnified, was_focused, .. } => {
3131                assert!(*was_magnified, "was_magnified must be true");
3132                assert!(!*was_focused, "was_focused stays false");
3133            }
3134            other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3135        }
3136    }
3137
3138    #[test]
3139    fn layout_delete_node_clears_focused_when_collapse_replaces_parent_id() {
3140        // `backend::layout::delete_node`'s collapse-sole-child path
3141        // promotes the surviving child and rewrites the parent's id
3142        // to the child's id. If focused/magnified pointed at the
3143        // ORIGINAL parent id, that id is gone from the tree even
3144        // though the same physical layout slot exists. Reducer must
3145        // clear the dangling reference.
3146        let (mut state, tab_id) = fresh_tab();
3147        let mut group = leaf_node("group-id", "");
3148        group.data = None;
3149        group.children = vec![leaf_node("only-child", "b1"), leaf_node("sibling", "b2")];
3150        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
3151        // Focus the group (the parent that will get its id rewritten
3152        // when "sibling" is deleted and "only-child" is the sole
3153        // survivor of the now-1-child group).
3154        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "group-id".into();
3155
3156        let events = update(
3157            &mut state,
3158            Command::LayoutDeleteNode {
3159                tab_id: tab_id.clone(),
3160                node_id: "sibling".into(),
3161                correlation_id: "corr".into(),
3162            },
3163            &ctx(1),
3164        );
3165        match &events[0] {
3166            Event::LayoutNodeDeleted { was_focused, .. } => {
3167                assert!(
3168                    *was_focused,
3169                    "collapse rewrote parent id; reducer must report focus loss"
3170                );
3171            }
3172            other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3173        }
3174        assert_eq!(
3175            state.tabs[&tab_id].focused_node_id, "",
3176            "stale focus cleared post-collapse"
3177        );
3178    }
3179
3180    #[test]
3181    fn layout_delete_node_clears_focused_when_target_subtree_contains_focus() {
3182        // deleting a container
3183        // wipes its descendants, but a direct-id-match check on
3184        // focused/magnified misses descendants — they stay dangling.
3185        let (mut state, tab_id) = fresh_tab();
3186        // Tree:
3187        //   root-group (children: group-A, leaf-z)
3188        //     group-A (children: leaf-x, leaf-y)
3189        let mut leaf_x = leaf_node("leaf-x", "bx");
3190        leaf_x.data = Some(agentmux_common::LayoutNodeData {
3191            block_id: "bx".into(),
3192            ..Default::default()
3193        });
3194        let mut group_a = leaf_node("group-A", "");
3195        group_a.data = None;
3196        group_a.children = vec![leaf_x, leaf_node("leaf-y", "by")];
3197        let mut root = leaf_node("root-group", "");
3198        root.data = None;
3199        root.children = vec![group_a, leaf_node("leaf-z", "bz")];
3200        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3201        // Focus a descendant of group-A, then delete group-A.
3202        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "leaf-x".into();
3203        state.tabs.get_mut(&tab_id).unwrap().magnified_node_id = "leaf-y".into();
3204
3205        let events = update(
3206            &mut state,
3207            Command::LayoutDeleteNode {
3208                tab_id: tab_id.clone(),
3209                node_id: "group-A".into(),
3210                correlation_id: "corr".into(),
3211            },
3212            &ctx(1),
3213        );
3214        match &events[0] {
3215            Event::LayoutNodeDeleted { was_focused, was_magnified, .. } => {
3216                assert!(*was_focused, "descendant focus must be cleared");
3217                assert!(*was_magnified, "descendant magnify must be cleared");
3218            }
3219            other => panic!("expected LayoutNodeDeleted, got {:?}", other),
3220        }
3221        assert_eq!(state.tabs[&tab_id].focused_node_id, "");
3222        assert_eq!(state.tabs[&tab_id].magnified_node_id, "");
3223    }
3224
3225    #[test]
3226    fn layout_insert_node_event_echoes_parent_id_and_index() {
3227        // The emitted event must echo the command's parent_id /
3228        // index so subscribers see what was requested. Tree pre-
3229        // populated with a group so the explicit-parent path
3230        // doesn't take the empty-tree rejection branch.
3231        let (mut state, tab_id) = fresh_tab();
3232        let mut group = leaf_node("group", "");
3233        group.data = None;
3234        group.children = vec![leaf_node("a", "ba")];
3235        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(group);
3236
3237        let events = update(
3238            &mut state,
3239            Command::LayoutInsertNode {
3240                tab_id: tab_id.clone(),
3241                node: leaf_node("new", "b1"),
3242                parent_id: Some("group".into()),
3243                index: Some(0),
3244                focus_after: false,
3245                magnify_after: false,
3246                correlation_id: "corr".into(),
3247            },
3248            &ctx(1),
3249        );
3250        match &events[0] {
3251            Event::LayoutNodeInserted { parent_id, index, .. } => {
3252                assert_eq!(parent_id.as_deref(), Some("group"));
3253                assert_eq!(*index, Some(0));
3254            }
3255            other => panic!("expected LayoutNodeInserted, got {:?}", other),
3256        }
3257    }
3258
3259    #[test]
3260    fn layout_delete_node_clears_focused_when_target_was_focused() {
3261        let (mut state, tab_id) = fresh_tab();
3262        // Tree: group with two leaves.
3263        let mut root = leaf_node("root-group", "");
3264        root.data = None;
3265        root.children = vec![leaf_node("a", "b1"), leaf_node("b", "b2")];
3266        state.tabs.get_mut(&tab_id).unwrap().rootnode = Some(root);
3267        state.tabs.get_mut(&tab_id).unwrap().focused_node_id = "a".into();
3268
3269        let events = update(
3270            &mut state,
3271            Command::LayoutDeleteNode {
3272                tab_id: tab_id.clone(),
3273                node_id: "a".into(),
3274                correlation_id: "corr-del".into(),
3275            },
3276            &ctx(1),
3277        );
3278        assert!(matches!(
3279            &events[0],
3280            Event::LayoutNodeDeleted { was_focused: true, .. }
3281        ));
3282        assert_eq!(state.tabs[&tab_id].focused_node_id, "");
3283    }
3284}