Skip to main content

agentmux_srv/server/service/
tab_move.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `workspace` service handlers — drag-and-drop block/tab moves
5//! (`MoveBlockToTab`, `PromoteBlockToTab`, `MoveTabToWorkspace`,
6//! `RestoreTornOffTab`). Split out of `workspace.rs`; see that file's
7//! dispatcher for the full method list.
8
9use crate::backend::obj::*;
10use crate::backend::service::{self, WebCallType, WebReturnType};
11
12use super::super::AppState;
13use super::layout_helpers::{queue_source_layout_delete, setup_torn_off_block_layout};
14use super::reducer_helpers::{dispatch_to_reducer, publish_events};
15
16// Phase E.5.7 — MoveBlockToTab migrated to dispatch
17// Command::MoveBlock through the reducer. Auto-close empty
18// source tab still uses Command::DeleteTab. ws_id arg kept
19// for backward compat — used only for the post-op SQLite
20// refresh + auto-close workspace check.
21pub(crate) async fn handle_move_block_to_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
22    let store = &state.mstore;
23    let args = &call.args;
24    let ws_id: String = match service::get_arg(args, 0) {
25        Ok(v) => v,
26        Err(e) => return WebReturnType::error(e),
27    };
28    let block_id: String = match service::get_arg(args, 1) {
29        Ok(v) => v,
30        Err(e) => return WebReturnType::error(e),
31    };
32    let source_tab_id: String = match service::get_arg(args, 2) {
33        Ok(v) => v,
34        Err(e) => return WebReturnType::error(e),
35    };
36    let dest_tab_id: String = match service::get_arg(args, 3) {
37        Ok(v) => v,
38        Err(e) => return WebReturnType::error(e),
39    };
40    let auto_close: bool = service::get_arg(args, 4).unwrap_or(true);
41    tracing::info!(ws_id = %ws_id, block_id = %block_id, source_tab = %source_tab_id, dest_tab = %dest_tab_id, "[dnd:svc] MoveBlockToTab via reducer");
42    // codex P2 #622: same-tab requests were no-ops in the
43    // prior wcore handler. The reducer's MoveBlock treats
44    // same source = dest as an in-tab reorder; with
45    // `dst_index: u32::MAX` it would silently move the block
46    // to the end of the list. Short-circuit to preserve the
47    // prior contract — a `MoveBlockToTab` whose dest equals
48    // the source is a UI quirk (e.g. drop on origin tab),
49    // not an intentional reorder.
50    if source_tab_id == dest_tab_id {
51        return WebReturnType::success_empty();
52    }
53    // Move the block via the reducer. dst_index 0 to mirror
54    // wcore::move_block_to_tab which appended at end... wait,
55    // wcore appends, so end-of-list. The reducer's MoveBlock
56    // clamps dst_index to dst.block_ids.len(); use u32::MAX
57    // to land at the end.
58    let events = dispatch_to_reducer(
59        state,
60        agentmux_common::ipc::Command::MoveBlock {
61            block_id: block_id.clone(),
62            src_tab_id: source_tab_id.clone(),
63            dst_tab_id: dest_tab_id.clone(),
64            dst_index: u32::MAX,
65        },
66    )
67    .await;
68    if let Some(err_msg) = events.iter().find_map(|e| match e {
69        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
70        _ => None,
71    }) {
72        return WebReturnType::error(err_msg);
73    }
74    for ev in &events {
75        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
76            return WebReturnType::error(format!(
77                "MoveBlockToTab: SQLite write failed: {}",
78                e
79            ));
80        }
81    }
82    publish_events(state, &events);
83    // Auto-close empty source tab (mirrors wcore::move_block_to_tab).
84    if auto_close {
85        let should_close = match store.must_get::<Tab>(&source_tab_id) {
86            Ok(t) => t.blockids.is_empty(),
87            Err(_) => false,
88        };
89        if should_close {
90            let total_tabs = match store.must_get::<Workspace>(&ws_id) {
91                Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
92                Err(_) => 0,
93            };
94            if total_tabs > 1 {
95                let close_events = dispatch_to_reducer(
96                    state,
97                    agentmux_common::ipc::Command::DeleteTab {
98                        workspace_id: ws_id.clone(),
99                        tab_id: source_tab_id.clone(),
100                        // Auto-close already gated on
101                        // `total_tabs > 1` above; reducer's
102                        // last-tab guard is defense-in-depth
103                        // for the race window.
104                        force: false,
105                    },
106                )
107                .await;
108                for ev in &close_events {
109                    let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
110                }
111                publish_events(state, &close_events);
112            }
113        }
114    }
115    let mut updates = vec![];
116    if let Ok(src) = store.must_get::<Tab>(&source_tab_id) {
117        updates.push(MuxObjUpdate {
118            updatetype: "update".into(),
119            otype: OTYPE_TAB.to_string(),
120            oid: source_tab_id.clone(),
121            obj: Some(mux_obj_to_value(&src)),
122        });
123    }
124    if let Ok(dst) = store.must_get::<Tab>(&dest_tab_id) {
125        updates.push(MuxObjUpdate {
126            updatetype: "update".into(),
127            otype: OTYPE_TAB.to_string(),
128            oid: dest_tab_id.clone(),
129            obj: Some(mux_obj_to_value(&dst)),
130        });
131    }
132    if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
133        updates.push(MuxObjUpdate {
134            updatetype: "update".into(),
135            otype: OTYPE_WORKSPACE.to_string(),
136            oid: ws_id.clone(),
137            obj: Some(mux_obj_to_value(&ws)),
138        });
139    }
140    WebReturnType::success_with_updates(updates)
141}
142
143// Phase E.5.7 — PromoteBlockToTab migrated to saga
144// (CreateTab + MoveBlock). Layout setup + SetActiveTab +
145// auto-close source tab stay wcore-direct here (E.4 layout
146// territory). Same shape as TearOffBlock's RPC handler.
147pub(crate) async fn handle_promote_block_to_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
148    let store = &state.mstore;
149    let args = &call.args;
150    let ws_id: String = match service::get_arg(args, 0) {
151        Ok(v) => v,
152        Err(e) => return WebReturnType::error(e),
153    };
154    let block_id: String = match service::get_arg(args, 1) {
155        Ok(v) => v,
156        Err(e) => return WebReturnType::error(e),
157    };
158    let source_tab_id: String = match service::get_arg(args, 2) {
159        Ok(v) => v,
160        Err(e) => return WebReturnType::error(e),
161    };
162    let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
163    tracing::info!(ws_id = %ws_id, block_id = %block_id, source_tab = %source_tab_id, "[dnd:svc] PromoteBlockToTab via saga");
164    let saga_result = crate::sagas::promote_block_to_tab::run(
165        state,
166        block_id.clone(),
167        source_tab_id.clone(),
168        ws_id.clone(),
169    )
170    .await;
171    let new_tab_oid = match saga_result {
172        Ok(v) => v
173            .get("new_tab_id")
174            .and_then(|v| v.as_str())
175            .unwrap_or_default()
176            .to_string(),
177        Err(reason) => return WebReturnType::error(reason),
178    };
179
180    // Layout setup: rootnode + leaforder for the new tab so
181    // the frontend renders the moved block correctly. Same
182    // helper TearOffBlock uses.
183    if let Err(e) = setup_torn_off_block_layout(state, &new_tab_oid, &block_id).await {
184        tracing::warn!(new_tab = %new_tab_oid, "PromoteBlockToTab: layout setup failed: {}", e);
185    }
186    // Source tab: queue layout-delete action.
187    if let Err(e) = queue_source_layout_delete(state, &source_tab_id, &block_id).await {
188        tracing::warn!(source_tab = %source_tab_id, "PromoteBlockToTab: source layout delete-action enqueue failed: {}", e);
189    }
190    // Set the new tab as active in the workspace via reducer.
191    let active_events = dispatch_to_reducer(
192        state,
193        agentmux_common::ipc::Command::SetActiveTab {
194            workspace_id: ws_id.clone(),
195            tab_id: new_tab_oid.clone(),
196        },
197    )
198    .await;
199    for ev in &active_events {
200        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
201            tracing::warn!("PromoteBlockToTab: SetActiveTab apply failed: {}", e);
202        }
203    }
204    publish_events(state, &active_events);
205
206    // Auto-close empty source tab (mirrors wcore behaviour).
207    if auto_close {
208        let should_close = match store.must_get::<Tab>(&source_tab_id) {
209            Ok(t) => t.blockids.is_empty(),
210            Err(_) => false,
211        };
212        if should_close {
213            let total_tabs = match store.must_get::<Workspace>(&ws_id) {
214                Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
215                Err(_) => 0,
216            };
217            if total_tabs > 1 {
218                let close_events = dispatch_to_reducer(
219                    state,
220                    agentmux_common::ipc::Command::DeleteTab {
221                        workspace_id: ws_id.clone(),
222                        tab_id: source_tab_id.clone(),
223                        // Auto-close already gated on
224                        // `total_tabs > 1` above; reducer's
225                        // last-tab guard is defense-in-depth
226                        // for the race window.
227                        force: false,
228                    },
229                )
230                .await;
231                for ev in &close_events {
232                    let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
233                }
234                publish_events(state, &close_events);
235            }
236        }
237    }
238
239    let mut updates = vec![];
240    if let Ok(new_tab) = store.must_get::<Tab>(&new_tab_oid) {
241        updates.push(MuxObjUpdate {
242            updatetype: "update".into(),
243            otype: OTYPE_TAB.to_string(),
244            oid: new_tab_oid.clone(),
245            obj: Some(mux_obj_to_value(&new_tab)),
246        });
247    }
248    if let Ok(src) = store.must_get::<Tab>(&source_tab_id) {
249        updates.push(MuxObjUpdate {
250            updatetype: "update".into(),
251            otype: OTYPE_TAB.to_string(),
252            oid: source_tab_id.clone(),
253            obj: Some(mux_obj_to_value(&src)),
254        });
255    }
256    if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
257        updates.push(MuxObjUpdate {
258            updatetype: "update".into(),
259            otype: OTYPE_WORKSPACE.to_string(),
260            oid: ws_id.clone(),
261            obj: Some(mux_obj_to_value(&ws)),
262        });
263    }
264    WebReturnType::success_data_updates(
265        serde_json::to_value(&new_tab_oid).unwrap_or_default(),
266        updates,
267    )
268}
269
270// Phase E.5.5 — MoveTabToWorkspace migrated to dispatch
271// Command::MoveTab through the reducer. Closes codex P1 #621
272// (the saga's reducer-state pre-check rejected tear-off after
273// a wcore-direct cross-window drag had left state.tabs stale)
274// by routing all tab moves through the reducer so its view
275// always matches SQLite.
276pub(crate) async fn handle_move_tab_to_workspace(state: &AppState, call: &WebCallType) -> WebReturnType {
277    let store = &state.mstore;
278    let args = &call.args;
279    let tab_id: String = match service::get_arg(args, 0) {
280        Ok(v) => v,
281        Err(e) => return WebReturnType::error(e),
282    };
283    let source_ws_id: String = match service::get_arg(args, 1) {
284        Ok(v) => v,
285        Err(e) => return WebReturnType::error(e),
286    };
287    let dest_ws_id: String = match service::get_arg(args, 2) {
288        Ok(v) => v,
289        Err(e) => return WebReturnType::error(e),
290    };
291    let insert_index: Option<u32> = service::get_arg::<usize>(args, 3)
292        .ok()
293        .map(|v| v.try_into().unwrap_or(u32::MAX));
294    tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, dest_ws = %dest_ws_id, insert_index = ?insert_index, "[dnd:svc] MoveTabToWorkspace via reducer");
295    // Same-workspace short-circuit matches wcore behaviour.
296    // The reducer rejects same-workspace moves outright (use
297    // ReorderTab instead); for the RPC contract, treat it as
298    // a no-op success so existing callers don't see a
299    // behavioural regression.
300    if source_ws_id == dest_ws_id {
301        return WebReturnType::success_empty();
302    }
303    // Last-tab guard mirrors wcore::move_tab_to_workspace —
304    // the reducer's MoveTab doesn't enforce this (intentionally,
305    // for sagas that legitimately drain a workspace to delete
306    // it). Keep the guard at the RPC layer where the policy
307    // belongs. Reads SQLite rather than reducer state — as of
308    // SPEC_864 Phase 5 every tab-set mutation (CreateTab, MoveTab,
309    // and the PromoteBlockToTab/TearOffBlock/TearOffTab sagas) is
310    // reducer-routed, so the two are expected to agree; SQLite is
311    // just the existing read path here, not a staleness workaround
312    // (codex P1 round-2 #621, which motivated it, predates that).
313    match store.get::<Workspace>(&source_ws_id) {
314        Ok(Some(src_ws)) => {
315            let total_tabs = src_ws.tabids.len() + src_ws.pinnedtabids.len();
316            if total_tabs <= 1 {
317                return WebReturnType::error(
318                    "cannot move last tab out of workspace".to_string(),
319                );
320            }
321        }
322        Ok(None) => {
323            return WebReturnType::error(format!(
324                "MoveTabToWorkspace: source workspace not found: {}",
325                source_ws_id
326            ));
327        }
328        Err(e) => {
329            return WebReturnType::error(format!(
330                "MoveTabToWorkspace: workspace read failed: {}",
331                e
332            ));
333        }
334    }
335    let dst_index = insert_index.unwrap_or(u32::MAX);
336    let events = dispatch_to_reducer(
337        state,
338        agentmux_common::ipc::Command::MoveTab {
339            tab_id: tab_id.clone(),
340            src_workspace_id: source_ws_id.clone(),
341            dst_workspace_id: dest_ws_id.clone(),
342            dst_index,
343        },
344    )
345    .await;
346    if let Some(err_msg) = events.iter().find_map(|e| match e {
347        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
348        _ => None,
349    }) {
350        return WebReturnType::error(err_msg);
351    }
352    for ev in &events {
353        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
354            return WebReturnType::error(format!(
355                "MoveTabToWorkspace: SQLite write failed: {}",
356                e
357            ));
358        }
359    }
360    publish_events(state, &events);
361    let mut updates = Vec::new();
362    if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
363        updates.push(MuxObjUpdate {
364            updatetype: "update".into(),
365            otype: OTYPE_WORKSPACE.to_string(),
366            oid: source_ws_id.clone(),
367            obj: Some(mux_obj_to_value(&src_ws)),
368        });
369    }
370    if let Ok(dst_ws) = store.must_get::<Workspace>(&dest_ws_id) {
371        updates.push(MuxObjUpdate {
372            updatetype: "update".into(),
373            otype: OTYPE_WORKSPACE.to_string(),
374            oid: dest_ws_id.clone(),
375            obj: Some(mux_obj_to_value(&dst_ws)),
376        });
377    }
378    WebReturnType::success_with_updates(updates)
379}
380
381// Phase E.5.6 — RestoreTornOffTab migrated to saga (MoveTab
382// back + conditional DeleteWorkspaceCascade if source becomes
383// empty). The legacy `was_pinned` arg is ignored — pinning
384// was removed from AgentMux in E.2c.3b; restored tabs always
385// land in `tab_ids`.
386pub(crate) async fn handle_restore_torn_off_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
387    let store = &state.mstore;
388    let args = &call.args;
389    let tab_id: String = match service::get_arg(args, 0) {
390        Ok(v) => v,
391        Err(e) => return WebReturnType::error(e),
392    };
393    let source_ws_id: String = match service::get_arg(args, 1) {
394        Ok(v) => v,
395        Err(e) => return WebReturnType::error(e),
396    };
397    let dest_ws_id: String = match service::get_arg(args, 2) {
398        Ok(v) => v,
399        Err(e) => return WebReturnType::error(e),
400    };
401    let insert_index: Option<u32> = service::get_arg::<usize>(args, 3)
402        .ok()
403        .map(|v| v.try_into().unwrap_or(u32::MAX));
404    tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, dest_ws = %dest_ws_id, insert_index = ?insert_index, "[dnd:svc] RestoreTornOffTab via saga");
405    let saga_result = crate::sagas::restore_torn_off_tab::run(
406        state,
407        tab_id,
408        source_ws_id.clone(),
409        dest_ws_id.clone(),
410        insert_index,
411    )
412    .await;
413    match saga_result {
414        Ok(_) => {
415            let mut updates = Vec::new();
416            match store.get::<Workspace>(&source_ws_id) {
417                Ok(Some(src_ws)) => {
418                    updates.push(MuxObjUpdate {
419                        updatetype: "update".into(),
420                        otype: OTYPE_WORKSPACE.to_string(),
421                        oid: source_ws_id.clone(),
422                        obj: Some(mux_obj_to_value(&src_ws)),
423                    });
424                }
425                Ok(None) => {
426                    updates.push(MuxObjUpdate {
427                        updatetype: "delete".into(),
428                        otype: OTYPE_WORKSPACE.to_string(),
429                        oid: source_ws_id.clone(),
430                        obj: None,
431                    });
432                }
433                Err(_) => {}
434            }
435            if let Ok(dst_ws) = store.must_get::<Workspace>(&dest_ws_id) {
436                updates.push(MuxObjUpdate {
437                    updatetype: "update".into(),
438                    otype: OTYPE_WORKSPACE.to_string(),
439                    oid: dest_ws_id.clone(),
440                    obj: Some(mux_obj_to_value(&dst_ws)),
441                });
442            }
443            WebReturnType::success_with_updates(updates)
444        }
445        Err(reason) => WebReturnType::error(reason),
446    }
447}