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}