Skip to content

add support for cross-window tab drag#9275

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

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

Conversation

@peicodes

@peicodes peicodes commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

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

oz-for-oss Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

@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

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

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.

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)

@zachbai zachbai left a comment

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.

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

@zachlloyd zachlloyd left a comment

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.

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/zap 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>
@baschny

baschny commented May 26, 2026

Copy link
Copy Markdown

This seems to be merged, but its not visible in 2026.05.09.21.stable_03 for me. I can only drag the tabs to "reorder" them. I read about a FeatureFlag::DragTabsToWindows - is this something I can turn "on" for experimental use already? I cannot find anything in the docs about feature flags. Would love to use this feature!

@andy-blum

Copy link
Copy Markdown

+1 to @baschny - latest stable relase does not appear to have this capability.

@Andrew-Chen-Wang

Copy link
Copy Markdown

Those might be compile time flags, so you'll need to download the source at a specific tag/release and compile it yourself with the flag on.

Stoica-Mihai pushed a commit to Stoica-Mihai/warp that referenced this pull request Jun 5, 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>
timomak added a commit to timomak/twarp that referenced this pull request Jun 19, 2026
…4e67, d7c45ca)

Ports the Chrome-style cross-window tab drag from upstream PR warpdotdev#9275 adapted to
twarp's diverged tree. Dragging a tab from one window's strip into another
window's strip moves the live pane tree (processes intact) to the drop position,
with an insertion ghost (slot + floating chip) tracking the cursor in the target
strip; dropping outside any strip detaches into a new window or cancels.

Adds the `CrossWindowTabDrag` singleton state machine
(app/src/workspace/cross_window_tab_drag.rs, ~1849 lines: Floating /
GhostInTarget / InsertedInTarget / Transitioning phases, single- and multi-tab
sources, deferred ghost transfer, reverse-handoff, drop-time re-resolution,
pending-close persistence guard).

Replaces the simpler 8b detach-only path (try_detach_tab_on_drag /
detach_tab_immediate global action) with the singleton-driven flow. Rewires
Workspace::on_tab_drag and adds perform_handoff / handle_drop_result /
tab_insertion_index_for_cursor / insert_transferred_tab_at_index /
prepare_for_transferred_tab_attach / get_tab_transfer_info_for_attach /
close_window_for_content_transfer plus tab-bar ghost + floating-chip rendering
in both the horizontal tab bar and vertical tabs panel.

Adapted to twarp: TransferredTab gains a draggable_state field; the
placeholder+adopt transfer model is bridged to upstream's
insert_transferred_tab_at_index / get_tab_transfer_info_for_attach;
is_drag_preview_workspace renamed to is_tab_drag_preview; adds
suppress_detach_panes_on_window_close wired into on_window_closed.

Platform/core: set_window_alpha across the WindowManager trait (default no-op),
windowing state, mac backend (Rust + objc setAlphaValue:); and
AppContext::set_and_cache_window_bounds. The winit real-manager
ordered_window_ids / WindowOrderingState and the integration-test ordering
changes were intentionally not ported (Linux-only; the trait default vec![]
degrades to the distance-scan fallback). macOS, the target platform, has full
ordered_window_ids.

Behind the DragTabsToWindows dogfood flag. cargo check -p warp clean
(0 errors, 0 warnings). The drag gesture and insertion ghost require visual
verification in a launched build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL
timomak added a commit to timomak/twarp that referenced this pull request Jun 21, 2026
* [twarp 08] specs: macOS-style UI overhaul

Fill PRODUCT.md and TECH.md for feature 08 (macOS UI overhaul),
covering sub-phases 8a–8f:

- 8a Chrome-style top-rounded tabs (tab.rs render_tab_container_internal),
  preserving feature 01 colors + feature 06 rename.
- 8b detach tab → new window: enable dormant DragTabsToWindows flag +
  create_transferred_window/transfer_view_tree_to_window (live processes
  carried across).
- 8c drag tab between windows: port upstream (3984e67, d7c45ca) drop
  hit-testing + insertion ghost; may bundle with 8b.
- 8d Claude chat bottom gradient fade-out via warpui foreground overlay +
  Fill::Gradient (theme-tracking).
- 8e sessions search: single-line EditorView substring filter on
  StoredSession.title in left_panel.rs.
- 8f macOS sidebar restyle: flat pinned-light background, pill segmented
  switcher, muted headers; warpui emulation, no AppKit embedding.

PRODUCT.md carries owner-confirmed decisions and a per-sub-phase smoke-test
checklist; TECH.md grounds each anchor in verified file:line references and
maps sub-phases → invariants → smoke steps. Phase → spec-in-review.

Also fixes two stale 08-rebrand → 09-rebrand references in feature 07 specs
(rebrand moved to 09 when this feature was inserted as 08).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08] specs: link spec PR #81 in STATUS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08] roadmap: owner-directed bundle — all of 8a–8f impl folded into spec PR #81

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08a-8c] tabs + cross-window drag

8a — Chrome-style tab shape (PRODUCT §1–§5):
- render_tab_container_internal: top-rounded only via
  CornerRadius::with_top(NEW_TAB_CORNER_RADIUS); bottom corners square so the
  active tab seats flush onto the pane below.
- Active tab drops its bottom border (merges with content); inactive tabs keep
  it (recessed). First-tab left-border logic preserved.
- Feature 01 per-tab colors (styles.background) and feature 06 inline rename
  editor untouched — color/fill opacity path and rename TextInput unchanged.

8b — Drag tab → new window (PRODUCT §6–§9):
- Enabled FeatureFlag::DragTabsToWindows in DOGFOOD_FLAGS.
- on_tab_drag: new try_detach_tab_on_drag branch detaches a multi-tab when the
  drag center clears the tab bar past DETACH_SENSITIVITY (threshold ported from
  upstream 3984e67); below threshold falls through to the existing reorder.
  Dispatches the deferred root_view:detach_tab_immediate global action, reusing
  the existing detach_tab_with_transfer machinery (live view-tree transfer +
  origin reflow + last-tab window close, processes intact). Sets TabData.detached
  and clears stray marks on DropTab (snap-back).

8c — Drag tab between windows (PRODUCT §10–§12) — partial port:
- Added the in-scope transfer primitives: Workspace::prepare_for_transferred_tab
  + reorder_last_tab_to (target-side placeholder/adopt/reorder) and
  root_view::transfer_tab_to_window, which moves a tab into an EXISTING window
  via the same transfer_view_tree_to_window primitive, preserving color/name/
  live processes.
- NOT ported: the cross-window drag-state machine, screen-space cursor hit-
  testing, and insertion-ghost drop feedback (PRODUCT §11). These live in
  upstream's workspace::cross_window_tab_drag module (+ app_state.rs,
  vertical_tabs.rs, global_actions.rs, mod.rs and warpui platform changes for
  screen-space drag tracking / window-follow), all outside this sub-phase's
  four-file scope. transfer_tab_to_window is the primitive a future scope-
  expanded phase would drive from that gesture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08d] claude chat bottom gradient fade-out

Add a bottom gradient fade to the Claude Code transcript so messages
slide under the floating composer instead of ending at a hard cut
(PRODUCT §13–§16).

A full-width band (`COMPOSER_CLEARANCE` tall) is pinned to the pane
bottom as a positioned Stack child inserted between the scrolled
transcript body and the floating composer: it paints above scrolled
content but below the opaque composer (§14). Its background is a
vertical `Fill::Gradient` running from transparent (top) to the live
theme pane background (bottom); the transparent endpoint reuses the
background RGB with zero alpha, so the fade never tints toward a
hard-coded colour and is invisible-by-design in light and dark (§16).
The band carries no event handlers, so it does not consume clicks or
alter scroll extent / hit-testing (§15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08e-8f] sessions search + macOS sidebar restyle

8e — Sessions search (PRODUCT §17–§20):
- Add a single-line `EditorView` search field to `LeftPanelView`, built like
  keybindings_page.rs and re-rendering live on `Edited`.
- `render_claude_sessions_panel` renders the field above the heading/rows and
  case-insensitive substring-filters by `session.title` via a new
  `filter_session_indices` free fn (preserves original indices so resume
  targets the right session — PRODUCT §20).
- Distinct "No matching sessions" no-match branch separate from the zero-stored
  empty state; empty query is not a filter (§18).
- Unit tests for the filter (empty query, case-insensitive substring, no-match
  + order preservation).

8f — macOS sidebar restyle (PRODUCT §21–§25):
- Pin the panel root `Container` to a flat macOS-light fill (`MACOS_SIDEBAR_BG`)
  that does not read the active theme, so a dark terminal theme leaves the
  sidebar light (intended two-tone).
- Replace the icon-row tool switcher with a macOS pill segmented control
  (rounded track Container + per-segment `render_pill_segment`); routing is
  unchanged — each segment dispatches the same `LeftPanelAction` as before,
  active segment = filled white pill, all colors pinned light.
- Muted section header, restyled sessions rows (pinned primary/secondary text,
  soft light hover highlight), pinned the close-panel icon so it stays legible
  on light under any theme.

Theme-leakage note: the inherited Project Explorer / Shortcuts / Timeline
content still reads theme `sub_text_color`/hover fills (§25 forbids bespoke
re-layout); flagged for human visual check against the light background.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08c] full interactive cross-window tab drag (port upstream 3984e67, d7c45ca)

Ports the Chrome-style cross-window tab drag from upstream PR warpdotdev#9275 adapted to
twarp's diverged tree. Dragging a tab from one window's strip into another
window's strip moves the live pane tree (processes intact) to the drop position,
with an insertion ghost (slot + floating chip) tracking the cursor in the target
strip; dropping outside any strip detaches into a new window or cancels.

Adds the `CrossWindowTabDrag` singleton state machine
(app/src/workspace/cross_window_tab_drag.rs, ~1849 lines: Floating /
GhostInTarget / InsertedInTarget / Transitioning phases, single- and multi-tab
sources, deferred ghost transfer, reverse-handoff, drop-time re-resolution,
pending-close persistence guard).

Replaces the simpler 8b detach-only path (try_detach_tab_on_drag /
detach_tab_immediate global action) with the singleton-driven flow. Rewires
Workspace::on_tab_drag and adds perform_handoff / handle_drop_result /
tab_insertion_index_for_cursor / insert_transferred_tab_at_index /
prepare_for_transferred_tab_attach / get_tab_transfer_info_for_attach /
close_window_for_content_transfer plus tab-bar ghost + floating-chip rendering
in both the horizontal tab bar and vertical tabs panel.

Adapted to twarp: TransferredTab gains a draggable_state field; the
placeholder+adopt transfer model is bridged to upstream's
insert_transferred_tab_at_index / get_tab_transfer_info_for_attach;
is_drag_preview_workspace renamed to is_tab_drag_preview; adds
suppress_detach_panes_on_window_close wired into on_window_closed.

Platform/core: set_window_alpha across the WindowManager trait (default no-op),
windowing state, mac backend (Rust + objc setAlphaValue:); and
AppContext::set_and_cache_window_bounds. The winit real-manager
ordered_window_ids / WindowOrderingState and the integration-test ordering
changes were intentionally not ported (Linux-only; the trait default vec![]
degrades to the distance-scan fallback). macOS, the target platform, has full
ordered_window_ids.

Behind the DragTabsToWindows dogfood flag. cargo check -p warp clean
(0 errors, 0 warnings). The drag gesture and insertion ghost require visual
verification in a launched build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08] roadmap: tick 8a–8f; record build status + smoke caveats

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08b] fix: enable DragTabsToWindows in the warp-oss binary

Drag-a-tab-out-to-a-new-window did nothing in the binary the user runs.
Root cause: DragTabsToWindows ships only in DOGFOOD_FLAGS, but warp-oss
(the default ./script/run target, including --release) never enables the
dogfood set. With the flag off, the tab Draggable stays locked to
DragAxis::HorizontalOnly and the detach branch in workspace/view.rs is
gated out — so the tab can never leave the strip and only reorders.

The detach/transfer machinery itself (8b/8c) is complete and correct; it
was simply dark. Force-enable the flag in TWARP_OSS_FLAGS, the same
mechanism already used for GitOperationsInCodeReview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08f] polish: remove sidebar close-X; tune paddings + Timeline

User-requested sidebar polish:
- Remove the in-header close "X" (and its mouse-state field + close_button
  method). The panel still toggles via workspace:toggle_left_panel; the
  macOS-app sidebar has no close glyph in-header.
- Give the file-tree and Warp Drive panels a comfortable shared horizontal
  inset (SIDEBAR_CONTENT_INSET = 8px) instead of the old flush 2px.
- Add a bottom inset (SIDEBAR_BOTTOM_INSET = 8px) so the last row / Timeline
  section doesn't sit flush against the window edge.
- Timeline header: pin its text + hover colors to the macOS sidebar consts
  (the old theme-derived sub_text_color/neutral_3 inverted to near-white on
  a dark theme and vanished on the pinned-light sidebar — the §25
  theme-leakage trap), and bump its vertical padding 3px -> 5px so it reads
  as a real section header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08a] chrome-style tab bottom flares

Add Chrome/Safari-style bottom "flares" so the active tab seats into the
strip + content area below it rather than reading as a floating rounded
rectangle.

Two decorative quarter-disc "ears" hug the active tab's lower-left and
lower-right corners. Each ear is an r x r box (r = NEW_TAB_CORNER_RADIUS,
7px) filled with the active tab's own background fill, with its inner-facing
top corner rounded by r. The rounded corner cuts a transparent quarter-disc
revealing the workspace terminal/content background painted behind it (the
whole tab-bar + content column sits on get_terminal_background_fill), leaving
a convex quarter-disc of tab-color that flares outward into the baseline.

The ears are added via add_positioned_overlay_child (unclipped overlay) so
they render into the strip beside the tab instead of being clipped to the
tab box, and carry no event handlers so clicks/drag pass through to the tab.
Active-tab-only and behind the existing NewTabStyling gate; feature 01 colors,
feature 06 rename, and drag/hit-target behavior are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08] roadmap: record review-feedback refinements (flares, drag-out flag, sidebar polish)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* Revert "[twarp 08a] chrome-style tab bottom flares"

This reverts commit b9e91f5.

* [twarp 08a] chrome tab shape via Metal SDF flare

Replace the reverted "ear overlay" hack with a real Chrome-style flared tab
shape computed directly in warpui's Metal rect fragment shader.

A new `tab_flare_radius` field is threaded end to end:
- shader_types.h: appended to PerRectUniforms (preserves existing offsets;
  bindgen regenerates the Rust struct).
- scene::Rect gains `tab_flare` + `with_tab_flare`; Container gains a
  `tab_flare` field, `with_tab_flare`, and paints it into the rect.
- renderer.rs PerRectUniforms::new takes the new param; the solid-rect push
  scales rect.tab_flare by scale_factor, all other push sites pass 0.

The shader factors the IQ rounded-box SDF into `sdf_round_box` and adds
`distance_from_tab_flare`: a rounded-top body inset by the flare, unioned with
a full-width foot strip, with two circles subtracted to carve the concave
valleys at the body/foot junctions. When tab_flare_radius > 0.5 this replaces
the per-quadrant convex distance for both the outer shape and the
border-tracking inner shape; the AA alpha cut is gated on the flare flag too
(a tab's outer_corner_radius may be 0).

Tabs (app/src/tab.rs, NewTabStyling path) now set TAB_TOP_RADIUS (8px) +
TAB_FLARE_RADIUS (8px) on all tabs and pad content horizontally by the flare,
replacing the old top-radius + bottom-border seating logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08] roadmap: record 8a tab-shape rework via Metal SDF (ear hack reverted)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Eqk7zLg17E5EbuBVbTGiL

* [twarp 08] sidebar: center toggles, taller pills, flush file-tree scrollbar

Review feedback on the sidebar:
- Center the tool-switcher pills in the header (was left-aligned).
- Taller toggle pills (24 -> 30) for more vertical padding.
- Drop the right inset on the Project Explorer file tree so it fills
  the panel width and its overlayed scrollbar sits flush against the
  panel's right edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uRGepJ4fJonPsJkHc2e23

* [twarp 08] sidebar: real vertical padding on toggles, strip TIMELINE padding

Further review feedback:
- Toggle pills now have explicit top/bottom padding (height 36, 10px
  top/bottom) and the sidebar header is grown to 44 so the taller pills
  aren't clipped — the previous 30px height was capped by the 34px pane
  header and showed no visible change.
- Remove all padding around the TIMELINE section header so the row hugs
  its text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uRGepJ4fJonPsJkHc2e23

* [twarp 08a] tab flare: ogee neck via smooth-min, transparent strip, pill while dragging

- Rework the Metal tab SDF to smooth-union an inset rounded-top body with a
  low full-width foot bar, so the concave neck is a single ogee valley rather
  than carved circles that read as bright keyholes.
- Keep the tab-bar strip transparent so the valleys sit on the window bg.
- Render dragged tabs as a self-contained pill (all corners rounded, no flare,
  no opaque backing slab).
- Sidebar: real top/bottom padding around the tool switcher row.

* [twarp 08] tab colors: drop black/white, boost vibrancy of the rest

- Remove Black and White from TAB_COLOR_OPTIONS — as small chips they read as
  muted grays and don't work as distinguishing tab accents.
- Add AnsiColor::vibrant() (HSL saturation boost + lightness compression) and
  AnsiColorIdentifier::to_tab_color(), and route all tab-color rendering
  (picker dots, tab body, directory picker, vertical tab groups) through it so
  the chips and tab bodies stay in sync and read as saturated accents. Terminal
  text keeps the raw pastel theme palette.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

6 participants