add support for cross-window tab drag#9275
Conversation
Removes WorkspaceAction::HandoffPendingTransfer, ReverseHandoff, and FinalizeDropTab. The cross-window drag flow no longer routes through WorkspaceAction; it is now coordinated through the upcoming CrossWindowTabDrag singleton model. hide window when dragging to target fix unstable drop zone render exact copy of tab rendering consistency checkin ghost state checkpoint Fix crashes Update view.rs fix edge case issues around persistence and detachment fix typo integration: add tests for cross-window tab drag Adds four end-to-end integration tests behind the new drag_tabs_to_windows feature on the integration crate: - test_reorder_tabs_with_drag - test_detach_tab_to_new_window_with_drag - test_attach_tab_to_other_window_and_continue_drag - test_single_tab_handoff_continues_drag Wires them into both the manual integration runner and the nextest ui_tests! suite, and adds the matching feature passthrough in crates/integration/Cargo.toml. app_state: skip persistence during cross-window tab drag While a cross-window tab drag is active, the dragged tab's pane group is in flight between the source and preview windows. Both can briefly claim the same terminal_panes.uuid, which trips SQLite's UNIQUE constraint when persistence runs mid-drag. Skip persistence entirely while CrossWindowTabDrag is active; the next mouse-up or non-drag change will trigger a save. Also switches the existing per-window workspace lookup to WorkspaceRegistry, mirroring root_view, and uses the renamed is_tab_drag_preview() helper. root_view: simplify after cross-window tab drag refactor Removes the bespoke DetachTabImmediateArg / TabTransferInfo plumbing and the root_view:detach_tab_immediate global action, both of which existed only to support the old cross-window drag flow. Their responsibilities now live in CrossWindowTabDrag and the workspace view. Updates create_transferred_window to take the TransferredTab and window placement directly and return just the new WindowId, and switches workspace_for_window to look up workspaces through WorkspaceRegistry instead of scanning views_of_type::<Workspace>. workspace view: integrate cross-window tab drag Wires the workspace view into the new CrossWindowTabDrag singleton: - Drives the drag state machine from on_drag/on_drop on tabs and the tab bar. - Exposes helpers (tab_bar_rects_for_window, TransferredTab, TAB_BAR_POSITION_ID) that the singleton uses to coordinate hit testing and view-tree transfers between windows. - Renames is_drag_preview_workspace to is_tab_drag_preview to match the new state model. - Adjusts vertical-tab drag behavior so that when DragTabsToWindows is enabled, vertical tabs can be dragged horizontally out of the panel to detach into a new window. When the flag is off the existing vertical-only constraint is preserved. workspace: add CrossWindowTabDrag singleton model Adds a new singleton model that owns all cross-window tab drag state across the application. The model tracks the drag lifecycle through three phases — Floating, InsertedInTarget, and Transitioning — and exposes on_drag/on_drop entry points that workspace views call to drive the state machine. Two drag sources are supported: - SingleTabWindow: the source window itself acts as the floating preview. - MultiTabWindow: a dedicated preview window is created for the tab. Registers the singleton in workspace::init() so it is available app-wide. The workspace view integration that actually exercises the new APIs is added in a follow-up commit. warpui: track window front-to-back ordering and add window bounds helper Adds a WindowOrderingState to WindowManager so callers can find which window is topmost at a given screen position, which is needed when deciding where a dragged tab should land. Adds a matching ordered_window_ids() API for both the production and integration-test window managers. Also adds AppContext::set_and_cache_window_bounds for callers that need to move a window and update the cache atomically, and tweaks the macOS window close path so force-terminated windows close immediately while normal closes still go through performClose:. Add tab dragging product and tech specs Includes the original drag-tabs-to-windows PRODUCT.md and TECH.md, plus follow-up TECH specs for fix-drag-drop, fix-dragging-out, pane-uuid-collision-on-handoff, and put-back-plus-new-window-overlap. Co-Authored-By: Oz <oz-agent@warp.dev>
|
I'm starting a first review of this pull request. You can follow along in the session on Warp. I completed the review and posted feedback on this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
Adds feature-flagged cross-window tab dragging with new drag-state orchestration, live view transfer support, platform window primitives, specs, and integration coverage.
Concerns
- The source==target put-back finalize path closes the preview asynchronously without keeping the drag guard active, which can allow session persistence to snapshot both the source and preview while they still reference the same pane group.
- Transferred tabs flatten tab color state to a resolved color, losing automatic directory-color vs manually-cleared/manual-color semantics when attaching to another window.
- No security findings.
Verdict
Found: 0 critical, 2 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
| "tab_drag: finalize_handoff source==target, closing preview_wid={}", | ||
| drag.preview_window_id() | ||
| ); | ||
| ctx.windows().close_window( |
There was a problem hiding this comment.
NoOp means finalize does not register a pending close; during a put-back drop the preview still has a TabData for the transferred pane group until on_window_closed, so save_app can snapshot both windows and hit the duplicate-pane race this guard is meant to prevent.
|
|
||
| let index = insertion_index.min(self.tabs.len()); | ||
| let mut tab_data = TabData::new(pane_group); | ||
| tab_data.selected_color = color.map_or(SelectedTabColor::Unset, SelectedTabColor::Color); |
There was a problem hiding this comment.
default_directory_color and selected_color separately instead of reconstructing from color.
|
Note that this needs to be tested on Windows before we can roll it out |
|
|
||
| ### Input focus after drag ends | ||
|
|
||
| - When the drag completes, the resulting active tab must have terminal input focus. |
There was a problem hiding this comment.
Should really just be whatever was already in focus within the tab, whether its a terminal input, overlay menu, or whatever (not necessarily terminal input)
There was a problem hiding this comment.
would like both specs to be in dir with linear ticket id (we should stop nesting them under username as well)
zachbai
left a comment
There was a problem hiding this comment.
the spec at a high level is reasonable and makes sense but as its probably obvious there are lots of little edge cases that appear to be addressed but hint at maybe the existence of more yet to be found.
Can you also verify that everything looks good when the vertical tabs panel is moved to the right via Re-arrange toolbar items
## Description Adds Chrome-style cross-window tab dragging behind the `DragTabsToWindows` feature flag. A user can drag a tab out of a window to create a new one, drag it into another window's tab bar to attach it, and keep dragging through multiple attach/detach cycles without releasing the mouse. Product behavior is fully described in `specs/pei/cross-window-tab-drag/PRODUCT.md`; the architectural rationale is in `specs/pei/cross-window-tab-drag/TECH.md`. https://www.loom.com/share/94d21b4b573c4f6893684142d66b844a ## How it works A cross-window drag has two shapes depending on where it starts: - **Single-tab window** — the source window itself follows the cursor and acts as the drag preview. No second window is created. - **Multi-tab window** — when the user drags a tab out of the tab bar, a dedicated preview window is spun up to hold the dragged tab, and the source window stays in place showing its remaining tabs. As the user drags, we continuously hit-test the cursor against the tab bars of other windows in z-order. When the cursor enters an eligible tab bar we show a lightweight ghost (insertion slot + floating chip) in the target; the live view tree only moves at drop time, except in the back-to-caller case where the tab is transferred so the source window can host real reordering. If the cursor leaves a target tab bar, any handoff is reversed and the drag continues. On drop, the preview is either promoted to a permanent window (no target), folded into the target (handoff committed), or cleaned up (no-op). ## State machines Full ASCII versions live in the module doc at `app/src/workspace/cross_window_tab_drag.rs`. Mermaid renderings: ### Single-tab source window ```mermaid stateDiagram-v2 [*] --> Floating: begin_single_tab_drag Floating --> Transitioning: cursor enters target tab bar Transitioning --> InsertedInTarget InsertedInTarget --> Floating: cursor leaves target tab bar (reverse_handoff) InsertedInTarget --> FinalizeHandoff: on_drop while inserted Floating --> FinalizeFloatingWindow: on_drop while floating FinalizeHandoff --> [*] FinalizeFloatingWindow --> [*] ``` The source window itself is the preview, so no extra window is created. `FinalizeFloatingWindow` just leaves the source window where the user dropped it. ### Multi-tab source window ```mermaid stateDiagram-v2 [*] --> Floating: begin_multi_tab_drag (creates preview window) Floating --> GhostInTarget: cursor enters target tab bar (deferred) GhostInTarget --> Floating: cursor leaves target tab bar Floating --> Transitioning: cursor re-enters source (back-to-caller) Transitioning --> InsertedInTarget InsertedInTarget --> Floating: cursor leaves source tab bar (reverse_handoff) InsertedInTarget --> FinalizeHandoff: on_drop while inserted GhostInTarget --> FinalizeHandoff: on_drop over target Floating --> FinalizePreviewAsNewWindow: on_drop while floating FinalizeHandoff --> [*] FinalizePreviewAsNewWindow --> [*] ``` `GhostInTarget` is a hover-only state — no view-tree transfer happens until drop. The `InsertedInTarget` branch is reserved for the back-to-caller path, where the preview must be kept alive so the user can drag the tab back out again. ## Infrastructure changes Most of the diff lands in shared infrastructure rather than in feature-specific code. Each piece exists to satisfy a specific product invariant: - **Move a live tab between windows without restarting it.** A user dragging a tab between windows expects their terminal, scrollback, agent state, and animations to be preserved — kill-and-respawn would break the illusion of one continuous gesture. WarpUI gains the ability to relocate a live view tree (and its non-rendered structural children) into a different window, which is what makes the dragged tab feel like the same tab no matter which window is hosting it. - **Make z-order observable so drop targeting matches what the user sees.** When windows overlap, dropping a tab "into" an occluded window through the window in front of it would feel buggy. The window manager now exposes front-to-back ordering so attach targeting only considers windows that are actually reachable from the current cursor position. - **Show preview windows without disrupting the user's typing context.** A preview window appearing under the cursor would steal focus from whatever the user was typing into and would briefly flash blank before its content is ready. A new windowing primitive lets us materialize a window at exact bounds without taking focus, paired with a focus-suppression hook that covers the gap before the preview's content paints for the first time. - **Closing a window because its tab moved should be silent.** Today, closing a window with running processes prompts "Close window?" and tears down panes — both correct for normal closes, both wrong when the window is closing only because its content moved elsewhere. New workspace flags and a dedicated termination mode let transfer-driven closes skip the prompt and the teardown, so the user never sees a dialog that suggests data loss during a harmless transfer. Snapshots also skip the temporary preview workspaces so they don't leak into persistence. - **One owner of cross-window drag state.** Multiple windows mutating each other in response to the same drag event is exactly the shape of bug that produces duplicated tabs and stale subscriptions. Concentrating the drag state machine in a singleton, with workspaces only reacting to its returned decisions, removes the re-entrancy entirely and makes "what state is the drag in?" a single question with a single answer. - **One drag implementation across tab presentations.** Horizontal tabs and the vertical tabs panel are different visual surfaces, but the user expects identical drag behavior from both. Both UIs now emit the same drag actions and feed the same orchestration code, which keeps them from drifting apart and prevents accidental "works for horizontal, broken for vertical" regressions. - **Specs and integration tests.** Product behavior and architectural decisions are checked into `specs/pei/cross-window-tab-drag/` so future changes have something to preserve. Integration coverage exercises detach, attach, reattach, reverse-handoff, target-side reorder, and drop-outside flows behind the feature flag rather than gating on a specific OS. Behind a feature flag, so no change for users until it's rolled out. ## Testing - New integration tests cover detach, attach, reattach, reverse-handoff, reorder-in-target, and drop-outside scenarios. - Manually verified single-tab and multi-tab drags against all scenarios in `specs/pei/cross-window-tab-drag/PRODUCT.md` § Success Criteria on macOS. ## Server API dependencies No server dependencies. ## Agent Mode - [ ] Warp Agent Mode - This PR was created via Warp's AI Agent Mode ## Changelog Entries for Stable CHANGELOG-NEW-FEATURE: You can now drag tabs out of a window into their own window, or between windows, similar to Chrome. --------- Co-authored-by: Oz <oz-agent@warp.dev>

Description
Adds Chrome-style cross-window tab dragging behind the
DragTabsToWindowsfeature flag. A user can drag a tab out of a window to create a new one, drag it into another window's tab bar to attach it, and keep dragging through multiple attach/detach cycles without releasing the mouse.Product behavior is fully described in
specs/pei/cross-window-tab-drag/PRODUCT.md; the architectural rationale is inspecs/pei/cross-window-tab-drag/TECH.md.https://www.loom.com/share/94d21b4b573c4f6893684142d66b844a
How it works
A cross-window drag has two shapes depending on where it starts:
As the user drags, we continuously hit-test the cursor against the tab bars of other windows in z-order. When the cursor enters an eligible tab bar we show a lightweight ghost (insertion slot + floating chip) in the target; the live view tree only moves at drop time, except in the back-to-caller case where the tab is transferred so the source window can host real reordering. If the cursor leaves a target tab bar, any handoff is reversed and the drag continues. On drop, the preview is either promoted to a permanent window (no target), folded into the target (handoff committed), or cleaned up (no-op).
State machines
Full ASCII versions live in the module doc at
app/src/workspace/cross_window_tab_drag.rs. Mermaid renderings:Single-tab source window
stateDiagram-v2 [*] --> Floating: begin_single_tab_drag Floating --> Transitioning: cursor enters target tab bar Transitioning --> InsertedInTarget InsertedInTarget --> Floating: cursor leaves target tab bar (reverse_handoff) InsertedInTarget --> FinalizeHandoff: on_drop while inserted Floating --> FinalizeFloatingWindow: on_drop while floating FinalizeHandoff --> [*] FinalizeFloatingWindow --> [*]The source window itself is the preview, so no extra window is created.
FinalizeFloatingWindowjust leaves the source window where the user dropped it.Multi-tab source window
stateDiagram-v2 [*] --> Floating: begin_multi_tab_drag (creates preview window) Floating --> GhostInTarget: cursor enters target tab bar (deferred) GhostInTarget --> Floating: cursor leaves target tab bar Floating --> Transitioning: cursor re-enters source (back-to-caller) Transitioning --> InsertedInTarget InsertedInTarget --> Floating: cursor leaves source tab bar (reverse_handoff) InsertedInTarget --> FinalizeHandoff: on_drop while inserted GhostInTarget --> FinalizeHandoff: on_drop over target Floating --> FinalizePreviewAsNewWindow: on_drop while floating FinalizeHandoff --> [*] FinalizePreviewAsNewWindow --> [*]GhostInTargetis a hover-only state — no view-tree transfer happens until drop. TheInsertedInTargetbranch is reserved for the back-to-caller path, where the preview must be kept alive so the user can drag the tab back out again.Infrastructure changes
Most of the diff lands in shared infrastructure rather than in feature-specific code. Each piece exists to satisfy a specific product invariant:
specs/pei/cross-window-tab-drag/so future changes have something to preserve. Integration coverage exercises detach, attach, reattach, reverse-handoff, target-side reorder, and drop-outside flows behind the feature flag rather than gating on a specific OS.Behind a feature flag, so no change for users until it's rolled out.
Testing
specs/pei/cross-window-tab-drag/PRODUCT.md§ Success Criteria on macOS.Server API dependencies
No server dependencies.
Agent Mode
Changelog Entries for Stable
CHANGELOG-NEW-FEATURE: You can now drag tabs out of a window into their own window, or between windows, similar to Chrome.