@cplieger/web-terminal-ui 5.3.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
15
  import { fromHTML, holdFocusOnPress } from "../dom.js";
16
16
  import { createClickSwallow, placeMenuAt } from "../menu-position.js";
17
17
  import { centreChipLabels } from "./ink-centre.js";
18
+ import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
18
19
  import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
19
20
  import {
20
21
  ACTIVE_TAB_KEY,
@@ -24,8 +25,8 @@ import {
24
25
  PROGRESS_ABSENT,
25
26
  SWIPE_HINT_KEY,
26
27
  SessionAPIError,
27
- TAB_ORDER_KEY,
28
28
  baseLabel,
29
+ compareTabOrder,
29
30
  createSessionAPI,
30
31
  createTombstones,
31
32
  hasPinnedName,
@@ -34,16 +35,19 @@ import {
34
35
  normalizeProgress,
35
36
  orderedInsertIndex,
36
37
  parseCueSeen,
37
- parseTabOrder,
38
38
  renderedProgress,
39
39
  sanitizePinnedName,
40
40
  serializeCueSeen,
41
- serializeTabOrder,
42
41
  statusPhrase,
43
42
  tabAccessibleName,
44
43
  } from "./model.js";
45
44
  import { browserNotifierEnv, createNotifier } from "./notify.js";
46
45
  import {
46
+ REORDER_DWELL_MS,
47
+ REORDER_LEAN_PX,
48
+ REORDER_SETTLE_MS,
49
+ REORDER_SHIFT_TRANS,
50
+ REORDER_SLOT_FADE_MS,
47
51
  TAB_HTML,
48
52
  kbButtonHTML,
49
53
  newButtonHTML,
@@ -183,38 +187,6 @@ const CATCHUP_SETTLE_MS = 250;
183
187
  * and must never arm it (nor pay for the completion poll). */
184
188
  const CATCHUP_MIN_BACKLOG = 400;
185
189
 
186
- /** orderSignature collapses an arrangement into one comparable string, so the
187
- * persist path can decide "did the order change?" with a string compare instead
188
- * of re-serializing and re-writing on every status tick. NUL is the separator
189
- * because a session id can never contain one. */
190
- function orderSignature(ids: readonly string[]): string {
191
- return ids.join("\u0000");
192
- }
193
-
194
- /** readSavedOrder loads the persisted tab arrangement, or [] when there is none
195
- * to trust. Storage itself can throw — Safari in private mode, a disabled
196
- * third-party context, an embedder's iframe — and an unreadable arrangement is
197
- * never fatal: the strip falls back to the server's creation order, exactly as
198
- * it behaved before arrangements were persisted. */
199
- function readSavedOrder(): string[] {
200
- try {
201
- return parseTabOrder(localStorage.getItem(TAB_ORDER_KEY));
202
- } catch {
203
- return [];
204
- }
205
- }
206
-
207
- /** writeSavedOrder persists an arrangement, best-effort: a full quota or an
208
- * unavailable store must not break a reorder the user just performed on screen.
209
- * The in-memory arrangement stays authoritative for this page either way. */
210
- function writeSavedOrder(ids: readonly string[]): void {
211
- try {
212
- localStorage.setItem(TAB_ORDER_KEY, serializeTabOrder(ids));
213
- } catch {
214
- /* storage unavailable or full — the order still holds for this page */
215
- }
216
- }
217
-
218
190
  /** readCueSeen loads the acknowledged background-tab cues, or an empty map when
219
191
  * there is none to trust. Storage itself can throw (Safari private mode, a
220
192
  * disabled third-party context, an embedder's iframe) and an unreadable map is
@@ -687,24 +659,37 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
687
659
  // /api/sessions) that predates the server reaping the session does not
688
660
  // re-adopt (flash back) a closed tab.
689
661
  const tombstones = createTombstones();
690
- // The user's persisted tab arrangement (model.ts TAB_ORDER_KEY): read ONCE
691
- // at setup, before any session can be adopted, so every adopt — from the
692
- // status-stream snapshot or from the bootstrap's list, in either order —
693
- // can place its tab at the saved position. Read-only for the life of the
694
- // page: it is the arrangement to RESTORE, and the live strip must not
695
- // overwrite it (see persistOrder).
696
- const savedOrder: readonly string[] = readSavedOrder();
697
- // Signature of the arrangement currently on disk, so persistOrder can run
698
- // from syncChrome (which fires on every status tick) and still write only
699
- // when the sequence actually changed. Seeded from what was read, so a
700
- // window that simply restored an arrangement writes nothing.
701
- let persistedSig = orderSignature(savedOrder);
702
662
  // The expanded mobile list's row elements, keyed by tab id. Rows are
703
663
  // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
704
664
  // same elements from their old slots to their new ones (the rotation).
705
665
  const rowEls = new Map<string, HTMLElement>();
706
666
  let activeId: string | null = null;
707
667
  let draggingEl: HTMLElement | null = null;
668
+ // --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
669
+ // The chips currently carrying an inline transform: the lean during a hold,
670
+ // or the slide that commits it. One set, because both stages write the same
671
+ // two properties on the same elements and one settle function has to be able
672
+ // to hand every one of them back to the stylesheet.
673
+ const shifted = new Set<HTMLElement>();
674
+ let shiftTimer: ReturnType<typeof setTimeout> | null = null;
675
+ // The pending slot and its hold. `dwellTimer !== null` is the only "a slot is
676
+ // pending" flag — `dwellBefore` cannot be one, because null is a legitimate
677
+ // value there meaning "past the last chip".
678
+ let dwellTimer: ReturnType<typeof setTimeout> | null = null;
679
+ let dwellBefore: HTMLElement | null = null;
680
+ // Whether a `drop` fired for the drag in flight. It is the exact signal for
681
+ // "the user released deliberately" as opposed to "the drag was abandoned":
682
+ // Escape and a refused release fire dragend with no drop at all. dragend
683
+ // without it reverts the preview, which is the cancel this reorder never had.
684
+ let dropped = false;
685
+ // The pending-drop rail: ONE element, moved to whichever edge the current hold
686
+ // is counting down on (see showDwellRail).
687
+ const dwellRail = document.createElement("span");
688
+ dwellRail.className = "wt-tab-dwell";
689
+ dwellRail.setAttribute("aria-hidden", "true");
690
+ // The chip mid slot-fade, and the timer that ends it.
691
+ let slotFadeEl: HTMLElement | null = null;
692
+ let slotFadeTimer: ReturnType<typeof setTimeout> | null = null;
708
693
  // Gate the new-tab enter animation: tabs present at initial population
709
694
  // should not animate in (jarring on load); only tabs added at runtime do.
710
695
  let started = false;
@@ -1131,7 +1116,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1131
1116
  renderSwitcherList();
1132
1117
  }
1133
1118
  maybeSwipeHint();
1134
- persistOrder();
1119
+ applyServerOrder();
1135
1120
  }
1136
1121
 
1137
1122
  // The percentage is deliberately NOT written into the browser document
@@ -1144,41 +1129,50 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1144
1129
  // window's. The per-chip prefix carries the same information without the
1145
1130
  // conflict, because each chip shows its own session. Do not add it back.
1146
1131
 
1147
- // persistOrder stores the strip's current arrangement so it survives a
1148
- // reload. It hangs off syncChrome — the one function every list mutation
1149
- // already ends with (reorder, create, adopt, close, reconcile) — rather than
1150
- // off the two reorder call sites, so a future path that rearranges tabs
1151
- // cannot forget to persist. Three guards make that safe:
1132
+ // applyServerOrder re-sorts the strip into the order the SERVER holds, and
1133
+ // is the read half of tab-order sync: a drag on another device arrives as a
1134
+ // new `order` on that session's status event, and this is what turns it into
1135
+ // a moved chip here.
1136
+ //
1137
+ // It hangs off syncChrome — the one function every list mutation already
1138
+ // ends with (reorder, create, adopt, close, reconcile) — so no path can
1139
+ // forget it. Three properties make that safe to run that often:
1152
1140
  //
1153
- // - `started`: nothing is written while the initial population runs. The
1154
- // strip grows one tab at a time there, and each intermediate state is a
1155
- // partial arrangement, NOT a user decision — writing them meant a reload
1156
- // landing mid-population could store a one-tab order and lose the real
1157
- // one. After the bootstrap every change is a genuine change.
1158
- // - the signature compare: syncChrome also runs on every status tick, so
1159
- // only a changed id sequence reaches storage.
1160
- // - a non-empty list: an empty strip carries no arrangement, and a
1161
- // transient empty state (the last tab closed just before its replacement
1162
- // lands, a reconcile against a restarted server) must not erase the
1163
- // stored order.
1141
+ // - It is a no-op when the strip already matches, decided by one pass over
1142
+ // the list, so the status tick pays a comparison and nothing else.
1143
+ // - It is skipped mid-drag. The pointer owns the strip then, and the DOM
1144
+ // holds a preview that is deliberately not yet in tabList; re-sorting
1145
+ // under the user's finger would fight the gesture.
1146
+ // - It sorts by the same total order adoption inserts by (compareTabOrder),
1147
+ // so the two cannot disagree about where a tab belongs.
1164
1148
  //
1165
- // savedOrder is deliberately NOT reassigned here. It is the ARRANGEMENT
1166
- // READ AT LOAD, which is what adoption places tabs by; overwriting it with
1167
- // the live strip discards the position of every session that has not been
1168
- // adopted yet — which is precisely what a strip still filling up is full of.
1169
- function persistOrder(): void {
1170
- if (!started || tabList.length === 0) {
1149
+ // A locally-committed reorder is already applied optimistically, and the
1150
+ // server echoes that same arrangement back, so the echo lands here as a
1151
+ // no-op rather than as a second visible move.
1152
+ function applyServerOrder(): void {
1153
+ if (draggingEl !== null) {
1171
1154
  return;
1172
1155
  }
1173
- const ids = tabList.map((t) => t.id);
1174
- const sig = orderSignature(ids);
1175
- if (sig === persistedSig) {
1156
+ const sorted = [...tabList].sort(compareTabOrder);
1157
+ let moved = false;
1158
+ for (let i = 0; i < sorted.length; i++) {
1159
+ if (sorted[i] !== tabList[i]) {
1160
+ moved = true;
1161
+ break;
1162
+ }
1163
+ }
1164
+ if (!moved) {
1176
1165
  return;
1177
1166
  }
1178
- persistedSig = sig;
1179
- // Writing the LIVE strip also prunes: ids whose sessions are gone never
1180
- // reach storage again, so the stored arrangement cannot grow forever.
1181
- writeSavedOrder(ids);
1167
+ tabList.length = 0;
1168
+ tabList.push(...sorted);
1169
+ // appendChild MOVES an existing node, so one pass in the wanted sequence
1170
+ // both reorders the chips and leaves no duplicates behind.
1171
+ for (const tab of tabList) {
1172
+ scroller.appendChild(tab.el);
1173
+ }
1174
+ paintActive();
1175
+ syncMobile();
1182
1176
  }
1183
1177
 
1184
1178
  // closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
@@ -1303,6 +1297,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1303
1297
  nameSeq: 0,
1304
1298
  display: "",
1305
1299
  createdAt: info.createdAt,
1300
+ order: info.order,
1306
1301
  // Through the kernel factory, never `new LineStore()`: the factory
1307
1302
  // applies the consumer's scrollbackLines cap, so per-tab caches and
1308
1303
  // the kernel's implicit store share one retained-line budget. The id
@@ -1437,16 +1432,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1437
1432
  e.preventDefault();
1438
1433
  showTabMenu(e.clientX, e.clientY, tab.id);
1439
1434
  });
1440
- // Drag-and-drop reorder on the desktop strip. The bar's dragover moves
1441
- // this element live; drop/dragend commit the new order into tabList.
1435
+ // Drag-and-drop reorder on the desktop strip. The bar's dragover arms a
1436
+ // slot, the hold (or a drop) commits it, and drop/dragend commit the
1437
+ // resulting order into tabList. See the reorder-preview block below.
1442
1438
  el.draggable = true;
1443
1439
  el.addEventListener("dragstart", (e) => {
1444
1440
  if (editingId === tab.id) {
1445
1441
  e.preventDefault(); // renaming: a drag would fight caret selection
1446
1442
  return;
1447
1443
  }
1444
+ endReorderPreview(); // no residue from a previous drag
1445
+ endShift(); // ...and nothing mid-slide, so this gesture starts from rest
1446
+ dropped = false;
1448
1447
  draggingEl = el;
1449
- // Snapshot the preview from the PRISTINE chip, before the dim class.
1448
+ // Snapshot the preview from the PRISTINE chip, before the slot class.
1450
1449
  setDragGhost(e, el);
1451
1450
  el.classList.add("wt-tab-dragging");
1452
1451
  if (e.dataTransfer) {
@@ -1460,9 +1459,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1460
1459
  });
1461
1460
  el.addEventListener("dragend", () => {
1462
1461
  el.classList.remove("wt-tab-dragging");
1462
+ if (!dropped) {
1463
+ // No drop fired, so the drag was abandoned: Escape, or a release the
1464
+ // browser refused. Put the strip back — tabList still holds the order
1465
+ // the gesture started from, because only a drop writes to it.
1466
+ revertPreview();
1467
+ }
1463
1468
  draggingEl = null;
1469
+ // The gesture's state goes; a slide started by the revert above is left to
1470
+ // finish under its own settle timer (see endReorderPreview).
1471
+ endReorderPreview();
1464
1472
  clearDragGhost(); // belt-and-braces: the rAF normally got there first
1465
- syncOrderFromDom();
1466
1473
  });
1467
1474
  return tab;
1468
1475
  }
@@ -1668,21 +1675,104 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1668
1675
  // direction the incoming content slides in from that side (the swipe
1669
1676
  // feel); without one it is a plain cross-fade. The rAF re-adds the class a
1670
1677
  // frame after clearing it so a rapid re-switch restarts the animation.
1678
+ //
1679
+ // The class comes off on the animation's OWN end, with the timer kept as
1680
+ // the net. Two numbers had to agree and did not: the CSS duration is
1681
+ // --dur-standard (0.2s) and this timer is 360ms, so the class outlived the
1682
+ // animation by ~144ms (the add is deferred one rAF), which on a tab above
1683
+ // ~3000 rows lands in the middle of the rebuild's drain. Reading the event
1684
+ // makes the timer a fallback instead of the primary signal.
1685
+ //
1686
+ // The timer CANNOT be dropped, and three cases are why:
1687
+ // - an interrupted animation fires no animationend, and animationcancel is
1688
+ // not reliably delivered in Blink. A rapid re-switch is exactly that
1689
+ // case, and it is the case the rAF above exists to serve.
1690
+ // - the animations feature is optional and removes .wt-animate under
1691
+ // reduced motion, so no animation runs and no event ever fires.
1692
+ // - a consumer stylesheet could drop the rules entirely.
1693
+ //
1694
+ // The listener filters on the ONE animation name this switch expects AND on
1695
+ // the class still being present. Three notes, because each was argued:
1696
+ // - The name must be the expected one, not any of the three. An animation
1697
+ // that COMPLETED just as a re-switch landed has its event already queued;
1698
+ // the old listener is gone by dispatch time, so the NEW listener receives
1699
+ // it, and a listener accepting all three names would let switch N's
1700
+ // completion end switch N+1's animation a frame in.
1701
+ // - The class check closes the window BEFORE the animation starts. The
1702
+ // listener is attached in this task and the class lands a frame later, so
1703
+ // for one frame a matching `animationend` from anywhere in the subtree
1704
+ // would cancel the pending class-add and skip the animation outright. An
1705
+ // animationend cannot precede its own animation, so requiring the class is
1706
+ // free.
1707
+ // - No generation counter, and no target check. A generation was tried and
1708
+ // removed: `endSwitchAnim` removes the listener synchronously before the
1709
+ // next one is added, so a stale listener cannot receive an event and the
1710
+ // counter could never fire (its red check could not be made to fail). A
1711
+ // target check would couple this feature to the kernel's `.term-output`
1712
+ // markup, which it does not own; the residual risk is a consumer applying
1713
+ // one of these three library-private keyframe names to another element
1714
+ // inside the surface, and the class check already reduces that to "ends a
1715
+ // running switch animation early" rather than "skips it".
1716
+ const SWITCH_ANIM_NET_MS = 360;
1671
1717
  let switchAnimTimer: ReturnType<typeof setTimeout> | null = null;
1672
- function flashSwitch(dir?: "next" | "prev"): void {
1673
- const surface = ctx.surface();
1674
- const cls = dir ? `wt-switching-${dir}` : "wt-switching";
1675
- surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
1718
+ let switchAnimFrame: number | null = null;
1719
+ let switchAnimOff: (() => void) | null = null;
1720
+
1721
+ // Drop the classes and every pending mechanism from the previous switch.
1722
+ // All three are torn down together: a stray timer or listener from switch N
1723
+ // would otherwise strip the class switch N+1 has just added, and a surviving
1724
+ // rAF would ADD the previous direction's class alongside the new one. That
1725
+ // last one predates this change, and its consequence is not "two animations
1726
+ // at once" — the cascade picks one winner for the `animation` property, and
1727
+ // the winner is whichever of the three rules comes LAST in the stylesheet
1728
+ // (`wt-switching-prev`), regardless of which switch the user actually made. So
1729
+ // a forward switch could animate backwards.
1730
+ function endSwitchAnim(surface: HTMLElement): void {
1731
+ surface.classList.remove(...SWITCH_CLASSES);
1676
1732
  if (switchAnimTimer !== null) {
1677
1733
  clearTimeout(switchAnimTimer);
1734
+ switchAnimTimer = null;
1678
1735
  }
1679
- requestAnimationFrame(() => {
1736
+ if (switchAnimFrame !== null) {
1737
+ cancelAnimationFrame(switchAnimFrame);
1738
+ switchAnimFrame = null;
1739
+ }
1740
+ if (switchAnimOff !== null) {
1741
+ switchAnimOff();
1742
+ switchAnimOff = null;
1743
+ }
1744
+ }
1745
+
1746
+ function flashSwitch(dir?: "next" | "prev"): void {
1747
+ const surface = ctx.surface();
1748
+ const cls = dir ? (`wt-switching-${dir}` as const) : ("wt-switching" as const);
1749
+ endSwitchAnim(surface);
1750
+ const expected = SWITCH_ANIMATIONS[cls];
1751
+ const onAnimEnd = (ev: AnimationEvent): void => {
1752
+ if (ev.animationName !== expected || !surface.classList.contains(cls)) {
1753
+ return;
1754
+ }
1755
+ endSwitchAnim(surface);
1756
+ };
1757
+ surface.addEventListener("animationend", onAnimEnd);
1758
+ switchAnimOff = (): void => {
1759
+ surface.removeEventListener("animationend", onAnimEnd);
1760
+ };
1761
+ // The net is armed INSIDE the frame that adds the class, never beside it.
1762
+ // Armed in this task it would be a deadline on a REQUEST to animate rather
1763
+ // than a net around an animation: rAF is paused in a hidden document and
1764
+ // deferred under a blocked main thread, so a 360ms timer could fire first,
1765
+ // and its `endSwitchAnim` would cancel the pending class-add and skip the
1766
+ // animation outright. Armed here, a frame that never runs arms nothing and
1767
+ // leaves no class to strip.
1768
+ switchAnimFrame = requestAnimationFrame(() => {
1769
+ switchAnimFrame = null;
1680
1770
  surface.classList.add(cls);
1771
+ switchAnimTimer = setTimeout(() => {
1772
+ switchAnimTimer = null;
1773
+ endSwitchAnim(surface);
1774
+ }, SWITCH_ANIM_NET_MS);
1681
1775
  });
1682
- switchAnimTimer = setTimeout(() => {
1683
- switchAnimTimer = null;
1684
- surface.classList.remove("wt-switching", "wt-switching-next", "wt-switching-prev");
1685
- }, 360);
1686
1776
  }
1687
1777
 
1688
1778
  // Any frame can reveal a backlog worth telling the user about: a resume
@@ -1736,20 +1826,503 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1736
1826
  }
1737
1827
  }
1738
1828
 
1739
- // dragTargetBefore returns the first tab whose horizontal midpoint is past
1740
- // x (the element the dragged tab should sit before), or null to drop at
1741
- // the end of the tab list. syncOrderFromDom rebuilds tabList to
1742
- // match the strip's DOM order after a drag, so position indicators, the
1743
- // switcher, and close-to-the-right/left all follow the visible order.
1744
- function dragTargetBefore(x: number): HTMLElement | null {
1745
- for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab:not(.wt-tab-dragging)")) {
1746
- const rect = el.getBoundingClientRect();
1747
- if (x < rect.left + rect.width / 2) {
1829
+ // --- Desktop reorder preview: hold, lean, slide -------------------------
1830
+ //
1831
+ // The old reorder moved the dragged chip on EVERY dragover, so the strip
1832
+ // rearranged itself continuously under the pointer and — every chip being
1833
+ // the same width — nothing on screen said which slot a release would land
1834
+ // in. The chip you were dragging looked like the slot it left, the slot it
1835
+ // was over, and the slot it would end up in.
1836
+ //
1837
+ // The reorder now runs in three stages, and the dragged chip stays in the
1838
+ // flow throughout as the slot it will land in (`.wt-tab-dragging`, which
1839
+ // 30-tabs.css renders as an empty dashed outline rather than a dimmed copy
1840
+ // of the tab — the solid copy is the drag image under the pointer, and one
1841
+ // of the two had to stop pretending to be the tab):
1842
+ //
1843
+ // 1. dragover picks a candidate slot and ARMS a hold. Nothing reorders.
1844
+ // 2. Every chip the commit would displace LEANS a few px toward where it
1845
+ // is going, at once, so the hold reads as "held here, opening that way"
1846
+ // instead of as a second of nothing.
1847
+ // 3. After REORDER_DWELL_MS on the same candidate the slot COMMITS: one
1848
+ // DOM move, the displaced chips slide from their old positions to their
1849
+ // new ones, and the slot fades in at its new home.
1850
+ //
1851
+ // A release never waits for the hold: `drop` commits whatever slot is
1852
+ // pending (flushDwell), so a fast drag-and-drop reorders exactly as before.
1853
+ //
1854
+ // dropTargetBefore returns the first tab whose horizontal midpoint is past
1855
+ // x (the element the dragged tab should sit before), or null to drop at the
1856
+ // end of the tab list. syncOrderFromDom rebuilds tabList to match the
1857
+ // strip's DOM order after a drag, so position indicators, the switcher, and
1858
+ // close-to-the-right/left all follow the visible order.
1859
+ //
1860
+ // It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
1861
+ // getBoundingClientRect. That is not an optimisation, it is what makes an
1862
+ // animated reorder hit-testable at all: a rect read while a chip is mid-lean or
1863
+ // mid-slide returns the INTERPOLATED position, so the preview's own motion feeds
1864
+ // back into the decision that produced it and the strip oscillates between two
1865
+ // slots for as long as the pointer sits near a boundary. Layout offsets are the
1866
+ // chip's position in the FLOW, which a transform does not affect at all.
1867
+ //
1868
+ // An earlier draft instead cached rects measured "at rest" and re-measured after
1869
+ // each commit. Two things killed it: the cache had to be invalidated on anything
1870
+ // that reflowed the strip (a window or embed resize changes every chip's midpoint
1871
+ // without changing the chip SET, which was the only thing the cache checked), and
1872
+ // it could capture a transform anyway, because a chip adopted mid-drag arrives
1873
+ // mid `.wt-tab-enter` scale. Reading layout needs no cache and cannot go stale.
1874
+ //
1875
+ // Coordinates: chips share the scroller's offsetParent (the scroller is not
1876
+ // positioned, so it is not one), which makes `el.offsetLeft - scroller.offsetLeft`
1877
+ // the chip's offset inside the scroller — and the scroller has no border for that
1878
+ // subtraction to skip. Layout offsets ignore scrolling, so clientX is converted
1879
+ // into the same space by adding scrollLeft back, which is what lets a scrolled
1880
+ // strip hit-test correctly.
1881
+ function dropTargetBefore(clientX: number): HTMLElement | null {
1882
+ const x = clientX - scroller.getBoundingClientRect().left + scroller.scrollLeft;
1883
+ const base = scroller.offsetLeft;
1884
+ for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
1885
+ if (el === draggingEl) {
1886
+ continue; // the slot does not displace itself
1887
+ }
1888
+ if (x < el.offsetLeft - base + el.offsetWidth / 2) {
1748
1889
  return el;
1749
1890
  }
1750
1891
  }
1751
1892
  return null;
1752
1893
  }
1894
+
1895
+ // applyShift writes one displacement per chip and remembers which chips are
1896
+ // carrying one. `trans` is REORDER_SHIFT_TRANS to animate to the new value,
1897
+ // or "none" to plant a from-state the next write animates out of.
1898
+ //
1899
+ // It writes the individual `translate` property, NOT `transform`, and that is
1900
+ // load-bearing rather than stylistic. Declarations from a running CSS ANIMATION
1901
+ // out-rank normal author declarations, inline style included, so a chip in the
1902
+ // middle of `wt-slot-in` (`transform: scale(0.97)`) or `wt-tab-in`
1903
+ // (`transform: scale(0.82)`) would silently ignore an inline `transform` and
1904
+ // refuse to move: an Escape landing inside the slot fade would snap the dragged
1905
+ // chip home while its siblings slid. `translate` is a separate property that
1906
+ // composes with `transform` instead of fighting it, so the two animations can own
1907
+ // one chip at the same time.
1908
+ function applyShift(px: ReadonlyMap<HTMLElement, number>, trans: string): void {
1909
+ if (shiftTimer !== null) {
1910
+ clearTimeout(shiftTimer);
1911
+ shiftTimer = null;
1912
+ }
1913
+ for (const [el, dx] of px) {
1914
+ el.style.transition = trans;
1915
+ el.style.translate = `${String(Math.round(dx))}px`;
1916
+ shifted.add(el);
1917
+ }
1918
+ }
1919
+ // endShift hands every displaced chip back to the stylesheet. Both stages
1920
+ // write the same two inline properties, so this one function ends whichever
1921
+ // ran last — called before a fresh measurement, by the settle timer after a
1922
+ // slide, when the drag ends, and on teardown. Idempotent.
1923
+ function endShift(): void {
1924
+ if (shiftTimer !== null) {
1925
+ clearTimeout(shiftTimer);
1926
+ shiftTimer = null;
1927
+ }
1928
+ for (const el of shifted) {
1929
+ el.style.transition = "";
1930
+ el.style.translate = "";
1931
+ }
1932
+ shifted.clear();
1933
+ }
1934
+
1935
+ // leanToward tips the chips a commit would displace a few px toward their
1936
+ // destination, for as long as the hold lasts. No settle timer: the lean is a
1937
+ // held state, not an animation with an end, and a timer that expired mid-hold
1938
+ // would snap it away while the user is still waiting on it.
1939
+ //
1940
+ // Chips that were leaning and no longer would be are written back to zero in
1941
+ // the SAME pass, so a changed candidate eases from one lean into the other
1942
+ // rather than snapping through rest first.
1943
+ function leanToward(before: HTMLElement | null): void {
1944
+ if (!draggingEl) {
1945
+ return;
1946
+ }
1947
+ // The preference is read live, so it can flip mid-gesture. Clearing here rather
1948
+ // than returning bare is what honours a mid-hold opt-in: a bare return would
1949
+ // leave the lean already applied under the previous preference sitting there.
1950
+ if (prefersReduce()) {
1951
+ endShift();
1952
+ return;
1953
+ }
1954
+ const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
1955
+ const from = chips.indexOf(draggingEl);
1956
+ const to = before === null ? chips.length : chips.indexOf(before);
1957
+ if (from < 0 || to < 0) {
1958
+ return;
1959
+ }
1960
+ // Moving right past the chips in between shifts them LEFT, and moving left
1961
+ // shifts the chips it displaces RIGHT — the direction the user reads as
1962
+ // "that tab is getting out of the way".
1963
+ const movers = from < to ? chips.slice(from + 1, to) : chips.slice(to, from);
1964
+ const dx = from < to ? -REORDER_LEAN_PX : REORDER_LEAN_PX;
1965
+ const next = new Map<HTMLElement, number>();
1966
+ for (const el of movers) {
1967
+ next.set(el, dx);
1968
+ }
1969
+ for (const el of shifted) {
1970
+ if (!next.has(el)) {
1971
+ next.set(el, 0);
1972
+ }
1973
+ }
1974
+ applyShift(next, REORDER_SHIFT_TRANS);
1975
+ }
1976
+ // releaseLean eases every leaning chip back to rest and settles it, for when
1977
+ // a candidate is withdrawn (the pointer came back to the committed slot, or
1978
+ // left the strip). endShift would be wrong here: it strips the transition in
1979
+ // the same write, so the lean would snap home.
1980
+ function releaseLean(): void {
1981
+ if (shifted.size === 0) {
1982
+ return;
1983
+ }
1984
+ // ...except under reduced motion, where snapping IS the requested behaviour and
1985
+ // easing back would animate the very motion the preference opted out of.
1986
+ if (prefersReduce()) {
1987
+ endShift();
1988
+ return;
1989
+ }
1990
+ const rest = new Map<HTMLElement, number>();
1991
+ for (const el of shifted) {
1992
+ rest.set(el, 0);
1993
+ }
1994
+ applyShift(rest, REORDER_SHIFT_TRANS);
1995
+ shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
1996
+ }
1997
+ // showDwellRail marks the exact boundary the tab will be inserted at, on the edge
1998
+ // of the chip beside it, and fills that mark over the hold's own duration.
1999
+ //
2000
+ // Without it the hold was a second of near-silence, and the silence was worse than
2001
+ // it sounds: the most authoritative thing on screen is the dashed slot, and the
2002
+ // slot sits at the tab's CURRENT position, so the strongest cue was describing the
2003
+ // state being left rather than the one being chosen. The 7px lean says which
2004
+ // DIRECTION the strip will open without saying which boundary, and nothing at all
2005
+ // said how much longer. The rail answers both of those, which makes the gesture
2006
+ // read as three ordered states: the tab under the pointer, the edge about to open,
2007
+ // then the gap that opened.
2008
+ //
2009
+ // The fill duration comes from REORDER_DWELL_MS, so the bar and the timer it
2010
+ // depicts cannot drift apart. That is also why the rail is a real element rather
2011
+ // than a pseudo-element on the chip: a pseudo-element's duration would have to
2012
+ // travel through a custom property, which is a second place to keep in step.
2013
+ function showDwellRail(before: HTMLElement | null): void {
2014
+ // The leading edge of the chip being pushed aside, or the trailing edge of the
2015
+ // last chip when the tab is heading past the end of the strip.
2016
+ const host = before ?? scroller.querySelector<HTMLElement>(".wt-tab:last-of-type");
2017
+ if (host === null || host === draggingEl) {
2018
+ hideDwellRail();
2019
+ return;
2020
+ }
2021
+ dwellRail.classList.toggle("wt-tab-dwell-end", before === null);
2022
+ host.appendChild(dwellRail);
2023
+ if (prefersReduce()) {
2024
+ // No countdown, but the boundary still has to be legible, so show it filled.
2025
+ dwellRail.style.transition = "none";
2026
+ dwellRail.style.transform = "scaleY(1)";
2027
+ return;
2028
+ }
2029
+ dwellRail.style.transition = "none";
2030
+ dwellRail.style.transform = "scaleY(0)";
2031
+ dwellRail.getBoundingClientRect(); // commit the from-state before the fill
2032
+ dwellRail.style.transition = `transform ${String(REORDER_DWELL_MS)}ms linear`;
2033
+ dwellRail.style.transform = "scaleY(1)";
2034
+ }
2035
+ function hideDwellRail(): void {
2036
+ dwellRail.remove(); // idempotent: removing a detached node does nothing
2037
+ }
2038
+
2039
+ // armDwell registers the slot a release would land in and starts (or keeps)
2040
+ // the hold that commits it. Called on every dragover, so it must be cheap
2041
+ // and idempotent for an unchanged candidate: restarting the timer on each
2042
+ // event would mean a hand that never holds perfectly still could never
2043
+ // commit anything, which is the bug a naive dwell always ships with.
2044
+ function armDwell(before: HTMLElement | null): void {
2045
+ if (!draggingEl) {
2046
+ return;
2047
+ }
2048
+ // Already the committed slot, so there is nothing to preview. Withdraw any
2049
+ // pending commitment — the pointer came back.
2050
+ if (before === draggingEl || before === draggingEl.nextElementSibling) {
2051
+ cancelDwell();
2052
+ return;
2053
+ }
2054
+ if (dwellTimer !== null && before === dwellBefore) {
2055
+ return; // same candidate, still holding
2056
+ }
2057
+ if (dwellTimer !== null) {
2058
+ clearTimeout(dwellTimer);
2059
+ }
2060
+ dwellBefore = before;
2061
+ leanToward(before);
2062
+ showDwellRail(before);
2063
+ dwellTimer = setTimeout(() => {
2064
+ dwellTimer = null;
2065
+ commitSlot(before);
2066
+ }, REORDER_DWELL_MS);
2067
+ }
2068
+ function cancelDwell(): void {
2069
+ hideDwellRail();
2070
+ if (dwellTimer === null) {
2071
+ return;
2072
+ }
2073
+ clearTimeout(dwellTimer);
2074
+ dwellTimer = null;
2075
+ dwellBefore = null;
2076
+ releaseLean();
2077
+ }
2078
+ // flushDwell commits a pending slot at once. A release is a decision, so a
2079
+ // drop does not have to wait out a hold it interrupted: this is what keeps a
2080
+ // quick drag-and-drop reordering exactly as it did before the preview
2081
+ // existed, and it is why the hold can afford to be a full second. Only a
2082
+ // `drop` calls it; an abandoned drag reaches dragend without one and reverts.
2083
+ function flushDwell(): void {
2084
+ if (dwellTimer === null) {
2085
+ return;
2086
+ }
2087
+ const before = dwellBefore;
2088
+ clearTimeout(dwellTimer);
2089
+ dwellTimer = null;
2090
+ dwellBefore = null;
2091
+ commitSlot(before);
2092
+ }
2093
+
2094
+ // flipTo runs a rearranging mutation and animates its result: every chip that
2095
+ // ends up somewhere new slides there from where it was. One function for both
2096
+ // directions of the preview — committing a slot, and reverting the whole
2097
+ // gesture — because "the strip rearranged, show the rearrangement" is one job,
2098
+ // and a revert that snapped while a commit slid would read as two different
2099
+ // features.
2100
+ //
2101
+ // `hold` is the one chip that must NOT slide. On a commit that is the dragged
2102
+ // chip: it is the slot, the pointer is already carrying a solid copy of it,
2103
+ // and a hole travelling across the strip alongside that copy is two things
2104
+ // moving at once. On a revert nothing is held — the drag image is gone by
2105
+ // then, so the chip has to travel home itself.
2106
+ function flipTo(mutate: () => void, hold: HTMLElement | null): void {
2107
+ // Reduced motion: perform the rearrangement and animate none of it. The gate
2108
+ // belongs HERE and not only in the CSS, because these transitions are written
2109
+ // inline and no stylesheet gate can reach them; the switcher's release reel
2110
+ // guards its own call site the same way. Read live, so toggling the OS setting
2111
+ // mid-drag takes effect on the next commit and strips any existing lean.
2112
+ if (prefersReduce()) {
2113
+ endShift();
2114
+ mutate();
2115
+ return;
2116
+ }
2117
+ // FIRST — where each chip is right now, LEAN INCLUDED, so a slide continues
2118
+ // out of the lean instead of jumping back through rest first. The switcher's
2119
+ // release reel captures its live swipe preview the same way, and for the
2120
+ // same reason. Rects, not layout offsets: this is VISUAL position, which is
2121
+ // what the animation interpolates (hit-testing wants the opposite, see
2122
+ // dropTargetBefore).
2123
+ const first = new Map<HTMLElement, number>();
2124
+ for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
2125
+ if (el !== hold) {
2126
+ first.set(el, el.getBoundingClientRect().left);
2127
+ }
2128
+ }
2129
+ endShift(); // back to the resting layout before the DOM moves
2130
+ mutate();
2131
+ // LAST — the new resting layout. No style is written between the reads below,
2132
+ // so they are all served from one layout pass.
2133
+ const invert = new Map<HTMLElement, number>();
2134
+ for (const [el, was] of first) {
2135
+ const dx = el.isConnected ? was - el.getBoundingClientRect().left : 0;
2136
+ if (dx !== 0) {
2137
+ invert.set(el, dx);
2138
+ }
2139
+ }
2140
+ if (invert.size === 0) {
2141
+ return; // nothing moved on screen (a zero-rect DOM: happy-dom)
2142
+ }
2143
+ applyShift(invert, "none");
2144
+ // The read forces the reflow that COMMITS the from-state. Without it the
2145
+ // browser is free to collapse the from- and to-writes into one recalc and
2146
+ // no transition runs at all — the exact trap the reel documents.
2147
+ scroller.getBoundingClientRect();
2148
+ const rest = new Map<HTMLElement, number>();
2149
+ for (const el of invert.keys()) {
2150
+ rest.set(el, 0);
2151
+ }
2152
+ applyShift(rest, REORDER_SHIFT_TRANS);
2153
+ shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
2154
+ }
2155
+
2156
+ // commitSlot performs the reorder the hold earned: one DOM move, the slide
2157
+ // that shows it, and the slot fading in at its new home.
2158
+ //
2159
+ // It moves the DOM and NOT tabList. That split is what makes the preview a
2160
+ // preview: tabList stays the arrangement the gesture started from for the
2161
+ // whole drag, and only a drop writes it (syncOrderFromDom). Two things fall
2162
+ // out of it for free — a cancel is just "re-project tabList" (revertPreview),
2163
+ // and a syncChrome arriving mid-drag from an unrelated source (a status tick,
2164
+ // an OSC title) renders the committed order rather than flickering through
2165
+ // whatever the pointer is hovering.
2166
+ //
2167
+ // Every early return releases the lean, and that is not belt-and-braces. The
2168
+ // caller nulls `dwellTimer` BEFORE calling in (both the timer callback and
2169
+ // flushDwell do), so by the time control arrives here `cancelDwell` has already
2170
+ // become a no-op and nothing else would ever hand those chips back.
2171
+ function commitSlot(before: HTMLElement | null): void {
2172
+ // The countdown is over however this call ends: the rail's whole job was to
2173
+ // depict a hold that has now expired.
2174
+ hideDwellRail();
2175
+ const dragged = draggingEl;
2176
+ if (!dragged?.isConnected) {
2177
+ releaseLean();
2178
+ return; // the dragged session was closed elsewhere mid-hold
2179
+ }
2180
+ // The hold captured a live DOM node a whole second ago. A session closed in
2181
+ // another window (an SSE push, or the poll reconcile) removes chips through
2182
+ // dropTab while this gesture is still open, so the reference node may be gone
2183
+ // — and insertBefore throws NotFoundError on a reference that is no longer a
2184
+ // child, which would abandon the drop mid-way and leave DOM order and tabList
2185
+ // disagreeing. null stays legal: it means "past the last chip".
2186
+ if (before !== null && before.parentNode !== scroller) {
2187
+ releaseLean();
2188
+ return;
2189
+ }
2190
+ if (before === dragged || before === dragged.nextElementSibling) {
2191
+ releaseLean();
2192
+ return; // already in that slot
2193
+ }
2194
+ flipTo(() => {
2195
+ scroller.insertBefore(dragged, before);
2196
+ }, dragged);
2197
+ flashSlot(dragged);
2198
+ announceTarget(dragged);
2199
+ }
2200
+
2201
+ // revertPreview puts the strip back the way the gesture found it, and is what
2202
+ // makes Escape mean cancel.
2203
+ //
2204
+ // The old reorder had no revert path at all: dragover moved the chip on every
2205
+ // event and dragend committed whatever the strip happened to be showing, so an
2206
+ // abandoned drag left the tabs rearranged and the only way back was to drag
2207
+ // them again. Reverting needs no saved snapshot, because tabList IS the
2208
+ // snapshot — nothing but a drop writes to it — so the original arrangement is
2209
+ // recovered by re-projecting it, the same projection moveTab uses.
2210
+ function revertPreview(): void {
2211
+ const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
2212
+ const untouched =
2213
+ chips.length === tabList.length && chips.every((el, i) => tabList[i]?.el === el);
2214
+ if (untouched) {
2215
+ return; // no slot ever committed; there is nothing to put back
2216
+ }
2217
+ flipTo(() => {
2218
+ for (const t of tabList) {
2219
+ scroller.appendChild(t.el);
2220
+ }
2221
+ }, null);
2222
+ ctx.announce("Move cancelled");
2223
+ }
2224
+
2225
+ // flashSlot restarts the slot's fade at its new home. Removing and re-adding
2226
+ // the class is the restart (re-adding a class an element already carries
2227
+ // restarts nothing), and the read between them is what flushes the removal to
2228
+ // style so the two writes are not collapsed into one. It owns that read rather
2229
+ // than depending on a caller's, so a commit can call it either side of the
2230
+ // FLIP's own reflow.
2231
+ function flashSlot(el: HTMLElement): void {
2232
+ endSlotFade();
2233
+ el.classList.remove("wt-tab-slotted");
2234
+ el.getBoundingClientRect();
2235
+ el.classList.add("wt-tab-slotted");
2236
+ slotFadeEl = el;
2237
+ slotFadeTimer = setTimeout(() => {
2238
+ slotFadeTimer = null;
2239
+ endSlotFade();
2240
+ }, REORDER_SLOT_FADE_MS);
2241
+ }
2242
+ function endSlotFade(): void {
2243
+ if (slotFadeTimer !== null) {
2244
+ clearTimeout(slotFadeTimer);
2245
+ slotFadeTimer = null;
2246
+ }
2247
+ slotFadeEl?.classList.remove("wt-tab-slotted");
2248
+ slotFadeEl = null;
2249
+ }
2250
+
2251
+ // The two things a reorder can say, and they are deliberately different
2252
+ // sentences. commitSlot moves the DOM but NOT tabList, so a committed slot is a
2253
+ // PREVIEW that Escape can still undo: announcing it as "Moved X to position 3"
2254
+ // told a screen-reader user that a reversible hover state was a finished action,
2255
+ // three times over on a drag that dwelt in three places, sometimes followed by
2256
+ // "Move cancelled" contradicting all of it. So the preview announces a TARGET and
2257
+ // only the drop announces a move.
2258
+ //
2259
+ // The position is read from the DOM because the DOM is what the preview moved;
2260
+ // tabList still holds the order the gesture started from.
2261
+ function slotPosition(el: HTMLElement): number {
2262
+ return [...scroller.querySelectorAll<HTMLElement>(".wt-tab")].indexOf(el) + 1;
2263
+ }
2264
+ function announceTarget(el: HTMLElement): void {
2265
+ const at = slotPosition(el);
2266
+ if (at > 0) {
2267
+ ctx.announce(`Drop position ${String(at)}`);
2268
+ }
2269
+ }
2270
+ // announceMoved is the completed move, on drop. The drag path announced nothing
2271
+ // at all before this, while the menu's moveTab announced every move — the same
2272
+ // reorder, one of them silent to anyone who cannot see the strip rearrange.
2273
+ function announceMoved(el: HTMLElement): void {
2274
+ const tab = tabList.find((t) => t.el === el);
2275
+ const at = slotPosition(el);
2276
+ if (tab && at > 0) {
2277
+ ctx.announce(`Moved ${tab.display} to position ${String(at)}`);
2278
+ }
2279
+ }
2280
+
2281
+ // endReorderPreview ends the GESTURE's state: the pending hold (easing any
2282
+ // lean home as it goes) and the slot's fade.
2283
+ //
2284
+ // It deliberately does NOT stop a slide that is already playing. A slide is an
2285
+ // animation with its own settle timer, and cutting it here is exactly what
2286
+ // would make a revert snap home instead of run — dragend fires immediately
2287
+ // after the revert starts. dragstart and teardown add an explicit endShift()
2288
+ // for the two cases where nothing may be left in flight: a fresh gesture must
2289
+ // start from rest, and a torn-down feature may leave no inline style on an
2290
+ // element it no longer owns.
2291
+ function endReorderPreview(): void {
2292
+ cancelDwell();
2293
+ hideDwellRail(); // cancelDwell returns early when no hold was pending
2294
+ endSlotFade();
2295
+ }
2296
+
2297
+ // abortReorderFor is the one path that cannot wait for dragend, called by every
2298
+ // site that REMOVES a chip while a drag may be open (dropTab and the bulk-close
2299
+ // sweep). Two removals matter, for different reasons:
2300
+ //
2301
+ // - the pending reference chip: commitSlot would hand a detached node to
2302
+ // insertBefore. It guards that too, but standing the hold down here means the
2303
+ // user's lean comes off at the moment the target vanishes rather than a second
2304
+ // later, and a fresh dragover can arm a slot that still exists.
2305
+ // - the DRAGGED chip: the gesture is over and there is no source left to deliver
2306
+ // a dragend. A browser is not obliged to fire one for a removed source, and
2307
+ // without this the feature would keep `draggingEl` set to a detached node for
2308
+ // the rest of its life — which the document-level guard reads as "a tab drag is
2309
+ // in progress" and so would preventDefault every unrelated file or text drop on
2310
+ // the page from then on.
2311
+ function abortReorderFor(removed: HTMLElement): void {
2312
+ if (removed === dwellBefore) {
2313
+ cancelDwell();
2314
+ }
2315
+ if (removed !== draggingEl) {
2316
+ return;
2317
+ }
2318
+ removed.classList.remove("wt-tab-dragging");
2319
+ draggingEl = null;
2320
+ dropped = false;
2321
+ endReorderPreview();
2322
+ endShift();
2323
+ clearDragGhost();
2324
+ }
2325
+
1753
2326
  function syncOrderFromDom(): void {
1754
2327
  const order: Tab[] = [];
1755
2328
  for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
@@ -1761,10 +2334,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1761
2334
  if (order.length === tabList.length) {
1762
2335
  tabList.length = 0;
1763
2336
  tabList.push(...order);
2337
+ // Before syncChrome: it ends in applyServerOrder, which would undo this
2338
+ // move while the tabs still carry their old positions.
2339
+ publishOrder();
1764
2340
  syncChrome();
1765
2341
  }
1766
2342
  }
1767
2343
 
2344
+ // publishOrder sends the strip's arrangement to the server, which is the
2345
+ // write half of tab-order sync: the order belongs to the session SET, so a
2346
+ // drag here has to reach the other devices watching the same server.
2347
+ //
2348
+ // Called only from the two paths that COMMIT a reorder (a finished drag, and
2349
+ // Move left/right), never from the drag preview, which repaints the DOM many
2350
+ // times per gesture. The local strip is already showing the new arrangement,
2351
+ // so this is optimistic; the server echoes the same order back and
2352
+ // applyServerOrder then has nothing to do.
2353
+ //
2354
+ // Best-effort by design, with one exception. A 409 means the server's
2355
+ // session set is not the one just sent — this client has not yet seen a
2356
+ // session created or closed elsewhere — and the answer is to take the
2357
+ // server's word: reconcileOnce adopts what it missed, and the arrangement
2358
+ // that survives is the server's. Re-sending would be a fight this client
2359
+ // cannot win, since its list is the stale one. Any other failure leaves the
2360
+ // strip as the user arranged it for this page and lets the next reorder try
2361
+ // again; a toast for a cosmetic write the user can simply repeat would be
2362
+ // noise, and the arrangement is not lost data.
2363
+ //
2364
+ // It renumbers the tabs BEFORE sending, and that is not bookkeeping: every
2365
+ // tab still carries the position the server gave it, so the applyServerOrder
2366
+ // pass inside the caller's syncChrome would sort the strip straight back and
2367
+ // the tab would snap to where it started. Adopting the new positions locally
2368
+ // is the optimistic half of the write — the server echoes these same numbers
2369
+ // and the echo then lands as a no-op. Against a server with no order route
2370
+ // the numbers simply stay local, which is the old per-page behaviour.
2371
+ function publishOrder(): void {
2372
+ if (tabList.length === 0) {
2373
+ return;
2374
+ }
2375
+ tabList.forEach((tab, i) => {
2376
+ tab.order = i;
2377
+ });
2378
+ if (!started) {
2379
+ return; // the bootstrap is still placing tabs; nothing to publish yet
2380
+ }
2381
+ const ids = tabList.map((t) => t.id);
2382
+ void api.setOrder(ids).catch((err: unknown) => {
2383
+ if (err instanceof SessionAPIError && err.status === 409) {
2384
+ void reconcileOnce();
2385
+ }
2386
+ });
2387
+ }
2388
+
1768
2389
  // --- Drag preview (the ghost) ---
1769
2390
  // WebKit renders NO automatic drag image for an element whose ancestor
1770
2391
  // carries a filter or transform (webkit.org/b/22787) — and the strip's own
@@ -1831,6 +2452,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1831
2452
  for (const item of tabList) {
1832
2453
  scroller.appendChild(item.el);
1833
2454
  }
2455
+ publishOrder(); // before syncChrome, for the reason at its definition
1834
2456
  syncChrome();
1835
2457
  ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
1836
2458
  }
@@ -1848,17 +2470,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1848
2470
  return; // just closed here; ignore a stale listing until the server reaps it
1849
2471
  }
1850
2472
  const tab = addTabChrome(info);
1851
- // Place the tab where the user's stored arrangement says it goes, not
1852
- // where it happened to arrive: the status-stream snapshot and the
1853
- // bootstrap's GET /api/sessions race each other, so arrival order is not
1854
- // the user's order (and is not even stable between loads). A session the
1855
- // arrangement does not know is appended, which is what a genuinely new
1856
- // tab wants.
1857
- const at = orderedInsertIndex(
1858
- tabList.map((t) => t.id),
1859
- savedOrder,
1860
- info.id,
1861
- );
2473
+ // Place the tab where the SERVER's shared order says it goes, not where it
2474
+ // happened to arrive: the status-stream snapshot and the bootstrap's
2475
+ // GET /api/sessions race each other, so arrival order is neither source's
2476
+ // order and is not stable between loads. Against a server that keeps no
2477
+ // order the same call falls back to age, which is creation order. See
2478
+ // compareTabOrder.
2479
+ const at = orderedInsertIndex(tabList, info);
1862
2480
  tabList.splice(at, 0, tab);
1863
2481
  // addTabChrome appended the chip to the scroller's end; move it to match
1864
2482
  // the list when the arrangement put it earlier, so the DOM, the switcher
@@ -1999,6 +2617,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1999
2617
  // Remove immediately (no exit animation): a lingering element made the
2000
2618
  // "+" teleport after a delay, and made a last-tab replacement appear in
2001
2619
  // the second slot before shifting left. The strip reflows in one frame.
2620
+ // A live desktop drag holds references to chips, so tell it first: this
2621
+ // element may be the one it is dragging or the one it means to insert
2622
+ // before, and both stop existing on the next line.
2623
+ abortReorderFor(tab.el);
2002
2624
  tab.el.remove();
2003
2625
  ctx.dropSession(id);
2004
2626
  // If this close empties the expanded list (only one tab remains),
@@ -2074,6 +2696,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2074
2696
  // to be listed here too.
2075
2697
  forgetCueSeen(t.id);
2076
2698
  t.aria.remove();
2699
+ abortReorderFor(t.el); // same reason as dropTab: a live drag holds this node
2077
2700
  t.el.remove();
2078
2701
  ctx.dropSession(t.id);
2079
2702
  }
@@ -2535,6 +3158,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2535
3158
  return;
2536
3159
  }
2537
3160
  t.reports = reports;
3161
+ // The server's shared position. Only an explicitly PRESENT value updates
3162
+ // the tab, for the same reason as progressValue below: an engine that
3163
+ // keeps no order sends no field, and reading that absence as "position 0"
3164
+ // would drag every tab to the front of the strip on every status tick.
3165
+ // syncChrome's applyServerOrder then moves the chip if this changed.
3166
+ if (rec.order !== undefined) {
3167
+ t.order = rec.order;
3168
+ }
2538
3169
  paintStatusDot(t.dot, rec.status, reports);
2539
3170
  // The OSC 9;4 percentage. Only an explicitly PRESENT value updates the
2540
3171
  // tab: the polling fallback lists SessionInfo, which carries no
@@ -2729,7 +3360,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2729
3360
  }
2730
3361
  return false;
2731
3362
  });
2732
- // Live reorder while dragging a tab over the strip (drop/dragend commit it).
3363
+ // Arm the reorder preview while dragging a tab over the strip. This no longer
3364
+ // reorders anything: it picks the slot a release would land in and starts the
3365
+ // hold that commits it (see the reorder-preview block above).
2733
3366
  bar.addEventListener("dragover", (e) => {
2734
3367
  if (!draggingEl) {
2735
3368
  return;
@@ -2738,7 +3371,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2738
3371
  if (e.dataTransfer) {
2739
3372
  e.dataTransfer.dropEffect = "move";
2740
3373
  }
2741
- scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX));
3374
+ armDwell(dropTargetBefore(e.clientX));
2742
3375
  });
2743
3376
  // Commit on drop — and, load-bearing, CANCEL the drop's default action. An
2744
3377
  // uncancelled drop leaves the browser free to act on the drag payload
@@ -2747,22 +3380,72 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2747
3380
  // instead of reordering. Chrome and Firefox ignore an unhandled reorder
2748
3381
  // drop, which is why the strip looked fine on the desktop.
2749
3382
  bar.addEventListener("drop", (e) => {
2750
- if (!draggingEl) {
3383
+ const moved = draggingEl;
3384
+ if (!moved) {
2751
3385
  return;
2752
3386
  }
2753
3387
  e.preventDefault();
3388
+ dropped = true;
3389
+ flushDwell(); // a release decides: do not make a fast drag wait out the hold
2754
3390
  syncOrderFromDom();
3391
+ announceMoved(moved); // the ONE completed-move announcement per gesture
3392
+ });
3393
+ // Leaving the strip stands a pending hold down. The document dragover below
3394
+ // does this too and covers more ground, but it cannot cover the case that
3395
+ // matters most here: the strip is docked at the viewport EDGE, so a pointer can
3396
+ // leave it by leaving the window entirely, and then no other element in the
3397
+ // document receives a dragover to notice with. Without this listener the hold
3398
+ // would run to completion and commit a slot the pointer had already abandoned.
3399
+ //
3400
+ // relatedTarget is what makes dragleave usable at all: it fires on every
3401
+ // child-to-child transition inside the bar (chip to label, label to close), and
3402
+ // the next target being inside the bar is exactly how those are told apart. A
3403
+ // null relatedTarget means the pointer left the window, which is the case this
3404
+ // exists for.
3405
+ bar.addEventListener("dragleave", (e) => {
3406
+ if (!draggingEl) {
3407
+ return;
3408
+ }
3409
+ const next = e.relatedTarget;
3410
+ if (next === null || !bar.contains(next as Node)) {
3411
+ cancelDwell();
3412
+ }
2755
3413
  });
2756
3414
  // A tab released anywhere OTHER than the strip — over the terminal, over
2757
- // any other chrome — must be inert as well, not a navigation, so claim the
2758
- // whole document as a drop target for the life of a tab drag and swallow
2759
- // the drop there too. dragend still commits whatever order the strip was
2760
- // previewing. Gated on draggingEl so an unrelated drag (a file dropped on
2761
- // the page) is left entirely to the browser.
3415
+ // any other chrome — must be inert, not a navigation, so claim the whole
3416
+ // document as a drop target for the life of a tab drag and swallow the drop
3417
+ // there too. Gated on draggingEl so an unrelated drag (a file dropped on the
3418
+ // page) is left entirely to the browser.
2762
3419
  const onDocTabDrop = (e: DragEvent): void => {
2763
- if (draggingEl) {
2764
- e.preventDefault();
3420
+ if (!draggingEl) {
3421
+ return;
2765
3422
  }
3423
+ e.preventDefault();
3424
+ if (bar.contains(e.target as Node)) {
3425
+ return; // on the strip: the bar's own handlers own it
3426
+ }
3427
+ if (e.type === "drop") {
3428
+ // Released off the strip, which CANCELS. The strip is the drop zone and it
3429
+ // is a generous one (a --touch-target chip plus the bar's own padding, ~61px
3430
+ // at the viewport edge), and once the pointer has left it this same handler
3431
+ // has already declared there is no candidate slot out here. Committing a
3432
+ // slot anyway would persist an arrangement chosen at a position the user
3433
+ // visibly left, and worse, the outcome would depend on whether the browser
3434
+ // chose to emit `drop` at all: an accepted document drop committed while a
3435
+ // refused release fell through to dragend and reverted. That distinction is
3436
+ // invisible to the person doing the dragging.
3437
+ //
3438
+ // So: leave `dropped` false, stand the hold down, and let dragend run the
3439
+ // revert. Nothing is lost silently — the revert animates and announces, so a
3440
+ // mis-release reads as "that did not take" rather than as a mystery.
3441
+ cancelDwell();
3442
+ return;
3443
+ }
3444
+ // dragover, off the strip: no candidate slot exists out here, so stand a
3445
+ // pending hold down rather than let it open a gap for a target the pointer
3446
+ // has already left. A slot already COMMITTED stays put; the pointer may yet
3447
+ // come back onto the strip and release there.
3448
+ cancelDwell();
2766
3449
  };
2767
3450
  document.addEventListener("dragover", onDocTabDrop);
2768
3451
  document.addEventListener("drop", onDocTabDrop);
@@ -3351,9 +4034,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3351
4034
  varRoot.style.removeProperty("--wt-tabbar-h");
3352
4035
  root?.classList.remove("wt-tabbed");
3353
4036
  varRoot.style.removeProperty("--wt-reserve-bottom");
3354
- if (switchAnimTimer !== null) {
3355
- clearTimeout(switchAnimTimer);
3356
- }
4037
+ endSwitchAnim(surface);
3357
4038
  if (collapseClearTimer !== null) {
3358
4039
  clearTimeout(collapseClearTimer);
3359
4040
  }
@@ -3361,6 +4042,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3361
4042
  gestureAbort?.abort(); // drop any in-flight gesture's window listeners
3362
4043
  endEdit(); // abandon an open rename without issuing a request
3363
4044
  clearDragGhost();
4045
+ endReorderPreview();
4046
+ endShift(); // leave no inline style on a chip this feature no longer owns
3364
4047
  clearCatchup();
3365
4048
  hideTabMenu();
3366
4049
  for (const t of tabList) {