Skip to main content

agentmux_srv/server/service/
layout_helpers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Layout-tree + pending-action writers used by the tear-off / promote /
5//! redock handlers. SPEC_864 Phase 3/4 — all of them route through the
6//! reducer (`seed_layout_via_reducer` / `queue_layout_actions_via_reducer`);
7//! nothing here writes mstore directly anymore.
8//! `setup_torn_off_block_layout` is re-exported crate-wide (the pane
9//! app-API uses it).
10
11use crate::backend::obj::*;
12
13/// Phase E.5.5 — set up the layout tree for a tab that just received
14/// its first block via the TearOffBlock saga. Called from the
15/// TearOffBlock / PromoteBlockToTab handlers and the floating
16/// `pane.open` path after the saga's reducer-state portion
17/// (CreateTab + MoveBlock) completes. (NOT RedockFloatingPane — that
18/// redocks into an EXISTING tab and goes through
19/// `queue_target_layout_insert`/`_split` instead; reagent P2 on #1971.)
20///
21/// SPEC_864 Phase 3 — reducer-routed: dispatches `LayoutSetTree` via
22/// `seed_layout_via_reducer` (single writer of `db_layout`; the reducer's
23/// `TabRecord.rootnode` stays authoritative) instead of the retired
24/// wcore-direct `rootnode`/`leaforder` write. Every caller runs post-saga,
25/// so the tab is always reducer-known. Best-effort at the call sites: a
26/// failure leaves the new tab with the moved block but a malformed layout;
27/// the user-visible symptom is an empty render in the new window.
28pub(crate) async fn setup_torn_off_block_layout(
29    state: &super::super::AppState,
30    new_tab_id: &str,
31    block_id: &str,
32) -> Result<(), String> {
33    let node_id = uuid::Uuid::new_v4().to_string();
34    // Phase E.4.B Phase 2 — construct typed LayoutNode (was inline JSON).
35    let rootnode = LayoutNode {
36        id: node_id.clone(),
37        flex_direction: FlexDirection::Row,
38        size: 1.0,
39        children: Vec::new(),
40        data: Some(LayoutNodeData {
41            block_id: block_id.to_string(),
42            ..Default::default()
43        }),
44        ..Default::default()
45    };
46    let leaforder = vec![LeafOrderEntry {
47        nodeid: node_id,
48        blockid: block_id.to_string(),
49    }];
50    // Focused id stays empty — the legacy writer never set it for a
51    // torn-off tab; the frontend focuses on load.
52    super::reducer_helpers::seed_layout_via_reducer(
53        state,
54        new_tab_id,
55        rootnode,
56        String::new(),
57        leaforder,
58        String::new(),
59    )
60    .await
61}
62
63/// Floating-pane re-dock — enqueue an "insert" action on the TARGET
64/// tab's `LayoutState.pendingbackendactions` so the target window's
65/// frontend adds a new leaf for the redocked block through its
66/// `LayoutTreeActionType.InsertNode` reducer pathway.
67///
68/// Why this and not direct rootnode/leaforder writes? The frontend's
69/// LayoutModel maintains its own in-memory tree state and doesn't
70/// auto-sync from external `LayoutState` MuxObj updates — so a
71/// backend `store.update` to the rootnode lands in the MOS cache
72/// but the LayoutModel never picks it up, and the next frontend-
73/// initiated `object.UpdateObject` overwrites the backend version
74/// with the LayoutModel's stale tree. The pending-actions queue
75/// (`onBackendUpdate` in `layoutPersistence.ts`) is the canonical
76/// channel for "backend wants the frontend to mutate its layout
77/// tree". Source-delete on tear-off uses the same channel via
78/// `queue_source_layout_delete`.
79///
80/// SPEC_864 Phase 4 — appends through the reducer
81/// (`queue_layout_actions_via_reducer`), not `store.update`.
82pub(crate) async fn queue_target_layout_insert(
83    state: &super::super::AppState,
84    target_tab_id: &str,
85    block_id: &str,
86) -> Result<(), String> {
87    let action = LayoutActionData {
88        // Matches `LayoutTreeActionType.InsertNode = "insert"` in
89        // `frontend/layout/lib/types.ts`.
90        actiontype: "insert".to_string(),
91        actionid: uuid::Uuid::new_v4().to_string(),
92        blockid: block_id.to_string(),
93        nodesize: None,
94        nodesizefraction: None,
95        indexarr: None,
96        focused: true,
97        magnified: false,
98        ephemeral: false,
99        targetblockid: String::new(),
100        position: String::new(),
101    };
102    super::reducer_helpers::queue_layout_actions_via_reducer(state, target_tab_id, vec![action])
103        .await
104}
105
106/// Tell the frontend that `block_id` was added — already, in the reducer — as
107/// the visible tab of the pane holding `target_block_id`
108/// (`Command::CreateBlockInStack`). Matches
109/// `LayoutTreeActionType.StackPush = "stackpush"` in
110/// `frontend/layout/lib/types.ts`. SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.3.
111pub(crate) async fn queue_target_stack_push(
112    state: &super::super::AppState,
113    tab_id: &str,
114    block_id: &str,
115    target_block_id: &str,
116) -> Result<(), String> {
117    let action = LayoutActionData {
118        actiontype: "stackpush".to_string(),
119        actionid: uuid::Uuid::new_v4().to_string(),
120        blockid: block_id.to_string(),
121        nodesize: None,
122        nodesizefraction: None,
123        indexarr: None,
124        focused: true,
125        magnified: false,
126        ephemeral: false,
127        targetblockid: target_block_id.to_string(),
128        position: String::new(),
129    };
130    super::reducer_helpers::queue_layout_actions_via_reducer(state, tab_id, vec![action]).await
131}
132
133/// Tell the frontend that `block_id` was moved — already, in the reducer —
134/// to `position` relative to `target_block_id` (`Command::LayoutStackMove`):
135/// a same-pane reorder when both are already in one leaf's stack, otherwise
136/// a cross-pane move. Matches `LayoutTreeActionType.StackMove = "stackmove"`
137/// in `frontend/layout/lib/types.ts`. `position` is the same
138/// `"before"`/`"after"`/`"end"` string `pane.moveTab`'s caller supplied —
139/// reused verbatim, not re-derived, so the frontend applies the identical
140/// operation rather than inferring it from resulting tree state. `activate`
141/// rides on `LayoutActionData.focused` — the same field `queue_target_stack_push`
142/// above already overloads for this purpose (that call hardcodes `true`,
143/// since `CreateBlockInStack` always activates; this one carries the
144/// caller's actual `activate` value since a reorder may or may not).
145/// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1, Phase 3.
146pub(crate) async fn queue_target_stack_move(
147    state: &super::super::AppState,
148    tab_id: &str,
149    block_id: &str,
150    target_block_id: &str,
151    position: &str,
152    activate: bool,
153) -> Result<(), String> {
154    let action = LayoutActionData {
155        actiontype: "stackmove".to_string(),
156        actionid: uuid::Uuid::new_v4().to_string(),
157        blockid: block_id.to_string(),
158        nodesize: None,
159        nodesizefraction: None,
160        indexarr: None,
161        focused: activate,
162        magnified: false,
163        ephemeral: false,
164        targetblockid: target_block_id.to_string(),
165        position: position.to_string(),
166    };
167    super::reducer_helpers::queue_layout_actions_via_reducer(state, tab_id, vec![action]).await
168}
169
170/// Phase 4b/4c — enqueue a directional split action on the TARGET tab's
171/// `LayoutState.pendingbackendactions` so the redocked block lands in
172/// the exact slot the ghost overlay previewed.
173///
174/// `dir` maps the `DropDirection` enum values to action types:
175/// * 0/4 (Top/OuterTop)    → `SplitVertical`,   position `before`
176/// * 2/6 (Bottom/OuterBot) → `SplitVertical`,   position `after`
177/// * 3/7 (Left/OuterLeft)  → `SplitHorizontal`, position `before`
178/// * 1/5 (Right/OuterRight)→ `SplitHorizontal`, position `after`
179/// * 8   (Center)          → falls through to `InsertNode` (handled by caller)
180///
181/// Phase 4c: rather than an absolute `nodesize` guess (which was only correct
182/// when the target happened to be at `DefaultNodeSize`), this sends
183/// `nodesizefraction` — the new node's share of the target's CURRENT size —
184/// which the frontend applies against the target's live size at split time
185/// (`layoutTree.ts`). Inner directions use 0.5 (50/50, matching the ghost's
186/// half-leaf); outer directions use 0.2 (matching the ghost's exact `leaf/5`
187/// band — see `ANALYSIS_FLOATING_PANE_GHOST_LANDING_DISCONNECT_2026_07_04.md`).
188pub(super) async fn queue_target_layout_split(
189    state: &super::super::AppState,
190    target_tab_id: &str,
191    block_id: &str,
192    target_block_id: &str,
193    dir: u8,
194) -> Result<(), String> {
195    const INNER_FRACTION: f64 = 0.5;
196    const OUTER_FRACTION: f64 = 0.2;
197    let (actiontype, position, nodesizefraction): (&str, &str, f64) = match dir {
198        0 => ("splitvertical",   "before", INNER_FRACTION),
199        4 => ("splitvertical",   "before", OUTER_FRACTION),
200        2 => ("splitvertical",   "after",  INNER_FRACTION),
201        6 => ("splitvertical",   "after",  OUTER_FRACTION),
202        3 => ("splithorizontal", "before", INNER_FRACTION),
203        7 => ("splithorizontal", "before", OUTER_FRACTION),
204        1 => ("splithorizontal", "after",  INNER_FRACTION),
205        5 => ("splithorizontal", "after",  OUTER_FRACTION),
206        // Center (8) or unknown — caller should use queue_target_layout_insert.
207        _ => return queue_target_layout_insert(state, target_tab_id, block_id).await,
208    };
209    let action = LayoutActionData {
210        actiontype: actiontype.to_string(),
211        actionid: uuid::Uuid::new_v4().to_string(),
212        blockid: block_id.to_string(),
213        nodesize: None,
214        nodesizefraction: Some(nodesizefraction),
215        indexarr: None,
216        focused: true,
217        magnified: false,
218        ephemeral: false,
219        targetblockid: target_block_id.to_string(),
220        position: position.to_string(),
221    };
222    super::reducer_helpers::queue_layout_actions_via_reducer(state, target_tab_id, vec![action])
223        .await
224}
225
226/// Phase E.5.5 — append a layout-delete action to the source tab's
227/// `LayoutState.pendingbackendactions` so the source window's
228/// frontend tears the moved block out of its layout tree on next
229/// poll. Used by the TearOffBlock/PromoteBlockToTab/RedockFloatingPane
230/// RPC handlers (the wcore-direct `tear_off_block` this used to mirror
231/// was dead code, deleted in SPEC_864 Phase 5).
232///
233/// Also used by `sagas::delete_block` (`pub(crate)`, not `pub(super)`,
234/// for that reason) — a direct `LayoutDeleteNodeByBlock` reducer
235/// dispatch prunes `db_layout` correctly but, per this function's own
236/// "why this and not direct writes" doc above, does nothing to tell a
237/// frontend that already has the tab's tree loaded. Without this call
238/// too, that frontend's next unrelated edit in the tab re-pushes its
239/// stale copy — still containing the just-deleted block's leaf — and
240/// silently resurrects it as a dangling reference (empty/dead pane;
241/// see `INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md`).
242pub(crate) async fn queue_source_layout_delete(
243    state: &super::super::AppState,
244    source_tab_id: &str,
245    block_id: &str,
246) -> Result<(), String> {
247    super::reducer_helpers::queue_layout_actions_via_reducer(state, source_tab_id, vec![source_delete_action(block_id)])
248        .await
249}
250
251/// The frontend `delete` action for one block: its handler removes just that
252/// stack member (the leaf goes with the last one) and never runs `closeNode`.
253pub(crate) fn source_delete_action(block_id: &str) -> LayoutActionData {
254    LayoutActionData {
255        actiontype: "delete".to_string(),
256        actionid: uuid::Uuid::new_v4().to_string(),
257        blockid: block_id.to_string(),
258        nodesize: None,
259        nodesizefraction: None,
260        indexarr: None,
261        focused: false,
262        magnified: false,
263        ephemeral: false,
264        targetblockid: String::new(),
265        position: String::new(),
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::server::tests::test_state;
273    use agentmux_common::ipc::{Command, Event};
274
275    /// SPEC_864 Phase 4 — the queue helpers dispatch through the reducer,
276    /// which rejects unknown tabs, so tests must create the tab via
277    /// reducer commands (same pattern as
278    /// `layout_seeders_route_through_reducer_coherently` in server tests)
279    /// rather than reading the wcore-seeded tab.
280    async fn reducer_known_tab(state: &crate::server::AppState) -> String {
281        async fn dispatch_apply(state: &crate::server::AppState, cmd: Command) -> Vec<Event> {
282            let events = super::super::reducer_helpers::dispatch_to_reducer(state, cmd).await;
283            for ev in &events {
284                crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
285            }
286            events
287        }
288        let ws_evs = dispatch_apply(state, Command::CreateWorkspace { name: "ws".into() }).await;
289        let ws_id = ws_evs
290            .iter()
291            .find_map(|e| match e {
292                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
293                _ => None,
294            })
295            .unwrap();
296        let tab_evs = dispatch_apply(
297            state,
298            Command::CreateTab {
299                workspace_id: ws_id,
300                name: "t1".into(),
301            },
302        )
303        .await;
304        tab_evs
305            .iter()
306            .find_map(|e| match e {
307                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
308                _ => None,
309            })
310            .unwrap()
311    }
312
313    fn pending_actions(state: &crate::server::AppState, tab_id: &str) -> Vec<LayoutActionData> {
314        let tab = state.mstore.must_get::<Tab>(tab_id).unwrap();
315        let layout = state.mstore.must_get::<LayoutState>(&tab.layoutstate).unwrap();
316        layout.pendingbackendactions.unwrap_or_default()
317    }
318
319    fn last_action(state: &crate::server::AppState, tab_id: &str) -> LayoutActionData {
320        pending_actions(state, tab_id)
321            .last()
322            .expect("an action was queued")
323            .clone()
324    }
325
326    #[tokio::test]
327    async fn queue_target_layout_split_maps_every_direction_to_an_exact_fraction() {
328        // Phase 4c: every non-Center direction must carry `nodesizefraction`
329        // (not the old hardcoded-integer `nodesize` guess) so the frontend
330        // can derive an exact size from the target's live size. See
331        // ANALYSIS_FLOATING_PANE_GHOST_LANDING_DISCONNECT_2026_07_04.md.
332        let state = test_state();
333        let tab_id = reducer_known_tab(&state).await;
334
335        let cases: &[(u8, &str, &str, f64)] = &[
336            (0, "splitvertical", "before", 0.5),
337            (4, "splitvertical", "before", 0.2),
338            (2, "splitvertical", "after", 0.5),
339            (6, "splitvertical", "after", 0.2),
340            (3, "splithorizontal", "before", 0.5),
341            (7, "splithorizontal", "before", 0.2),
342            (1, "splithorizontal", "after", 0.5),
343            (5, "splithorizontal", "after", 0.2),
344        ];
345
346        for &(dir, expected_type, expected_pos, expected_fraction) in cases {
347            queue_target_layout_split(&state, &tab_id, "new-block", "target-block", dir)
348                .await
349                .unwrap();
350            let action = last_action(&state, &tab_id);
351            assert_eq!(action.actiontype, expected_type, "dir={dir}");
352            assert_eq!(action.position, expected_pos, "dir={dir}");
353            assert_eq!(action.nodesizefraction, Some(expected_fraction), "dir={dir}");
354            assert_eq!(action.nodesize, None, "dir={dir}: absolute nodesize is no longer used for splits");
355            assert_eq!(action.targetblockid, "target-block");
356        }
357        // 8 splits queued → 8 actions APPENDED (not replaced).
358        assert_eq!(pending_actions(&state, &tab_id).len(), cases.len());
359    }
360
361    #[tokio::test]
362    async fn queue_target_layout_split_falls_back_to_insert_for_center_and_unknown_dirs() {
363        let state = test_state();
364        let tab_id = reducer_known_tab(&state).await;
365
366        for dir in [8u8, 200u8] {
367            queue_target_layout_split(&state, &tab_id, "new-block", "target-block", dir)
368                .await
369                .unwrap();
370            let action = last_action(&state, &tab_id);
371            assert_eq!(action.actiontype, "insert", "dir={dir}");
372            assert_eq!(action.nodesizefraction, None, "dir={dir}");
373        }
374    }
375
376    /// SPEC_864 Phase 4 — queue writers hit the reducer's unknown-tab
377    /// validation instead of silently writing mstore.
378    #[tokio::test]
379    async fn queue_helpers_error_on_reducer_unknown_tab() {
380        let state = test_state();
381        let err = queue_source_layout_delete(&state, "ghost-tab", "b1")
382            .await
383            .expect_err("unknown tab must error");
384        assert!(err.contains("unknown tab"), "got: {err}");
385    }
386}