feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)

* feat(tabs): drag a tab in as a pane, and a pane out as a tab

A tab dragged by its chip or its sidebar row can be dropped over the
panes to become one of them, and a pane dragged by its grip can be
dropped on the strip or the sidebar to become a tab of its own. Both
carry the panes across as they are: nothing is spawned and nothing is
killed, so a shell mid-command, an SSH session or an agent mid-turn
keeps running.

The landing is read the way a pane drag's already is, minus the middle:
an arriving tab has nothing here to trade places with, so a pane's core
means "split it the way it is longest". A tab that was itself split
arrives with its own shape intact and takes one share of the row or
column it joined. A pane on its way out is offered a caret between two
tabs, and the last pane in a tab is offered nothing, being a tab of its
own already.

Picking a tab up no longer switches to it: the strip and the sidebar
now activate on the click rather than on the press. Without that the
merge cannot be expressed at all — pressing the tab to drag it would
put it on screen, leaving no other tab to drop it into.

Two things in the machine tree had to follow:

* Panes that change tabs are told as PaneMove, one at a time, rather
  than as a tab closing and another being rebuilt around them.
* The tabs the machine already has are reconciled before new ones are
  created, so a pane leaving for a tab of its own is given up by the
  old tab before the new one asks to register it. The machine refuses a
  pane that is in two tabs at once, and the refusal desynced the window.

Closes #621

* test(tree-sync): a tab grafted above a whole layout still converges

* fix(tabs): keep a click on the close button from switching tabs

Switching on the release rather than the press means every click inside
a chip or a sidebar row now reaches the row itself, and gpui-component's
`Button` does not stop propagation on a click it handled. So one click on
a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i`
by then naming whichever tab had slid into that slot, which moved the
active tab somewhere nobody asked for. A click into the rename field did
the same: it switched away from the tab whose name was being typed, and
took the focus out of the field with it.

Both now hold the click where they handled it, the way they already held
the press.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-15 17:05:34 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 3ef644d267
commit 9fc0f331e8
10 changed files with 1203 additions and 54 deletions
+6
View File
@@ -58,6 +58,12 @@ Drag a row to reorder it within its group, or drag a whole group header to move
the group. A row cannot be dragged into a different group: a tab's group comes
from its working directory, so `cd` is what moves it.
Drag a row out over the panes instead and it stops being a session of its own:
it lands as a pane of the tab on screen, wherever the highlight says. Dragging a
pane the other way — by its grip, onto the sidebar — gives it a row of its own,
between whichever two the caret lands between. See
[making one tab a pane of another](/window/tabs-and-splits#making-one-tab-a-pane-of-another).
## Naming
Almost no tab has a name of its own, so the sidebar falls back:
+29
View File
@@ -82,6 +82,35 @@ Where you drop it decides what happens:
The landing lights up while you drag, and only ever lights up when the drop
would actually change the layout.
## Making one tab a pane of another
Two sessions you keep switching between belong in one tab. Drag a tab — by its
chip in the tab bar, or by its row in the sidebar — out over the panes and drop
it where you want it: it becomes a pane of the tab you are looking at, and the
tab it came from is gone.
The landing reads exactly as it does for a pane, minus the middle: a tab has
nothing here to trade places with, so a pane's middle means "split it the way it
is longest". A tab that was itself split arrives with its panes still arranged
the way you left them, taking one share of whatever row or column it joined.
Nothing restarts on the way over. A shell mid-command, an SSH session, a coding
agent halfway through a turn — all of them keep running; only the tab they were
in goes away. The tab's *name* does not come along.
Picking a tab up does not switch to it, so the tab you drop into is the one you
were already looking at. A plain click still switches, as always.
## Making a pane a tab of its own
The same move backwards. Drag a pane by its grip up to the tab bar — or out to
the sidebar, if that is where your tabs live — and a caret shows which two tabs
it would go between. Drop it and it leaves the split it was in and becomes a tab
of its own, at that spot.
Nothing restarts here either. The last pane in a tab has nowhere to go, since it
is already a tab on its own, so no caret appears for it.
## Closing something that is busy
Closing a pane or tab with a command still running asks first, and says what is