Skip to content

add support for cross-window tab drag#9275

Merged
zachlloyd merged 5 commits intomasterfrom
tab-dragging-recleaned
May 2, 2026
Merged

add support for cross-window tab drag#9275
zachlloyd merged 5 commits intomasterfrom
tab-dragging-recleaned

Conversation

@peicodes
Copy link
Copy Markdown
Contributor

@peicodes peicodes commented Apr 28, 2026

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

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 --> [*]
Loading

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

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 --> [*]
Loading

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.

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>
@cla-bot cla-bot Bot added the cla-signed label Apr 28, 2026
@peicodes peicodes marked this pull request as ready for review April 28, 2026 21:14
@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented Apr 28, 2026

@peicodes

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 /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This preview close is async, but returning 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.

Comment thread app/src/workspace/view.rs

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This flattens the tab color to the resolved value, so transferred tabs lose whether the color was automatic, manually selected, or manually cleared; preserve default_directory_color and selected_color separately instead of reconstructing from color.

@peicodes
Copy link
Copy Markdown
Contributor Author

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would like both specs to be in dir with linear ticket id (we should stop nesting them under username as well)

Copy link
Copy Markdown
Contributor

@zachbai zachbai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 itemsimage.png

Copy link
Copy Markdown
Contributor

@zachlloyd zachlloyd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's go

@zachlloyd zachlloyd merged commit 3984e67 into master May 2, 2026
69 checks passed
@zachlloyd zachlloyd deleted the tab-dragging-recleaned branch May 2, 2026 16:01
zerx-lab pushed a commit to zerx-lab/warp that referenced this pull request May 3, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants