Skip to main content

agentmux_srv/server/service/
tear_off.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `workspace` service handlers — floating-pane / torn-off tab flows
5//! (`TearOffBlock`, `RedockFloatingPane`, `TearOffTab`). Split out of
6//! `workspace.rs`; see that file's dispatcher for the full method list.
7
8use crate::backend::obj::*;
9use crate::backend::service::{self, WebCallType, WebReturnType};
10
11use super::super::AppState;
12use super::layout_helpers::{
13    queue_source_layout_delete, queue_target_layout_insert, queue_target_layout_split,
14    queue_target_stack_push, setup_torn_off_block_layout,
15};
16use super::reducer_helpers::{dispatch_to_reducer, publish_events};
17
18// Phase E.5.5 — TearOffBlock migrated to saga (reducer-state
19// portion: CreateWorkspace + CreateTab + MoveBlock). Layout
20// tree setup on the new tab + queueing the source tab's
21// layout-delete action stay wcore-direct here — layout state
22// is E.4 work, separately scoped. The saga's atomicity is
23// limited to the reducer-state portion; layout writes are
24// best-effort and can leave a torn-off block with a malformed
25// layout if the post-saga step fails. Acceptable trade-off
26// for the smoke regression fix; full atomicity is a Phase F+
27// gap (see saga-coordinator-location-analysis-2026-04-30.md
28// §4.2).
29pub(crate) async fn handle_tear_off_block(state: &AppState, call: &WebCallType) -> WebReturnType {
30    let store = &state.mstore;
31    let args = &call.args;
32    let block_id: String = match service::get_arg(args, 0) {
33        Ok(v) => v,
34        Err(e) => return WebReturnType::error(e),
35    };
36    let source_tab_id: String = match service::get_arg(args, 1) {
37        Ok(v) => v,
38        Err(e) => return WebReturnType::error(e),
39    };
40    let source_ws_id: String = match service::get_arg(args, 2) {
41        Ok(v) => v,
42        Err(e) => return WebReturnType::error(e),
43    };
44    let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
45    tracing::info!(block_id = %block_id, source_tab = %source_tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffBlock via saga");
46    let saga_result = crate::sagas::tear_off_block::run(
47        state,
48        block_id.clone(),
49        source_tab_id.clone(),
50        source_ws_id.clone(),
51    )
52    .await;
53    let (new_ws_oid, new_tab_oid) = match saga_result {
54        Ok(value) => {
55            let new_ws_oid = value
56                .get("new_workspace_id")
57                .and_then(|v| v.as_str())
58                .unwrap_or_default()
59                .to_string();
60            let new_tab_oid = value
61                .get("new_tab_id")
62                .and_then(|v| v.as_str())
63                .unwrap_or_default()
64                .to_string();
65            (new_ws_oid, new_tab_oid)
66        }
67        Err(reason) => return WebReturnType::error(reason),
68    };
69
70    // Layout setup for the new tab — make the moved block its
71    // single root node so the frontend renders it. Best-effort;
72    // reducer-routed (SPEC_864 Phase 3 seed / Phase 4 queue).
73    if let Err(e) = setup_torn_off_block_layout(state, &new_tab_oid, &block_id).await {
74        tracing::warn!(new_tab = %new_tab_oid, "TearOffBlock: layout setup failed: {} (block in tab but layout malformed)", e);
75    }
76    // Source tab, backend side: prune the block from the reducer's own copy
77    // of the source tree (`db_layout`) now, the same two-step the
78    // `delete_block` saga does (its steps 2 and 2b). Stack-safe: when the
79    // block is one tab of a multi-tab pane, only that member is removed and
80    // the pane stays (`remove_stack_member`); the leaf goes only if it was
81    // the pane's last tab. Without this the backend tree was only fixed
82    // when the source window echoed its tree back, and a failed or missed
83    // echo left a dangling leaf behind.
84    // SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §3.5 (design 4).
85    let prune_events = dispatch_to_reducer(
86        state,
87        agentmux_common::ipc::Command::LayoutDeleteNodeByBlock {
88            tab_id: source_tab_id.clone(),
89            block_id: block_id.clone(),
90            correlation_id: String::new(),
91        },
92    )
93    .await;
94    // Best-effort, like the delete_block saga's identical dispatch: the block
95    // has already moved, so a failed prune must not fail the tear-off. But it
96    // must not be silent either — it's exactly the dangling-leaf case this
97    // step exists to prevent (reagent P1 on #3708). The queued frontend delete
98    // below still runs either way.
99    if let Some(msg) = prune_events.iter().find_map(|e| match e {
100        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
101        _ => None,
102    }) {
103        tracing::warn!(
104            source_tab = %source_tab_id,
105            block_id = %block_id,
106            "TearOffBlock: backend source-layout prune failed (best-effort; the source window's queued delete still applies): {}",
107            msg
108        );
109    }
110    for ev in &prune_events {
111        let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
112    }
113    publish_events(state, &prune_events);
114
115    // Source tab, frontend side: queue a layout-delete action so the source
116    // window's frontend removes the node from its tree.
117    if let Err(e) = queue_source_layout_delete(state, &source_tab_id, &block_id).await {
118        tracing::warn!(source_tab = %source_tab_id, "TearOffBlock: source layout delete-action enqueue failed: {}", e);
119    }
120
121    // Auto-close empty source tab. Route through the reducer
122    // (DeleteTab cascade is built in; the tab has no blocks
123    // at this point — we just moved the only one out). Skip
124    // when source workspace would become empty.
125    if auto_close {
126        let should_close = match store.must_get::<Tab>(&source_tab_id) {
127            Ok(t) => t.blockids.is_empty(),
128            Err(_) => false,
129        };
130        if should_close {
131            let total_tabs = match store.must_get::<Workspace>(&source_ws_id) {
132                Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
133                Err(_) => 0,
134            };
135            if total_tabs > 1 {
136                tracing::info!(source_tab = %source_tab_id, "[dnd:svc] auto-closing empty source tab after TearOffBlock");
137                let close_events = dispatch_to_reducer(
138                    state,
139                    agentmux_common::ipc::Command::DeleteTab {
140                        workspace_id: source_ws_id.clone(),
141                        tab_id: source_tab_id.clone(),
142                        // Auto-close already gated on
143                        // total_tabs > 1.
144                        force: false,
145                    },
146                )
147                .await;
148                for ev in &close_events {
149                    let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
150                }
151                publish_events(state, &close_events);
152            }
153        }
154    }
155
156    let mut updates = Vec::new();
157    if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
158        updates.push(MuxObjUpdate {
159            updatetype: "update".into(),
160            otype: OTYPE_TAB.to_string(),
161            oid: source_tab_id.clone(),
162            obj: Some(mux_obj_to_value(&src_tab)),
163        });
164    }
165    if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
166        updates.push(MuxObjUpdate {
167            updatetype: "update".into(),
168            otype: OTYPE_WORKSPACE.to_string(),
169            oid: source_ws_id.clone(),
170            obj: Some(mux_obj_to_value(&src_ws)),
171        });
172    }
173    if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
174        updates.push(MuxObjUpdate {
175            updatetype: "update".into(),
176            otype: OTYPE_WORKSPACE.to_string(),
177            oid: new_ws_oid.clone(),
178            obj: Some(mux_obj_to_value(&new_ws)),
179        });
180    }
181    WebReturnType::success_data_updates(
182        serde_json::to_value(&new_ws_oid).unwrap_or_default(),
183        updates,
184    )
185}
186
187// Floating-pane Phase 4a — re-dock a floater's block back into
188// an existing tab in another workspace. Same shape as
189// TearOffBlock's RPC handler: saga handles the reducer-state
190// move (MoveBlock); layout writes are wcore-direct (the
191// target's layout grows a leaf; the source's layout enqueues
192// a delete action). Source floater closes via PR #1089's
193// empty-tab watcher once its tab.blockids is empty.
194// Spec: docs/specs/SPEC_FLOATING_PANE_REDOCK_2026-05-27.md
195pub(crate) async fn handle_redock_floating_pane(state: &AppState, call: &WebCallType) -> WebReturnType {
196    let store = &state.mstore;
197    let args = &call.args;
198    let block_id: String = match service::get_arg(args, 0) {
199        Ok(v) => v,
200        Err(e) => return WebReturnType::error(e),
201    };
202    let source_tab_id: String = match service::get_arg(args, 1) {
203        Ok(v) => v,
204        Err(e) => return WebReturnType::error(e),
205    };
206    let source_ws_id: String = match service::get_arg(args, 2) {
207        Ok(v) => v,
208        Err(e) => return WebReturnType::error(e),
209    };
210    let target_tab_id: String = match service::get_arg(args, 3) {
211        Ok(v) => v,
212        Err(e) => return WebReturnType::error(e),
213    };
214    let target_ws_id: String = match service::get_arg(args, 4) {
215        Ok(v) => v,
216        Err(e) => return WebReturnType::error(e),
217    };
218    // Phase 4b — optional direction hint from the ghost overlay.
219    // Both must be present; if either is absent fall back to InsertNode.
220    let target_block_id: Option<String> = service::get_optional_arg(args, 5)
221        .unwrap_or(None);
222    let direction: Option<u8> = service::get_optional_arg(args, 6)
223        .unwrap_or(None);
224    // Optional: land the block as a TAB of `target_block_id`'s pane (its
225    // stack, activated) instead of as a split/insert. Used to roll a failed
226    // pane-tab tear-off back into the pane it came from
227    // (SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §3.5, design 6).
228    let as_tab: bool = service::get_optional_arg(args, 7).unwrap_or(None).unwrap_or(false);
229    tracing::info!(
230        block_id = %block_id,
231        source_tab = %source_tab_id,
232        source_ws = %source_ws_id,
233        target_tab = %target_tab_id,
234        target_ws = %target_ws_id,
235        target_block_id = ?target_block_id,
236        direction = ?direction,
237        "[dnd:svc] RedockFloatingPane via saga"
238    );
239    let saga_result = crate::sagas::redock_floating_pane::run(
240        state,
241        block_id.clone(),
242        source_tab_id.clone(),
243        source_ws_id.clone(),
244        target_tab_id.clone(),
245        target_ws_id.clone(),
246        None,
247    )
248    .await;
249    if let Err(reason) = saga_result {
250        return WebReturnType::error(reason);
251    }
252
253    // Target layout: enqueue the appropriate action on its
254    // `pendingbackendactions`. Phase 4b: when a direction hint is
255    // present, queue SplitHorizontal/SplitVertical so the block lands
256    // in the exact slot the ghost previewed. Otherwise fall back to
257    // the generic InsertNode (Phase 4a behavior).
258    // Layout writes are required before the Tab broadcast — if either
259    // fails the block becomes invisible. Return error so the caller can
260    // retry; no visible change has propagated to the renderers yet.
261    let target_layout_result = match (target_block_id.as_deref(), direction) {
262        (Some(tbid), _) if as_tab => redock_as_tab(state, &target_tab_id, &block_id, tbid).await,
263        (Some(tbid), Some(dir)) => {
264            queue_target_layout_split(state, &target_tab_id, &block_id, tbid, dir).await
265        }
266        _ => queue_target_layout_insert(state, &target_tab_id, &block_id).await,
267    };
268    if let Err(e) = target_layout_result {
269        tracing::error!(
270            target_tab = %target_tab_id,
271            "RedockFloatingPane: target layout action failed — aborting broadcast: {}",
272            e
273        );
274        return WebReturnType::error(format!(
275            "redock layout action failed: {e}"
276        ));
277    }
278    if let Err(e) = queue_source_layout_delete(state, &source_tab_id, &block_id).await {
279        tracing::error!(
280            source_tab = %source_tab_id,
281            "RedockFloatingPane: source layout delete failed — aborting broadcast: {}",
282            e
283        );
284        return WebReturnType::error(format!(
285            "redock layout delete failed: {e}"
286        ));
287    }
288
289    let mut updates = Vec::new();
290    // Layout updates MUST come first — the queue helpers persist
291    // via the reducer route (SPEC_864 Phase 4), whose SQLite write
292    // is NOT auto-broadcast (only the row gets a new version).
293    // Without these entries in the response, the target window's
294    // frontend never sees the new leaf and renders nothing; the
295    // source's pending delete action never gets pulled either.
296    // Both layouts are read AFTER the helpers return — the
297    // helpers persist synchronously inside the reducer lock, so
298    // the re-read always sees the appended actions.
299    if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
300        if let Ok(src_layout) = store.must_get::<LayoutState>(&src_tab.layoutstate) {
301            updates.push(MuxObjUpdate {
302                updatetype: "update".into(),
303                otype: OTYPE_LAYOUT.to_string(),
304                oid: src_tab.layoutstate.clone(),
305                obj: Some(mux_obj_to_value(&src_layout)),
306            });
307        }
308        updates.push(MuxObjUpdate {
309            updatetype: "update".into(),
310            otype: OTYPE_TAB.to_string(),
311            oid: source_tab_id.clone(),
312            obj: Some(mux_obj_to_value(&src_tab)),
313        });
314    }
315    if let Ok(dst_tab) = store.must_get::<Tab>(&target_tab_id) {
316        if let Ok(dst_layout) = store.must_get::<LayoutState>(&dst_tab.layoutstate) {
317            updates.push(MuxObjUpdate {
318                updatetype: "update".into(),
319                otype: OTYPE_LAYOUT.to_string(),
320                oid: dst_tab.layoutstate.clone(),
321                obj: Some(mux_obj_to_value(&dst_layout)),
322            });
323        }
324        updates.push(MuxObjUpdate {
325            updatetype: "update".into(),
326            otype: OTYPE_TAB.to_string(),
327            oid: target_tab_id.clone(),
328            obj: Some(mux_obj_to_value(&dst_tab)),
329        });
330    }
331    // CRITICAL: MuxObjUpdates in the response only reach the
332    // CALLING renderer (the floater that's about to close). The
333    // TARGET window's renderer is a different process and won't
334    // see the layout change unless we explicitly broadcast on
335    // the event bus. Mirrors the pattern in `app_api/agent_open.rs`.
336    // Without this the target tab.blockids includes the new
337    // block but its layout.leaforder doesn't → block invisible.
338    // One batched frame so the renderer applies all of them in a single
339    // reactive flush — see EventBus::broadcast_mux_obj_updates.
340    state.event_bus.broadcast_mux_obj_updates(&updates);
341
342    WebReturnType::success_data_updates(
343        serde_json::json!({
344            "redocked": true,
345            "block_id": block_id,
346            "target_tab_id": target_tab_id,
347        }),
348        updates,
349    )
350}
351
352/// Place a just-redocked block as a TAB of the pane holding
353/// `target_block_id`, active: the reducer's own tree first
354/// (`LayoutStackPush`, which also checks the block now lives in this tab),
355/// then a queued `stackpush` so the window showing that tab adds the pill.
356/// Same backend-then-frontend shape as `pane.moveTab`.
357async fn redock_as_tab(
358    state: &AppState,
359    target_tab_id: &str,
360    block_id: &str,
361    target_block_id: &str,
362) -> Result<(), String> {
363    let events = dispatch_to_reducer(
364        state,
365        agentmux_common::ipc::Command::LayoutStackPush {
366            tab_id: target_tab_id.to_string(),
367            target_block_id: target_block_id.to_string(),
368            block_id: block_id.to_string(),
369            activate: true,
370            correlation_id: String::new(),
371        },
372    )
373    .await;
374    if let Some(msg) = events.iter().find_map(|e| match e {
375        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
376        _ => None,
377    }) {
378        return Err(msg);
379    }
380    for ev in &events {
381        let _ = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore);
382    }
383    publish_events(state, &events);
384    queue_target_stack_push(state, target_tab_id, block_id, target_block_id).await
385}
386
387// Phase E.5.5 — TearOffTab migrated to saga. Closes the
388// smoke regression where wcore::tear_off_tab created the new
389// workspace bypassing the reducer, leaving the new window's
390// CreateTab/etc. calls failing on "workspace not found"
391// checks against the reducer's stale view.
392pub(crate) async fn handle_tear_off_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
393    let store = &state.mstore;
394    let args = &call.args;
395    let tab_id: String = match service::get_arg(args, 0) {
396        Ok(v) => v,
397        Err(e) => return WebReturnType::error(e),
398    };
399    let source_ws_id: String = match service::get_arg(args, 1) {
400        Ok(v) => v,
401        Err(e) => return WebReturnType::error(e),
402    };
403    tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffTab via saga");
404    match crate::sagas::tear_off_tab::run(state, tab_id, source_ws_id.clone()).await {
405        Ok(saga_result) => {
406            let new_ws_oid = saga_result
407                .get("new_workspace_id")
408                .and_then(|v| v.as_str())
409                .unwrap_or_default()
410                .to_string();
411            let mut updates = Vec::new();
412            if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
413                updates.push(MuxObjUpdate {
414                    updatetype: "update".into(),
415                    otype: OTYPE_WORKSPACE.to_string(),
416                    oid: source_ws_id.clone(),
417                    obj: Some(mux_obj_to_value(&src_ws)),
418                });
419            }
420            if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
421                updates.push(MuxObjUpdate {
422                    updatetype: "update".into(),
423                    otype: OTYPE_WORKSPACE.to_string(),
424                    oid: new_ws_oid.clone(),
425                    obj: Some(mux_obj_to_value(&new_ws)),
426                });
427            }
428            WebReturnType::success_data_updates(
429                serde_json::to_value(&new_ws_oid).unwrap_or_default(),
430                updates,
431            )
432        }
433        Err(reason) => WebReturnType::error(reason),
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    //! Tearing ONE tab out of a multi-tab pane, and putting it back as a tab.
440    //! SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §3.5 (designs 4 and 6).
441    use super::*;
442    use crate::server::tests::test_state;
443    use agentmux_common::ipc::{Command, Event};
444
445    async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
446        let events = dispatch_to_reducer(state, cmd).await;
447        for ev in &events {
448            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
449        }
450        events
451    }
452
453    fn call(method: &str, args: Vec<serde_json::Value>) -> WebCallType {
454        WebCallType { service: "workspace".into(), method: method.into(), uicontext: None, args }
455    }
456
457    /// A workspace with one tab laid out as `[pane-src: stack a,b (active
458    /// a)] | [pane-other: c]`. Returns (ws_id, tab_id, a, b, c).
459    async fn two_tab_pane_and_a_neighbour(state: &AppState) -> (String, String, String, String, String) {
460        let ws_id = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() })
461            .await
462            .iter()
463            .find_map(|e| match e {
464                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
465                _ => None,
466            })
467            .unwrap();
468        let tab_id = dispatch_apply(state, Command::CreateTab { workspace_id: ws_id.clone(), name: "t".into() })
469            .await
470            .iter()
471            .find_map(|e| match e {
472                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
473                _ => None,
474            })
475            .unwrap();
476        let mut ids = Vec::new();
477        for _ in 0..3 {
478            let id = dispatch_apply(state, Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null })
479                .await
480                .iter()
481                .find_map(|e| match e {
482                    Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
483                    _ => None,
484                })
485                .unwrap();
486            ids.push(id);
487        }
488        let (a, b, c) = (ids[0].clone(), ids[1].clone(), ids[2].clone());
489        dispatch_apply(
490            state,
491            Command::LayoutSetTree {
492                tab_id: tab_id.clone(),
493                new_tree: Some(agentmux_common::LayoutNode {
494                    id: "root".into(),
495                    children: vec![
496                        agentmux_common::LayoutNode {
497                            id: "pane-src".into(),
498                            data: Some(agentmux_common::LayoutNodeData {
499                                block_id: a.clone(),
500                                block_stack: vec![a.clone(), b.clone()],
501                                active_block_id: a.clone(),
502                                ..Default::default()
503                            }),
504                            ..Default::default()
505                        },
506                        agentmux_common::LayoutNode {
507                            id: "pane-other".into(),
508                            data: Some(agentmux_common::LayoutNodeData { block_id: c.clone(), ..Default::default() }),
509                            ..Default::default()
510                        },
511                    ],
512                    ..Default::default()
513                }),
514                correlation_id: String::new(),
515                slices: None,
516            },
517        )
518        .await;
519        (ws_id, tab_id, a, b, c)
520    }
521
522    async fn tear_off(state: &AppState, block: &str, tab_id: &str, ws_id: &str) -> String {
523        let ret = handle_tear_off_block(
524            state,
525            &call("TearOffBlock", vec![block.into(), tab_id.into(), ws_id.into(), true.into()]),
526        )
527        .await;
528        assert!(ret.error.is_none(), "TearOffBlock failed: {:?}", ret.error);
529        ret.data.as_ref().and_then(|v| v.as_str()).expect("new workspace id").to_string()
530    }
531
532    async fn src_pane_members(state: &AppState, tab_id: &str) -> (Vec<String>, String, Vec<String>) {
533        let srv = state.srv_state.lock().await;
534        let tab = &srv.tabs[tab_id];
535        let root = tab.rootnode.as_ref().expect("source tree");
536        let src = crate::backend::layout::find_node_by_id(root, "pane-src").expect("source pane survives");
537        let data = src.data.as_ref().unwrap();
538        (crate::backend::layout::leaf_members(data), data.block_id.clone(), tab.block_ids.clone())
539    }
540
541    #[tokio::test]
542    async fn tearing_off_a_background_tab_keeps_its_pane_and_siblings_in_the_backend_tree() {
543        let state = test_state();
544        let (ws_id, tab_id, a, b, c) = two_tab_pane_and_a_neighbour(&state).await;
545
546        tear_off(&state, &b, &tab_id, &ws_id).await;
547
548        let (members, visible, blocks) = src_pane_members(&state, &tab_id).await;
549        assert_eq!(members, vec![a.clone()], "only the torn-off tab left the pane");
550        assert_eq!(visible, a);
551        assert!(blocks.contains(&a) && blocks.contains(&c) && !blocks.contains(&b));
552        // The source window is still told to prune its own copy.
553        let tab = state.mstore.must_get::<Tab>(&tab_id).unwrap();
554        let layout = state.mstore.must_get::<LayoutState>(&tab.layoutstate).unwrap();
555        assert!(
556            layout.pendingbackendactions.unwrap_or_default().iter().any(|x| x.actiontype == "delete" && x.blockid == b),
557            "queued frontend delete"
558        );
559    }
560
561    #[tokio::test]
562    async fn tearing_off_the_visible_tab_makes_its_neighbour_visible() {
563        let state = test_state();
564        let (ws_id, tab_id, a, b, _c) = two_tab_pane_and_a_neighbour(&state).await;
565
566        tear_off(&state, &a, &tab_id, &ws_id).await;
567
568        let (members, visible, _) = src_pane_members(&state, &tab_id).await;
569        assert_eq!(members, vec![b.clone()]);
570        assert_eq!(visible, b, "the right-hand neighbour becomes visible");
571    }
572
573    #[tokio::test]
574    async fn redock_as_tab_puts_a_torn_off_tab_back_into_its_pane() {
575        let state = test_state();
576        let (ws_id, tab_id, a, b, _c) = two_tab_pane_and_a_neighbour(&state).await;
577        let new_ws = tear_off(&state, &b, &tab_id, &ws_id).await;
578        let floater_tab = state.srv_state.lock().await.workspaces[&new_ws].tab_ids[0].clone();
579
580        // The rollback path: back as a TAB of the pane it came from.
581        let ret = handle_redock_floating_pane(
582            &state,
583            &call(
584                "RedockFloatingPane",
585                vec![
586                    b.clone().into(),
587                    floater_tab.into(),
588                    new_ws.into(),
589                    tab_id.clone().into(),
590                    ws_id.into(),
591                    a.clone().into(),
592                    serde_json::Value::Null,
593                    true.into(),
594                ],
595            ),
596        )
597        .await;
598        assert!(ret.error.is_none(), "redock failed: {:?}", ret.error);
599
600        let (members, visible, blocks) = src_pane_members(&state, &tab_id).await;
601        assert_eq!(members, vec![a, b.clone()], "back in its pane's stack, not a split");
602        assert_eq!(visible, b, "and visible");
603        assert!(blocks.contains(&b), "and back in the tab");
604        let tab = state.mstore.must_get::<Tab>(&tab_id).unwrap();
605        let layout = state.mstore.must_get::<LayoutState>(&tab.layoutstate).unwrap();
606        assert!(
607            layout.pendingbackendactions.unwrap_or_default().iter().any(|x| x.actiontype == "stackpush" && x.blockid == b),
608            "the window showing the pane is told to add the pill"
609        );
610    }
611}