@cplieger/web-terminal-ui 7.3.2 → 8.0.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.
Files changed (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +21 -0
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. package/src/features/tabs/notify.ts +0 -265
@@ -1,28 +1,25 @@
1
- /**
2
- * tabs feature: multiple independent terminals over the one kernel.
3
- * It owns the session set (GET/POST/DELETE
4
- * /api/sessions), a per-tab LineStore switching cache, the reconnect-on-switch
5
- * swap, and the tab chrome on both form factors: the desktop top-bar strip and
6
- * the mobile bottom-switcher + modal overview sheet. The kernel drives one
7
- * active session; switching re-points the renderer at the next tab's cached
8
- * store (ctx.render.bind) and asks the kernel to reconnect the terminal WS to it
9
- * (ctx.notifySwitch), so the last-known screen paints instantly and the
10
- * background delta arrives after.
11
- *
12
- * @module
13
- */
14
-
15
- import { modes } from "@cplieger/web-terminal-engine";
16
- import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
1
+ // Showing a tab re-points a pane's renderer at the tab's cached store before the
2
+ // reconnect, so the last-known screen paints instantly and the delta arrives
3
+ // after.
4
+
5
+ import type {
6
+ PaneHandle,
7
+ PaneLayoutOwnerRegistration,
8
+ PaneSide,
9
+ TerminalContext,
10
+ TerminalFeature,
11
+ Unsubscribe,
12
+ } from "../../kernel/types.js";
13
+ import { isPaneSide, MIN_SPLIT_AREA_PX } from "../../kernel/layout-policy.js";
14
+ import { windowOf } from "../../kernel/realm.js";
17
15
  import type { ActivityMonitorApi } from "../activity-monitor.js";
18
16
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
19
17
  import { fromHTML, holdFocusOnPress } from "../dom.js";
20
18
  import { createClickSwallow, placeMenuAt } from "../menu-position.js";
21
19
  import { centreChipLabels } from "./ink-centre.js";
22
20
  import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
23
- import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
21
+ import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
24
22
  import {
25
- ACTIVE_TAB_KEY,
26
23
  CUE_SEEN_KEY,
27
24
  MAX_PERSISTED_CUE_SEEN,
28
25
  MAX_PINNED_NAME,
@@ -33,6 +30,7 @@ import {
33
30
  compareTabOrder,
34
31
  createSessionAPI,
35
32
  createTombstones,
33
+ cueIconName,
36
34
  foldedCueStatus,
37
35
  hasPinnedName,
38
36
  isCueStatus,
@@ -47,10 +45,9 @@ import {
47
45
  sanitizePinnedName,
48
46
  serializeCueSeen,
49
47
  statusPhrase,
48
+ summarizeCues,
50
49
  tabAccessibleName,
51
50
  } from "./model.js";
52
- import { NO_ATTENTION, browserAttentionEnv, createAttention, summarize } from "./attention.js";
53
- import { browserNotifierEnv, createNotifier } from "./notify.js";
54
51
  import {
55
52
  REORDER_MOVE_EPS_PX,
56
53
  REORDER_REST_MS,
@@ -65,6 +62,7 @@ import {
65
62
  paintProgress,
66
63
  paintStatusDot,
67
64
  pick,
65
+ splitButtonHTML,
68
66
  switchButtonHTML,
69
67
  } from "./strip.js";
70
68
  import {
@@ -82,177 +80,94 @@ import {
82
80
  } from "./switcher.js";
83
81
 
84
82
  const DEFAULT_API_BASE = "/api/sessions";
85
- // The mobile bottom-switcher (a single full-width active-tab chip + swipe) is
86
- // used ONLY on a narrow coarse-pointer device (a phone — in EITHER
87
- // orientation: a landscape phone is wide but short, and the kernel's narrow
88
- // fact covers both). A big touchscreen (an iPad) and every fine-pointer device
89
- // (a desktop, or an iPad with a trackpad / Magic Keyboard) get the multi-tab
90
- // top strip instead — the switcher's single-giant-tab layout wastes a big
91
- // screen (an iPad was getting the phone UI). The narrow half of that fact is
92
- // the kernel's .wt-narrow root class / ctx.layout().narrow (kernel-owned
93
- // breakpoint constants, root-size driven); CSS pairs it with
94
- // (pointer: coarse) where touch matters.
95
- // Default cadence for the no-activityMonitor polling fallback.
83
+ // The mobile switcher is used only on a narrow coarse-pointer device, in EITHER
84
+ // orientation; a big touchscreen and every fine-pointer device get the strip.
96
85
  const DEFAULT_POLL_MS = 4000;
97
- // The drag data type carrying a reordered tab's session id. A PRIVATE type, not
98
- // text/plain, deliberately: WebKit resolves dropped plain text as a URL and
99
- // NAVIGATES to it when no handler cancels the drop, so a bare session id read as
100
- // a relative path — dropping a tab on iPadOS loaded /<session-id> instead of
101
- // reordering. A custom type also keeps the id out of the system pasteboard when
102
- // the drag leaves the browser, which is what MDN's recommended-drag-types
103
- // guidance prescribes for data specific to one application.
86
+ // A PRIVATE drag type, not text/plain: WebKit resolves dropped plain text as a
87
+ // URL and NAVIGATES to it when no handler cancels the drop, so a bare session id
88
+ // loaded /<session-id> on iPadOS. It also keeps the id out of the pasteboard.
104
89
  const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
105
- // Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
106
- // the shared point-anchored positioner (menu-position.ts), shared with the
107
- // terminal context menu (formerly two hand-synced copies of the same math).
108
90
 
109
- /** The value a peer feature or a host reads through `ctx.use(tabs(...))`: the
110
- * session set as commands, plus a snapshot of the strip.
111
- *
112
- * `create` and `close` are server round trips, and NEITHER rejects: a refusal is
113
- * reported to the user as a toast and the promise resolves anyway, so awaiting one
114
- * tells you the attempt finished and not that it succeeded (read `list` for that).
115
- * Both are safe to call more than once per gesture — `create` shares an in-flight
116
- * create, so the duplicate activation an iPad trackpad delivers opens one terminal
117
- * rather than two. `switchTo` is local and synchronous, and ignores an unknown id.
118
- *
119
- * `list` is a snapshot, not a live view — it does not update, so re-read it rather
120
- * than holding onto the array, and treat it as display data: the tab a user is
121
- * looking at is this feature's own state and mutating it goes through the members
122
- * above. */
91
+ /** The value a peer or a host reads through `ctx.use(tabs(...))`. `create` and
92
+ * `close` are server round trips and NEITHER rejects: a refusal is toasted and
93
+ * the promise resolves anyway, so read `list` for the outcome. `create` shares
94
+ * an in-flight create, so a duplicate activation opens one terminal. `list` is a
95
+ * snapshot of display data, not a live view. */
123
96
  export interface TabsApi {
124
- /** Spawn a fresh session and switch to it. Calls made while a create is in
125
- * flight share that create, so one gesture opens exactly one terminal. */
97
+ /** Spawn a fresh session and show it where a tab click would land it. Calls
98
+ * made while a create is in flight share that create, so one gesture opens
99
+ * exactly one terminal. */
126
100
  create(): Promise<void>;
127
101
  /** Close a session (kills its process) and drop its tab + cache. */
128
102
  close(id: string): Promise<void>;
129
- /** Switch the active tab. */
103
+ /** Show a tab: in the first empty pane, else in the selected pane; a tab a
104
+ * pane already shows has that pane selected instead. */
130
105
  switchTo(id: string): void;
131
- /** The current tabs, active first-to-last by creation. */
106
+ /** Show a tab on one side of the split, opening it when closed; whatever that
107
+ * side showed becomes an ordinary tab, the tab's old side is emptied, and the
108
+ * tab's pane is selected. False when refused: an unknown id, an invalid side,
109
+ * or a split the pane row is too narrow to open. */
110
+ snap(id: string, side: PaneSide): boolean;
111
+ /** The current tabs, first-to-last by creation; `active` marks a tab a pane
112
+ * shows. */
132
113
  list(): readonly { id: string; title: string; active: boolean }[];
133
114
  }
134
115
 
135
- /** Options for the tabs feature.
136
- *
137
- * The two feature-valued members (`activityMonitor`, `keyboardToggle`) must be the
138
- * SAME values the composition includes and must be ordered before tabs in the
139
- * feature list, since `ctx.use` only resolves a peer the kernel has already set
140
- * up. Both are optional and their absence degrades rather than fails: no monitor
141
- * means polling instead of the status SSE, no toolbar means no keyboard button in
142
- * the mobile bar. `buildTabbed` in presets/tabbed.ts is the worked example. */
116
+ /** Options for the tabs feature. The two feature-valued members must be the SAME
117
+ * values the composition includes, ordered before tabs (`ctx.use` only resolves
118
+ * a peer already set up) and marked `scope: "shell"`, since a pane feature's api
119
+ * is not visible at the shell. Both degrade rather than fail when absent. */
143
120
  export interface TabsOptions {
144
121
  /** REST base for the session API (default "/api/sessions"). */
145
122
  apiBase?: string;
146
- /** The activityMonitor feature value, so tabs renders live status dots and
147
- * drops exited/removed tabs (ctx.use). Without it, dots stay neutral and tabs
148
- * falls back to polling the session list (see pollMs). */
123
+ /** The activityMonitor feature value, for live status dots and dropping
124
+ * exited tabs. Without it, tabs polls the session list (see pollMs). */
149
125
  activityMonitor?: TerminalFeature<ActivityMonitorApi>;
150
- /** Poll interval in ms for the no-activityMonitor fallback: without the status
151
- * SSE, tabs re-lists GET /api/sessions on this cadence to refresh dots and
152
- * titles and drop reaped tabs (section 22.5). Ignored when activityMonitor is
153
- * present. Default 4000. */
126
+ /** Poll interval in ms for the no-activityMonitor fallback (default 4000). */
154
127
  pollMs?: number;
155
- /** The mobileToolbar feature value, so the mobile switcher bar renders a
156
- * keyboard button that opens the key grid (ctx.use at tap time). Without it
157
- * the bar shows no keyboard button (e.g. a desktop-only consumer). The
158
- * toolbar should be built with { externalToggle: true } so its own top-right
159
- * toggle is hidden and the grid opens above the bar. */
128
+ /** The mobileToolbar feature value, built with { externalToggle: true }, so
129
+ * the mobile switcher bar renders a keyboard button that opens the key grid. */
160
130
  keyboardToggle?: TerminalFeature<MobileToolbarApi>;
161
- /** Presume every session reports activity (an agent shell, where the program
162
- * always emits OSC 9;4 progress): each tab's dot is visible as idle from
163
- * creation instead of popping in seconds later when the agent has booted
164
- * far enough to first report — the server's sticky reportsActivity flag
165
- * then merely confirms. Default false (evidence-driven reveal: a plain
166
- * shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
131
+ /** Presume every session reports activity (an agent shell): each tab's dot is
132
+ * visible as idle from creation instead of popping in when the agent first
133
+ * reports. Default false, so a plain shell keeps clean, label-only tabs. */
167
134
  presumeReports?: boolean;
168
135
  /** Swap the page's icon links to a status variant while a background session
169
- * holds an unacknowledged cue, so the browser tab icon carries the same colour
170
- * its chip's dot does. Default false, because it needs asset files the library
171
- * cannot ship: the dot's colour comes from the app's own `--status-*` theme, so
172
- * the variants are per-app artifacts.
173
- *
174
- * Enabling it is a promise that those files exist. For every
175
- * `link[rel=icon]` whose filename starts with `favicon`, three variants must
176
- * be served alongside it with `-input`, `-done` and `-alert` inserted after
177
- * that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
178
- * needs `/favicon-input-32x32.png`). Generate them with whatever tooling you
179
- * like as long as it writes exactly those names, and assert their presence in
180
- * the APP's own tests — the library cannot check a file it does not ship, and
181
- * a missing variant is a blank tab icon.
182
- *
183
- * Not every platform honours it: Safari caches the first icon it fetched and
184
- * ignores later changes, and an installed app has no tab icon at all. The
185
- * title count is not gated on this, so those cases lose nothing.
186
- *
187
- * The links are restored on `pagehide` as well as on teardown, because a
188
- * browser remembers one icon per URL and renders it for the bookmark, the
189
- * history row and the new-tab tile. Best-effort there, since the write races
190
- * the unload. Deliberately not on `freeze`: a frozen background tab is still in
191
- * the strip rendering its icon, which is the case this exists for. */
136
+ * holds an unacknowledged cue. Default false, because it needs assets the
137
+ * library cannot ship: for every `link[rel=icon]` whose filename starts with
138
+ * `favicon`, variants with `-input`, `-done` and `-alert` inserted after that
139
+ * token must be served (`/favicon-32x32.png` needs `/favicon-input-32x32.png`),
140
+ * and a missing one is a blank tab icon. Safari ignores later icon changes; the
141
+ * title count is not gated on this, so nothing is lost there. */
192
142
  attentionIcons?: boolean;
193
143
  }
194
144
 
195
- // looksLikeHardwareKey reports whether a keydown could only have come from a
196
- // PHYSICAL keyboard on a touch device. There is no web API that directly says "a
197
- // hardware keyboard is attached" (navigator.keyboard is layout/lock only and
198
- // unsupported on iOS Safari; navigator.virtualKeyboard is Chromium-only), so we
199
- // infer it: the iOS on-screen keyboard has no modifier keys and no
200
- // arrows/Escape/Tab/nav/function keys, so any of these means real hardware. Used
201
- // to latch a "physical keyboard present" flag that also covers a keyboard-only
202
- // Smart Keyboard Folio (which, unlike a Magic Keyboard, adds no trackpad and so
203
- // does not match `any-pointer: fine`).
204
- /** Session creation can be legitimately and TEMPORARILY refused, which is not the
205
- * same event as a broken server. web-terminal-kiro answers 503 with
206
- * `Retry-After: 5` and a body message while its tool engine installs the
207
- * manifest's tools on first boot, a window its own HEALTHCHECK budgets 20
208
- * minutes for. That state used to reach the user as the same fixed "Couldn't
209
- * open a terminal" toast as a 500, with no retry: the page looked broken while
210
- * the server was deliberately waiting, and `/api/health` reported healthy at the
211
- * same time, so the two channels contradicted each other.
212
- *
213
- * So honour what the server published: retry on its own schedule, and repeat its
214
- * own explanation rather than inventing library wording for a host-specific
215
- * condition. Only 503 retries; a 429 rate limit, a 4xx, or a 500 still fails
216
- * fast, because those are not "come back shortly".
217
- *
218
- * The bound is ELAPSED TIME, not an attempt count. An attempt cap interacts
219
- * badly with the server's hint: at web-terminal-kiro's `Retry-After: 5` a dozen
220
- * attempts is only a minute, while the window this exists for can run twenty
221
- * (toolbelt's boot job is bounded at 30 minutes, which is why that app's
222
- * HEALTHCHECK carries --start-period=20m), so the retry would give up long
223
- * before the server was ready and the whole fix would miss its case. Waiting is
224
- * cheap here because every iteration sleeps at least the server's hint, so this
225
- * is never a hot loop. The user is re-told periodically rather than once,
226
- * because a page that silently retries for twenty minutes is its own kind of
227
- * broken. */
145
+ /** Session creation can be TEMPORARILY refused: a host answers 503 with
146
+ * `Retry-After` and a body message while it installs tools on first boot, a
147
+ * window that can run twenty minutes. Only 503 retries, on the server's own
148
+ * schedule and repeating its own explanation. The bound is ELAPSED TIME, not an
149
+ * attempt count, which at `Retry-After: 5` would give up in a minute; every
150
+ * iteration sleeps at least the hint, so this is never a hot loop, and the user
151
+ * is re-told periodically. */
228
152
  const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
229
153
  const CREATE_RETRY_FALLBACK_MS = 5000;
230
154
  const CREATE_RETRY_REANNOUNCE_MS = 60000;
231
155
 
232
- /** How long the catching-up cue may wait for the render backlog to drain before
233
- * retiring itself. A backlog that never drains (the server stops mid-replay, the
234
- * socket drops) must not leave a "Catching up" badge on screen forever, and the
235
- * reconnect that follows will arm a fresh one. */
156
+ /** A backlog that never drains (the socket drops mid-replay) must not leave a
157
+ * "Catching up" badge on screen forever. */
236
158
  const CATCHUP_MAX_MS = 30000;
237
- /** How long the render queue must stay empty before the restore counts as
238
- * finished. The queue empties BETWEEN the server's replay chunks, so a bare
239
- * "queue is empty" test declares victory several times per restore; this
240
- * hysteresis is what turns it into one honest completion signal. */
159
+ /** The render queue empties BETWEEN the server's replay chunks, so a bare "queue
160
+ * is empty" test declares victory several times per restore. */
241
161
  const CATCHUP_SETTLE_MS = 250;
242
- /** Backlog that arms the cue, in queued rows. The renderer builds at most 300
243
- * rows per frame, so a backlog above this needs multiple frames and is worth
244
- * telling the user about; ordinary streaming queues a handful of rows per frame
245
- * and must never arm it (nor pay for the completion poll). */
162
+ /** The renderer builds at most 300 rows per frame, so a backlog above this needs
163
+ * multiple frames; ordinary streaming must never arm the cue. */
246
164
  const CATCHUP_MIN_BACKLOG = 400;
247
165
 
248
- /** readCueSeen loads the acknowledged background-tab cues, or an empty map when
249
- * there is none to trust. Storage itself can throw (Safari private mode, a
250
- * disabled third-party context, an embedder's iframe) and an unreadable map is
251
- * never fatal: the dot simply lights again, exactly as it behaved before
252
- * acknowledgements were remembered. */
253
- function readCueSeen(): Map<string, CueStatus> {
166
+ /** The acknowledged background-tab cues, or an empty map when storage throws or
167
+ * holds nothing trustworthy: the dot simply lights again. */
168
+ function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
254
169
  try {
255
- return parseCueSeen(localStorage.getItem(CUE_SEEN_KEY));
170
+ return parseCueSeen(win.localStorage.getItem(CUE_SEEN_KEY));
256
171
  } catch {
257
172
  return new Map<string, CueStatus>();
258
173
  }
@@ -260,31 +175,54 @@ function readCueSeen(): Map<string, CueStatus> {
260
175
 
261
176
  /** writeCueSeen persists acknowledgements, best-effort: a full quota must not
262
177
  * break the dismissal the user just performed on screen. */
263
- function writeCueSeen(seen: ReadonlyMap<string, CueStatus>): void {
178
+ function writeCueSeen(
179
+ win: Pick<Window, "localStorage">,
180
+ seen: ReadonlyMap<string, CueStatus>,
181
+ ): void {
264
182
  try {
265
- localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
183
+ win.localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
266
184
  } catch {
267
185
  /* storage unavailable or full — the dismissal still holds for this page */
268
186
  }
269
187
  }
270
188
 
189
+ /** A wait the feature's teardown ends early: the timer goes with the feature. */
190
+ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void> {
191
+ return new Promise((resolve) => {
192
+ const timer = win.setTimeout(() => {
193
+ signal.removeEventListener("abort", onAbort);
194
+ resolve();
195
+ }, ms);
196
+ const onAbort = (): void => {
197
+ win.clearTimeout(timer);
198
+ resolve();
199
+ };
200
+ signal.addEventListener("abort", onAbort, { once: true });
201
+ });
202
+ }
203
+
204
+ /** Create a session, honouring the server's retry schedule, for as long as
205
+ * `signal` stays live: an abort ends the wait and throws its reason, and a
206
+ * create that finishes after the abort is closed rather than returned, because
207
+ * the page that asked for it has no tab left to hold it. */
271
208
  async function createSessionHonouringRetry(
272
- api: { create: () => Promise<SessionInfo> },
209
+ api: { create: () => Promise<SessionInfo>; close: (id: string) => Promise<void> },
273
210
  ctx: TerminalContext,
274
- isTornDown: () => boolean,
211
+ signal: AbortSignal,
275
212
  ): Promise<SessionInfo> {
276
- const startedAt = Date.now();
277
- // The elapsed reading of the last announcement, or null when nothing has been
278
- // announced yet. NOT a 0 sentinel: elapsed is legitimately 0 when the server
279
- // refuses inside the first millisecond (a host on the same machine), and a 0
280
- // sentinel read that as "never spoken" and re-announced on the next refusal —
281
- // the one thing the throttle below exists to prevent.
213
+ const win = windowOf(ctx.shell.root.ownerDocument);
214
+ const startedAt = win.Date.now();
215
+ // Null, NOT a 0 sentinel: elapsed is legitimately 0 when a host on the same
216
+ // machine refuses inside the first millisecond.
282
217
  let lastAnnouncedAt: number | null = null;
283
218
  for (;;) {
219
+ signal.throwIfAborted();
220
+ let info: SessionInfo;
284
221
  try {
285
- return await api.create();
222
+ info = await api.create();
286
223
  } catch (err) {
287
- const elapsed = Date.now() - startedAt;
224
+ signal.throwIfAborted();
225
+ const elapsed = win.Date.now() - startedAt;
288
226
  if (
289
227
  !(err instanceof SessionAPIError) ||
290
228
  err.status !== 503 ||
@@ -292,37 +230,30 @@ async function createSessionHonouringRetry(
292
230
  ) {
293
231
  throw err;
294
232
  }
295
- // First refusal always speaks; after that only every
296
- // CREATE_RETRY_REANNOUNCE_MS, so a multi-minute wait is neither silent nor
297
- // a toast storm.
298
- //
299
- // The suffix says the page is WAITING, not recovering from a failure. It
300
- // used to say "retrying", which read as an error loop when the server had
301
- // only said "not yet" -- and the first announcement fires before any retry
302
- // has happened at all, so a user two seconds into a boot was told
303
- // "retrying" about a request that had been made exactly once.
233
+ // "waiting", not "retrying": the server only said "not yet", and the first
234
+ // announcement fires before any retry has happened.
304
235
  if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
305
236
  lastAnnouncedAt = elapsed;
306
237
  const reason = err.serverMessage ?? "Server is not ready yet";
307
238
  ctx.toast(`${reason}; waiting…`, 8000);
308
239
  ctx.announce(`${reason}; waiting…`);
309
240
  }
310
- // Separate from the throttled pair above, and unthrottled: this writes the
311
- // reason onto the loading OVERLAY, which is the only surface a user can
312
- // actually see before the first frame -- the toast and the banner both live
313
- // inside .wt-root and paint under it. It replaces text in place rather than
314
- // stacking notifications, so there is no storm to throttle, and repeating
315
- // the same string is idempotent. This is what turns the black screen of a
316
- // twenty-minute tools install into a screen that says why it is waiting.
241
+ // Unthrottled: the overlay is the only surface visible before the first
242
+ // frame, and it replaces text in place.
317
243
  ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}; waiting…`);
318
- await new Promise((resolve) => {
319
- window.setTimeout(resolve, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS);
244
+ await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
245
+ continue;
246
+ }
247
+ if (signal.aborted) {
248
+ void api.close(info.id).catch((err: unknown) => {
249
+ console.warn(
250
+ `web-terminal-ui: session ${info.id} was created after teardown and could not be closed`,
251
+ err,
252
+ );
320
253
  });
321
- // A reload or destroy during the wait must not resurrect a create.
322
- if (isTornDown()) {
323
- throw err;
324
- }
254
+ signal.throwIfAborted();
325
255
  }
256
+ return info;
326
257
  }
327
258
  }
328
259
 
@@ -348,79 +279,64 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
348
279
  }
349
280
 
350
281
  /** Build the tabs feature.
351
- *
352
- * Requires a server that speaks the session API — `GET`/`POST`/`DELETE` on
353
- * `/api/sessions`, `?session=<id>` on the WebSocket, and ideally the status SSE.
354
- * It registers itself as the kernel's `sessionOwner`, so it and not the kernel
355
- * resolves the session the first connect attaches to. At most one feature may claim
356
- * that role, and `createTerminal` throws before any DOM work when two do.
357
- *
358
- * The arrangement of the strip is SERVER state (the `order` field), while the
359
- * active tab and the dismissed notification cues are per-viewer and live in
360
- * `localStorage` — so a phone reordering tabs moves them on the desktop, and
361
- * switching tabs on one does not move the other. Teardown removes all chrome,
362
- * releases the per-tab caches, and leaves the sessions themselves running on the
363
- * server; closing a tab is what kills a process. */
282
+ * Requires a server that speaks the session API (`GET`/`POST`/`DELETE` on
283
+ * `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
284
+ * It registers as the terminal's `paneLayoutOwner`, so it decides which session
285
+ * the first connect attaches to; two owners fail at `kernel-init`. The strip's
286
+ * order and the pane layout are SERVER state every viewer shares; the dismissed
287
+ * cues are per-viewer in `localStorage`. Teardown removes the chrome and leaves
288
+ * the sessions running; closing a tab is what kills a process. */
364
289
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
365
290
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
366
291
  const presumeReports = opts.presumeReports ?? false;
367
- // reportsOf floors the server's per-session reportsActivity flag with the
368
- // consumer's presumption (see TabsOptions.presumeReports): an agent shell
369
- // shows the idle dot from tab creation instead of waiting out the agent's
370
- // boot-to-first-OSC-9;4 window.
371
292
  const reportsOf = (reports?: boolean): boolean => presumeReports || (reports ?? false);
372
- // The session REST client (model.ts): every call timeout-bounded, list
373
- // shape-guarded, title persistence fire-and-forget.
374
- const api = createSessionAPI(apiBase);
375
293
 
376
- // tabs owns session selection. The static sessionOwner registration tells
377
- // the kernel not to open a bare /ws at startup (which a SessionManager would
378
- // 404 for lack of ?session=); the kernel instead awaits
379
- // resolveInitialSession() once setup completes and performs the first switch
380
- // itself. The registration must exist on the feature VALUE (read before
381
- // setup), while the bootstrap needs setup-scoped state — so it delegates to
382
- // a closure setup() wires. resolveImpl is nulled on teardown.
383
- let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
384
- // Set on teardown so an in-flight create RETRY (which sleeps between attempts,
385
- // potentially across a multi-minute install window) cannot resurrect a session
386
- // for a feature that is already gone.
387
- let tornDown = false;
294
+ // The registration must exist on the feature VALUE, read before setup, while
295
+ // its members need setup-scoped state; setup() wires the closure.
296
+ let live: PaneLayoutOwnerRegistration | null = null;
388
297
  return {
389
298
  name: "tabs",
390
- sessionOwner: {
391
- resolveInitialSession: () => (resolveImpl ? resolveImpl() : Promise.resolve(null)),
299
+ scope: "shell",
300
+ paneLayoutOwner: {
301
+ resolveInitialLayout: () => live?.resolveInitialLayout() ?? Promise.resolve(false),
302
+ shownIn: (side) => live?.shownIn(side) ?? null,
303
+ showIn: (side, id) => live?.showIn(side, id) ?? false,
392
304
  },
393
- // Synchronous setup: the chrome mounts immediately; the async session
394
- // bootstrap that used to live here is the kernel-driven resolver above.
395
305
  setup(ctx: TerminalContext) {
306
+ const doc = ctx.shell.root.ownerDocument;
307
+ const win = windowOf(doc);
308
+ const api = createSessionAPI(apiBase, win);
309
+ // Every request continuation and retry wait checks this after its await, so
310
+ // nothing this feature started can touch the chrome, a pane or the server
311
+ // once the feature is gone.
312
+ const lifetime = new AbortController();
313
+ ctx.defer(() => {
314
+ lifetime.abort();
315
+ });
316
+ // A live read: a plain property read is narrowed to always-false by TS CFA,
317
+ // which cannot model the abort firing during an await.
318
+ const tornDown = (): boolean => lifetime.signal.aborted;
396
319
  const tablist = ctx.tablist();
397
320
  const monitor = opts.activityMonitor ? ctx.use(opts.activityMonitor) : undefined;
398
321
 
399
- // The keyboard buttons wired to the key grid — the mobile switcher's and
400
- // the desktop strip's — built + wired by the ONE makeKbButton factory;
401
- // closeKeyGrid and the sticky-Ctrl armed reflect update every one.
322
+ // Every keyboard button (the switcher's and the strip's) reflects the grid's
323
+ // open state and the sticky-Ctrl armed state.
402
324
  const kbButtons: HTMLElement[] = [];
403
- // makeNewButton / makeKbButton are the shared control factories (goals 2 &
404
- // 3): one "+" and one keyboard-button implementation, each built + wired
405
- // once and reused for the desktop strip and the mobile switcher. The "+"
406
- // spawns a terminal; the keyboard button toggles the key grid (via the
407
- // keyboardToggle feature, read lazily so feature ordering does not matter)
408
- // and reflects its open state on every keyboard button.
409
325
  function makeNewButton(cls: string): HTMLElement {
410
- const btn = fromHTML(newButtonHTML(cls));
326
+ const btn = fromHTML(doc, newButtonHTML(cls));
411
327
  // Keeps the keyboard on the terminal and paints its own press state;
412
328
  // holdFocusOnPress explains why those two are one job. It does NOT make
413
329
  // one press one activation — that is create()'s in-flight coalescing,
414
330
  // for the opposite failure.
415
- holdFocusOnPress(btn);
331
+ ctx.defer(holdFocusOnPress(btn));
416
332
  btn.addEventListener("click", () => {
417
333
  void create();
418
334
  });
419
335
  return btn;
420
336
  }
421
337
  function makeKbButton(cls: string): HTMLElement {
422
- const btn = fromHTML(kbButtonHTML(cls));
423
- holdFocusOnPress(btn);
338
+ const btn = fromHTML(doc, kbButtonHTML(cls));
339
+ ctx.defer(holdFocusOnPress(btn));
424
340
  btn.addEventListener("click", () => {
425
341
  const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
426
342
  if (!kb) {
@@ -436,63 +352,86 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
436
352
  kbButtons.push(btn);
437
353
  return btn;
438
354
  }
355
+ // Both split buttons (the strip's and the switcher's) toggle the split and
356
+ // reflect its state; a terminal built without the split option has none.
357
+ const splitButtons: HTMLElement[] = [];
358
+ function makeSplitButton(cls: string): HTMLElement | null {
359
+ if (!ctx.shell.split.enabled) {
360
+ return null;
361
+ }
362
+ const btn = fromHTML(doc, splitButtonHTML(cls));
363
+ ctx.defer(holdFocusOnPress(btn));
364
+ btn.addEventListener("click", () => {
365
+ const split = ctx.shell.split;
366
+ if (split.isOpen()) {
367
+ split.close();
368
+ } else {
369
+ split.open();
370
+ }
371
+ });
372
+ splitButtons.push(btn);
373
+ return btn;
374
+ }
375
+ function rowFitsSplit(): boolean {
376
+ return ctx.shell.root.clientWidth >= MIN_SPLIT_AREA_PX;
377
+ }
378
+ function paintSplitButtons(): void {
379
+ const open = ctx.shell.split.isOpen();
380
+ const hidden = !rowFitsSplit();
381
+ for (const b of splitButtons) {
382
+ b.hidden = hidden;
383
+ b.setAttribute("aria-expanded", String(open));
384
+ }
385
+ // Two selected tabs are a valid state exactly while two panes are shown.
386
+ if (open) {
387
+ scroller.setAttribute("aria-multiselectable", "true");
388
+ } else {
389
+ scroller.removeAttribute("aria-multiselectable");
390
+ }
391
+ }
439
392
  // makeSwitchButton builds the mobile switcher's dedicated open/close
440
393
  // button (its notification dot is painted by paintSwitchDot). Like the
441
394
  // other bar buttons it goes through holdFocusOnPress; its click toggles the
442
395
  // list (toggleSwitcher opens when collapsed, closes when expanded).
443
396
  function makeSwitchButton(): HTMLElement {
444
- const btn = fromHTML(switchButtonHTML("wt-switcher-switch wt-btn"));
445
- holdFocusOnPress(btn);
397
+ const btn = fromHTML(doc, switchButtonHTML("wt-switcher-switch wt-btn"));
398
+ ctx.defer(holdFocusOnPress(btn));
446
399
  btn.addEventListener("click", () => {
447
400
  toggleSwitcher();
448
401
  });
449
402
  return btn;
450
403
  }
451
404
 
452
- // --- Desktop tab strip (top-bar region) ---
453
- // Two layers: the bar itself never scrolls; an inner scroller
454
- // (.wt-tab-scroll, the tablist) holds ONLY the tabs. The "+" and the
455
- // keyboard button sit OUTSIDE the scroller as fixed bar items in the
456
- // order [scroller | + | kb], so an overflowing tab list can never push
457
- // or scroll either control away. The scroller shrink-wraps its content
458
- // (CSS flex: 0 1 auto), so while the tabs fit the "+" trails the last
459
- // tab exactly as if it were in the list, while the kb button (a wide
460
- // touchscreen; hidden on a fine pointer) stays pinned at the bar's FAR
461
- // right edge via margin-left: auto whatever the tab count. Once the
462
- // tabs overflow, the scroller caps at the remaining bar width and the
463
- // "+" packs right, up against the kb. Both controls are built + wired
464
- // by the same shared factories as the mobile switcher's; the kb button
465
- // is CSS-gated to a wide touchscreen and un-hidden below only when a
466
- // keyboardToggle is wired. addTabChrome appends each tab to the
467
- // scroller's end.
405
+ // The bar never scrolls; the inner scroller holds ONLY the tabs, and the
406
+ // "+" and keyboard buttons sit outside it as fixed bar items, so an
407
+ // overflowing tab list can never push or scroll either control away.
468
408
  const slot = ctx.region("top-bar", "tabs");
469
- const bar = document.createElement("div");
409
+ const bar = doc.createElement("div");
470
410
  bar.className = "wt-tab-bar";
471
411
  slot.appendChild(bar);
472
- const scroller = document.createElement("div");
412
+ const scroller = doc.createElement("div");
473
413
  scroller.className = "wt-tab-scroll";
474
414
  scroller.setAttribute("role", "tablist");
475
415
  bar.appendChild(scroller);
476
416
  const newBtn = makeNewButton("wt-tab-new");
477
417
  bar.appendChild(newBtn);
418
+ const deskSplit = makeSplitButton("wt-tab-split wt-btn");
419
+ if (deskSplit !== null) {
420
+ bar.appendChild(deskSplit);
421
+ }
478
422
  const deskKb = makeKbButton("wt-tab-kb wt-btn");
479
423
  bar.appendChild(deskKb);
480
- // A vertical mouse wheel anywhere over the strip scrolls the tab list
481
- // horizontally — the strip has no vertical dimension to spend the delta
482
- // on, and this is the affordance browser tab bars train. Bound on the
483
- // BAR so the empty strip area and the fixed controls translate too.
484
- // Horizontal-dominant deltas (a trackpad pan) keep native handling, and
485
- // a wheel over a non-overflowing strip falls through untouched. The
486
- // listener is non-passive because a translated tick must preventDefault
487
- // so an embedding page (wt-container mode) does not also scroll.
424
+ // A vertical wheel over the strip scrolls the tab list horizontally, the
425
+ // affordance browser tab bars train. Non-passive because a translated tick
426
+ // must preventDefault so an embedding page does not also scroll.
488
427
  bar.addEventListener(
489
428
  "wheel",
490
429
  (e) => {
491
430
  if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
492
- return; // horizontal-dominant: native scroll already handles it
431
+ return;
493
432
  }
494
433
  if (scroller.scrollWidth <= scroller.clientWidth) {
495
- return; // nothing to scroll: let the page have the wheel
434
+ return;
496
435
  }
497
436
  e.preventDefault();
498
437
  // deltaMode: 0 = pixels, 1 = lines (Firefox wheel), 2 = pages.
@@ -507,28 +446,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
507
446
  { passive: false },
508
447
  );
509
448
 
510
- // Pull the terminal surface up off the docked BOTTOM strip on desktop so
511
- // the bar does not overlap the last rows. The surface is absolute
512
- // inset:0, so a bottom offset (gated to a fine pointer / non-narrow root
513
- // in CSS, since the strip is hidden on the narrow-coarse phone where the
514
- // mobile switcher applies its own inset) clears it. A ResizeObserver
515
- // keeps the offset in step with the real strip height rather than a
516
- // hard-coded guess. The measured height is published on the terminal
517
- // ROOT (not the surface): the scroll-to-bottom button sits in a sibling
518
- // region, not inside .term, so a property set on .term would not inherit
519
- // to it and it would fall back to the 44px guess and overlap the strip.
520
- // Both .term and the button inherit it from .wt-root — and the host page
521
- // never sees it.
522
- const surface = ctx.surface();
523
- surface.classList.add("wt-with-tabbar");
524
- const varRoot = surface.parentElement ?? surface;
525
- const barResize = new ResizeObserver(() => {
449
+ // Every pane's surface clears the strip, the one a later open builds too;
450
+ // the measured strip height is published on the shell ROOT, not a surface:
451
+ // the scroll-to-bottom button sits in a sibling region, so a property set on
452
+ // .term would not reach it and it would overlap the strip.
453
+ const markSurfaces = (): void => {
454
+ for (const p of ctx.shell.panes()) {
455
+ p.surface().classList.add("wt-with-tabbar");
456
+ }
457
+ };
458
+ markSurfaces();
459
+ ctx.defer(() => {
460
+ for (const p of ctx.shell.panes()) {
461
+ p.surface().classList.remove("wt-with-tabbar");
462
+ }
463
+ });
464
+ const varRoot = ctx.shell.root;
465
+ const barResize = new win.ResizeObserver(() => {
526
466
  varRoot.style.setProperty("--wt-tabbar-h", `${String(bar.offsetHeight)}px`);
527
467
  });
528
468
  barResize.observe(bar);
469
+ ctx.defer(() => {
470
+ barResize.disconnect();
471
+ });
529
472
 
530
- // --- Mobile bottom bar (bottom-switcher region) ---
531
- const switcher = fromHTML(SWITCHER_HTML);
473
+ const switcher = fromHTML(doc, SWITCHER_HTML);
532
474
  const swList = pick(switcher, ".wt-switcher-list");
533
475
  const swBar = pick(switcher, ".wt-switcher-bar");
534
476
  const swCurrent = pick(switcher, ".wt-switcher-current");
@@ -549,23 +491,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
549
491
  // swipe: the content (dot + label) and the close (x). Moving both keeps the
550
492
  // whole active-tab chip sliding as one, rather than the close staying put.
551
493
  const swipeEls = [swInner, swClose];
552
- // The mobile "+", keyboard, and switcher buttons: built + wired by the
553
- // SAME shared factories as the desktop strip's, then appended to the bar
554
- // row so the order stays current-wrap | keyboard | switch | "+". Unifying
555
- // the controls means one implementation placed per layout rather than
556
- // duplicated markup. The switch button sits BETWEEN the keyboard and "+":
494
+ // The mobile "+", keyboard, split and switcher buttons: built + wired by
495
+ // the SAME shared factories as the desktop strip's, then appended to the
496
+ // bar row so the order stays current-wrap | keyboard | split | switch | "+".
497
+ // Unifying the controls means one implementation placed per layout rather
498
+ // than duplicated markup. The switch button sits BETWEEN the split and "+":
557
499
  // it toggles the list and carries the moved background-tab attention cue.
558
500
  const swKb = makeKbButton("wt-switcher-kb wt-btn");
501
+ const swSplit = makeSplitButton("wt-switcher-split wt-btn");
559
502
  const swSwitch = makeSwitchButton();
560
503
  const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
561
504
  const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
562
- swBar.append(swKb, swSwitch, swNew);
563
- // Latest-wins notification state for the switch button's dot: overwritten
564
- // by each qualifying background-tab event (applyStatus) and cleared when
565
- // the list opens (expandSwitcher) or the raising tab is visited or closed
566
- // (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
567
- // cue-worthy set is CueStatus (input / done / crashed / failed — the states
568
- // that want the user), declared once in model.ts beside isCueStatus.
505
+ swBar.append(swKb, ...(swSplit === null ? [] : [swSplit]), swSwitch, swNew);
506
+ // Latest-wins cue for the switch button's dot, cleared when the list opens or
507
+ // the raising tab is visited or closed; "" is no pending cue.
569
508
  let switchNotify: "" | CueStatus = "";
570
509
  // The session that raised the pending cue, so arriving on that tab (a
571
510
  // swipe or any switch) acknowledges it without opening the list.
@@ -576,7 +515,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
576
515
  // latch in the snapshot it sends on every open, so without this a
577
516
  // dismissed dot came back on the next load — and on every SSE reconnect,
578
517
  // which on a phone is just returning to a backgrounded page.
579
- const cueSeen = readCueSeen();
518
+ const cueSeen = readCueSeen(win);
580
519
  // markCueSeen records that this viewer has seen `id` holding `status`:
581
520
  // either because that tab is the one on screen, or because the tray listing
582
521
  // every tab's dot was opened. A non-cue status is not an acknowledgeable
@@ -599,7 +538,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
599
538
  }
600
539
  cueSeen.delete(oldest);
601
540
  }
602
- writeCueSeen(cueSeen);
541
+ writeCueSeen(win, cueSeen);
603
542
  // An acknowledgement can change the unseen count without touching the tab
604
543
  // list, so syncChrome's sweep is not enough on its own: opening the tray
605
544
  // and arriving on a tab both land here and neither adds or removes a chip.
@@ -610,111 +549,51 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
610
549
  // (a new working phase, an exit) and when the session goes away.
611
550
  function forgetCueSeen(id: string): void {
612
551
  if (cueSeen.delete(id)) {
613
- writeCueSeen(cueSeen);
552
+ writeCueSeen(win, cueSeen);
614
553
  paintAttention();
615
554
  }
616
555
  }
617
- // OSC 9 Form B notifications: the one signal the specs say to surface
618
- // OUTSIDE the page ("post a notification"), so a finished turn can reach a
619
- // user who is looking at another app. All the policy — the suppression
620
- // rule, the gesture-gated permission request, the silent tab-only
621
- // degradation — lives in notify.ts; this feature only feeds it events and
622
- // gestures.
623
- const notifier = createNotifier(browserNotifierEnv());
624
- // The out-of-page surfaces for the SAME unseen-cue set the switch dot
625
- // shows: the document-title count, the installed app's icon badge, and the
626
- // tab icon. Constructed here so every capability decision is made once,
627
- // and fed only through paintAttention below.
628
- const attention = createAttention(
629
- browserAttentionEnv((text) => {
630
- ctx.titlePrefix(text);
631
- }, opts.attentionIcons === true),
632
- );
633
- // pageVisible is the "can the user see this page at all" test, and
634
- // visibilityState is the only reliable one: document.hasFocus() is false for
635
- // a visible-but-unfocused window, where the terminal IS on screen. Same
636
- // signal notify.ts reads, for the same decision.
556
+ // The out-of-page surfaces render the SAME unseen-cue set the switch dot
557
+ // shows, fed only through paintAttention below.
558
+ const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
559
+ // visibilityState, not document.hasFocus(), which is false for a
560
+ // visible-but-unfocused window where the terminal IS on screen.
637
561
  function pageVisible(): boolean {
638
- return document.visibilityState !== "hidden";
562
+ return doc.visibilityState !== "hidden";
639
563
  }
640
564
 
641
- // onPageVisible is the DEFERRED half of the active-tab acknowledgement in
642
- // applyStatus: a cue that latched while the page was hidden was deliberately
643
- // not acknowledged then, so it could raise the out-of-page surfaces, and this
644
- // acknowledges it the moment the user can actually see the terminal.
645
- //
646
- // Scoped to the ACTIVE tab only. A wholesale clear on becoming visible is the
647
- // obvious shortcut and it is wrong: the tab the user returns to is not
648
- // necessarily the one that was waiting, and blanking a background tab's cue
649
- // it never saw is how a viewer loses the thing it came back for.
565
+ // The DEFERRED half of the shown-tab acknowledgement: a cue that latched on
566
+ // a hidden page raised the out-of-page surfaces and is acknowledged when the
567
+ // user can see the terminal. The SHOWN tabs only: blanking a background
568
+ // tab's cue the viewer never saw loses the thing they came back for.
650
569
  function onPageVisible(): void {
651
- if (!pageVisible() || activeId === null) {
570
+ if (!pageVisible()) {
652
571
  return;
653
572
  }
654
- const active = tabList.find((t) => t.id === activeId);
655
- if (active) {
656
- markCueSeen(active.id, statusOf(active));
657
- }
658
- }
659
- document.addEventListener("visibilitychange", onPageVisible);
660
-
661
- // onPageGone hands the page's own icon back before the page GOES AWAY,
662
- // which is a different question from the page being hidden and needs a
663
- // different event. A browser remembers ONE icon per URL and renders it for
664
- // the bookmark, the history row and the new-tab tile, so a tab closed while
665
- // a cue was lit leaves a status variant standing in for the app until the
666
- // page is next loaded.
667
- //
668
- // `pagehide`, and deliberately NOT `freeze`. freeze fires for a background
669
- // tab the browser is conserving resources on (Android after five minutes in
670
- // the background; desktop for a collapsed tab group and, since Chrome 133, a
671
- // CPU-heavy tab under Energy Saver), and that tab is STILL in the strip
672
- // rendering its icon — restoring there would blank the cue in exactly the
673
- // case the cue exists for. pagehide fires on unload, tab close and bfcache
674
- // entry, where no strip entry is left to render.
675
- //
676
- // Through apply() rather than the icon sink directly, because the sinks are
677
- // change-gated on the last applied value: writing behind that memo would
678
- // leave it believing the variant is still up, and a page restored from the
679
- // bfcache would then never repaint it. Which is what onPageBack is for — a
680
- // bfcache restore re-runs the fold, so the cue returns for a page that did
681
- // not actually go away. Best-effort on a real unload, where the icon write
682
- // races the teardown, and a no-op when nothing was lit.
683
- function onPageGone(): void {
684
- attention.apply(NO_ATTENTION);
685
- }
686
- function onPageBack(event: PageTransitionEvent): void {
687
- if (event.persisted) {
688
- paintAttention();
573
+ for (const t of tabList) {
574
+ if (isShown(t.id)) {
575
+ markCueSeen(t.id, statusOf(t));
576
+ }
689
577
  }
690
578
  }
691
- window.addEventListener("pagehide", onPageGone);
692
- window.addEventListener("pageshow", onPageBack);
579
+ doc.addEventListener("visibilitychange", onPageVisible);
580
+ ctx.defer(() => {
581
+ doc.removeEventListener("visibilitychange", onPageVisible);
582
+ });
693
583
 
694
- // paintAttention re-derives the whole attention state from the tab list and
695
- // this viewer's acknowledgements, and hands it to the surfaces.
696
- //
697
- // A FOLD rather than incremental bookkeeping, for the reason applyServerOrder
698
- // is one: there is no state of its own to get out of step, so no path can
699
- // leave it stale, and it is cheap enough to run on every tick (a loop over a
700
- // handful of tabs, then sinks that no-op when nothing changed). The dot is
701
- // the store — cueStatusOf reads each tab's status back off it — so this and
702
- // the raise in applyStatus fold the same value.
584
+ // A FOLD over the tab list rather than incremental bookkeeping: no state of
585
+ // its own can go stale, and the shell no-ops when nothing changed.
703
586
  function paintAttention(): void {
704
- attention.apply(
705
- summarize(
706
- tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
707
- cueSeen,
708
- ),
587
+ const { count, worst } = summarizeCues(
588
+ tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
589
+ cueSeen,
709
590
  );
591
+ attention.report({ count, icon: worst === "" ? null : cueIconName(worst) });
710
592
  }
711
593
  function paintSwitchDot(): void {
712
- // Reuse the per-tab status-dot colours (single source, css/05-tabs.css
713
- // .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
714
- // re-declaring them: the dot has the .wt-status-dot class, so data-status
715
- // colours it exactly like the tabs' own dots. Its tooltip comes from the
716
- // same wording map as theirs, so the aggregate cue names the state it is
717
- // showing rather than being a coloured dot with no explanation.
594
+ // The dot carries .wt-status-dot, so data-status colours it like the tabs'
595
+ // own dots, and its tooltip comes from their wording map, so the aggregate
596
+ // cue names the state it shows.
718
597
  if (switchNotify === "") {
719
598
  delete swSwitchDot.dataset["status"];
720
599
  swSwitchDot.removeAttribute("title");
@@ -731,11 +610,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
731
610
  switchNotifyId = null;
732
611
  paintSwitchDot();
733
612
  }
734
- // acknowledgeSwitchNotify clears the pending cue when its subject session
735
- // is reached (switchTo — including a swipe arriving on it) or ceases to
736
- // exist (a close/reap): the notification is resolved or moot then, and
737
- // only opening the list used to clear it (the reported "swiping to the
738
- // concerned tab leaves the dot lit").
613
+ // The pending cue is resolved when its session is reached (a switch, a swipe
614
+ // arriving on it) and moot when the session ceases to exist (a close, a
615
+ // reap); opening the list alone does not clear it.
739
616
  function acknowledgeSwitchNotify(id: string): void {
740
617
  if (switchNotifyId === id) {
741
618
  clearSwitchNotify();
@@ -745,7 +622,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
745
622
  // The keyboard buttons open the key grid; show them only when a toolbar is
746
623
  // wired to drive. Read the toolbar's API lazily at tap time (ctx.use), so
747
624
  // feature ordering does not matter.
748
- let offArmed: (() => void) | undefined;
749
625
  if (opts.keyboardToggle) {
750
626
  // Un-hide every keyboard button; the mobile one then shows in the
751
627
  // switcher bar, the desktop one is CSS-gated to a wide touchscreen.
@@ -766,64 +642,71 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
766
642
  }
767
643
  };
768
644
  reflectArmed(kbApi.isCtrlArmed());
769
- offArmed = kbApi.onCtrlArmedChange(reflectArmed);
645
+ ctx.defer(kbApi.onCtrlArmedChange(reflectArmed));
770
646
  }
771
647
  }
772
648
  // Measured optical centring for every chip label in both layouts: writes
773
649
  // --label-ink-shift onto the strip and the switcher from the line box THIS
774
650
  // engine produced for THIS font at THIS size, rather than the em constant
775
651
  // in 00-tokens.css that can only be right at one size (see ink-centre.ts).
776
- const stopInkCentring = centreChipLabels(varRoot, { strip: bar, switcher });
652
+ ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
777
653
 
778
654
  // Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
779
655
  // scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
780
- const root = ctx.surface().parentElement;
781
- root?.classList.add("wt-tabbed");
782
- // Reserve the collapsed bar row's height so terminal content stops above it
783
- // (mobile item 2): viewport.ts adds --wt-reserve-bottom to the surface's
656
+ const root = ctx.shell.root;
657
+ root.classList.add("wt-tabbed");
658
+ // Reserve the collapsed bar row's height so terminal content stops above
659
+ // it: viewport.ts adds --wt-reserve-bottom to the surface's
784
660
  // bottom inset (it reads the var off the surface, which inherits it from
785
661
  // the root). Measure the bar row (not the expandable list, which just
786
662
  // overlays content). innerHeight - rect.top captures the row plus the
787
663
  // safe-area beneath it; the RO fires with the keyboard closed, so the value
788
664
  // excludes the keyboard lift (viewport.ts adds that separately). The
789
665
  // synthetic visualViewport resize makes viewport.ts recompute immediately.
790
- const swReserve = new ResizeObserver(() => {
666
+ const swReserve = new win.ResizeObserver(() => {
791
667
  const rect = swBar.getBoundingClientRect();
792
- const px = rect.height > 0 ? Math.max(0, Math.round(window.innerHeight - rect.top)) : 0;
668
+ const px = rect.height > 0 ? Math.max(0, Math.round(win.innerHeight - rect.top)) : 0;
793
669
  varRoot.style.setProperty("--wt-reserve-bottom", `${String(px)}px`);
794
- window.visualViewport?.dispatchEvent(new Event("resize"));
670
+ win.visualViewport?.dispatchEvent(new win.Event("resize"));
795
671
  });
796
672
  swReserve.observe(swBar);
673
+ ctx.defer(() => {
674
+ swReserve.disconnect();
675
+ });
797
676
 
798
- // Activity dots are revealed PER TAB, not chrome-wide: each dot stays
799
- // hidden (CSS: .wt-status-dot { display: none }) until its session reports
800
- // activity (OSC 9;4 progress or a classified OSC 9 notification), at which
801
- // point paintStatusDot adds .wt-reports to reveal it (see applyStatus /
802
- // syncMobile / updateRow). A program that emits no OSC 9 signal (a plain
803
- // bash/sh) keeps clean, label-only tabs; an agent's tabs light up. The
804
- // monitor (below) is the live source; without it the poll fallback feeds
805
- // the same reportsActivity flag.
806
-
807
- // Catching-up cue: a switched-into tab's cached screen is stale until its
808
- // resume delta lands, so it must not read as live (sections 12/13). Shown
809
- // only if the delta has not arrived shortly after a switch; cleared on the
810
- // first screen frame.
811
- const catchup = document.createElement("div");
812
- catchup.className = "wt-catchup";
813
- catchup.setAttribute("role", "status");
814
- catchup.textContent = "Catching up\u2026";
815
- ctx.region("banner", "catchup").appendChild(catchup);
816
- let catchupTimer: ReturnType<typeof setTimeout> | null = null;
817
- // Completion-poll state for the catching-up cue (see armCatchup).
818
- let catchupPoll: number | null = null;
819
- let catchupEmptySince = 0;
820
- let catchupDeadline = 0;
677
+ // Fire-and-forget frames and timers, cancelled together at teardown so no
678
+ // callback writes to a chip or a row this feature no longer owns.
679
+ const frames = new Set<number>();
680
+ const timers = new Set<number>();
681
+ function frame(cb: () => void): void {
682
+ const id = win.requestAnimationFrame(() => {
683
+ frames.delete(id);
684
+ cb();
685
+ });
686
+ frames.add(id);
687
+ }
688
+ function after(ms: number, cb: () => void): void {
689
+ const id = win.setTimeout(() => {
690
+ timers.delete(id);
691
+ cb();
692
+ }, ms);
693
+ timers.add(id);
694
+ }
695
+ ctx.defer(() => {
696
+ for (const id of frames) {
697
+ win.cancelAnimationFrame(id);
698
+ }
699
+ for (const id of timers) {
700
+ win.clearTimeout(id);
701
+ }
702
+ frames.clear();
703
+ timers.clear();
704
+ });
821
705
 
822
- // --- Desktop right-click tab context menu (overlay region) ---
823
- // Replaces the old bar "Close all" button with a richer per-tab menu. Built
824
- // on demand (mirroring context-menu.ts) so each item targets the
825
- // right-clicked tab and its disabled state reflects that tab's position.
826
- const tabMenu = document.createElement("div");
706
+ // The tab menu is built on demand (as context-menu.ts builds its own) so
707
+ // each item targets the right-clicked tab and its disabled state reflects
708
+ // that tab's position.
709
+ const tabMenu = doc.createElement("div");
827
710
  tabMenu.className = "wt-tab-menu";
828
711
  tabMenu.setAttribute("role", "menu");
829
712
  ctx.region("overlay", "tab-menu").appendChild(tabMenu);
@@ -838,24 +721,53 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
838
721
  // server listing (the SSE re-open snapshot, or the poll's GET
839
722
  // /api/sessions) that predates the server reaping the session does not
840
723
  // re-adopt (flash back) a closed tab.
841
- const tombstones = createTombstones();
724
+ const tombstones = createTombstones(() => win.Date.now());
842
725
  // The expanded mobile list's row elements, keyed by tab id. Rows are
843
726
  // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
844
727
  // same elements from their old slots to their new ones (the rotation).
845
728
  const rowEls = new Map<string, HTMLElement>();
846
- let activeId: string | null = null;
729
+ // Which tab each pane shows is the PANES' fact, read from them rather than
730
+ // mirrored: the shell empties a pane on a close of the split without asking.
731
+ function shownIn(side: PaneSide): string | null {
732
+ const pane = ctx.shell.pane(side);
733
+ return pane !== null && pane.state() === "shown" ? pane.session.id : null;
734
+ }
735
+ function sideOf(id: string): PaneSide | null {
736
+ for (const p of ctx.shell.panes()) {
737
+ if (p.state() === "shown" && p.session.id === id) {
738
+ return p.side;
739
+ }
740
+ }
741
+ return null;
742
+ }
743
+ const isShown = (id: string): boolean => sideOf(id) !== null;
744
+ const anyShown = (): boolean => ctx.shell.panes().some((p) => p.state() === "shown");
745
+ /** The tab that receives typing: the selected pane's. */
746
+ const selectedId = (): string | null => shownIn(ctx.shell.selected());
747
+ const otherSide = (side: PaneSide): PaneSide => (side === "left" ? "right" : "left");
748
+ // Set by a bootstrap that showed nothing, cleared by the first showIn: the
749
+ // one case a status event may pick a tab to show.
750
+ let bootShowedNothing = false;
751
+ // The coalesced layout write (scheduleLayoutWrite): one PUT per macrotask,
752
+ // one in flight at a time, and a change during the flight sends the latest
753
+ // state once it lands, so an older record can never become the server's.
754
+ let layoutWrite: number | null = null;
755
+ let layoutInFlight: Promise<void> | null = null;
756
+ let layoutDirty = false;
757
+ // The record is read once and never written back by a read.
758
+ let applyingRecord = false;
759
+ let warnedLayout = false;
847
760
  let draggingEl: HTMLElement | null = null;
848
- // --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
849
761
  // The chips currently carrying an inline displacement from the commit slide.
850
762
  // A set plus one settle function, so whatever a slide wrote can always be handed
851
763
  // back to the stylesheet — including when a second drag interrupts the first.
852
764
  const shifted = new Set<HTMLElement>();
853
- let shiftTimer: ReturnType<typeof setTimeout> | null = null;
765
+ let shiftTimer: number | null = null;
854
766
  // The no-events fallback timer. It carries its own target, so there is
855
767
  // deliberately NO pending-slot field here: one was the cause of the reorder
856
768
  // dropping commits (see trackRest). restX is the last pointer position seen, which
857
769
  // is how movement is told from stillness.
858
- let restTimer: ReturnType<typeof setTimeout> | null = null;
770
+ let restTimer: number | null = null;
859
771
  let restX: number | null = null;
860
772
  // When the pointer last actually MOVED. A stationary dragover this long after it
861
773
  // is believed as a stop (see trackRest).
@@ -867,7 +779,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
867
779
  let dropped = false;
868
780
  // The chip mid slot-fade, and the timer that ends it.
869
781
  let slotFadeEl: HTMLElement | null = null;
870
- let slotFadeTimer: ReturnType<typeof setTimeout> | null = null;
782
+ let slotFadeTimer: number | null = null;
871
783
  // Gate the new-tab enter animation: tabs present at initial population
872
784
  // should not animate in (jarring on load); only tabs added at runtime do.
873
785
  let started = false;
@@ -882,66 +794,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
882
794
  // row grows+fades in (animateRowIn) rather than rotating.
883
795
  let reelReconcile = false;
884
796
  let creatingTab = false;
885
- let collapseClearTimer: ReturnType<typeof setTimeout> | null = null;
797
+ let collapseClearTimer: number | null = null;
886
798
  let hintShown = false;
887
- // Whether to focus the input on a tab switch. On a device with a physical
888
- // keyboard this is what you want (switch, then type immediately); on a
889
- // keyboard-less touchscreen it must NOT happen, or every switch pops the
890
- // virtual keyboard. No web API reports a hardware keyboard directly, so we
891
- // combine two proxies: (1) a fine pointer (a Magic Keyboard carries a
892
- // trackpad, so an iPad with one matches, as does every desktop; a bare
893
- // phone / keyboard-less tablet does not) — read live, since a keyboard can
894
- // be attached/detached; and (2) sawHardwareKey, latched once we observe a
895
- // keydown only a hardware keyboard emits (covers a trackpad-less keyboard
896
- // folio). See looksLikeHardwareKey and the keydown observer below.
799
+ // No web API reports a hardware keyboard, so two proxies: a fine pointer
800
+ // (read live, since a keyboard folio with a trackpad can be detached) and a
801
+ // keydown only a hardware keyboard emits (a trackpad-less folio).
897
802
  let sawHardwareKey = false;
898
803
  const hasFinePointer = (): boolean =>
899
- typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
804
+ typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
900
805
  const physicalKeyboardLikely = (): boolean => sawHardwareKey || hasFinePointer();
901
- // Motion opt-out (checked live: the OS setting can change). Gates the
902
- // interactive swipe/rotation animations, mirroring the CSS .wt-animate gate.
903
806
  const prefersReduce = (): boolean =>
904
- window.matchMedia("(prefers-reduced-motion: reduce)").matches;
807
+ win.matchMedia("(prefers-reduced-motion: reduce)").matches;
905
808
 
906
- // relabelAll recomputes every tab's display label with de-duplication:
907
- // when two tabs resolve to the same base label (e.g. two shells with the
908
- // same window title, or two tabs whose last submitted line was identical),
909
- // the second and later get a " (k)" suffix in creation order, so the strip
910
- // never shows two identical labels.
911
- //
912
- // The OSC 9;4 percentage prefix ("78% · one") is applied HERE, at render
913
- // time, and stored nowhere: `display` stays the plain de-duplicated label,
914
- // so de-duplication compares real names, the rename field opens on the real
915
- // name, `list()` reports the real name, and clearing the progress needs no
916
- // cleanup — the next paint simply stops adding it.
809
+ // Recomputes every display label with de-duplication. The percentage prefix
810
+ // is applied at render time and stored nowhere, so de-duplication and the
811
+ // rename field see real names and clearing it needs no cleanup.
917
812
  function relabelAll(): void {
918
- // De-duplicate identical labels with a "(k)" suffix, numbered by the session's
919
- // AGE — never by its position in the strip.
920
- //
921
- // The suffix used to be assigned by encounter order while walking tabList, which
922
- // made it a property of the SLOT: two tabs both called "workspace" always read
923
- // "workspace" then "workspace (2)" whichever way round they sat, so the label
924
- // text stayed put while the sessions moved underneath it. Dragging one of them
925
- // changed nothing on screen, and the reorder — which had worked correctly all
926
- // along, server included — was invisible. It also meant dragging tab A could
927
- // renumber tab B, which is the clearest sign the number belonged to the wrong
928
- // thing.
929
- //
930
- // A label answers "which session is this", so it has to be a property of the
931
- // session: stable while other tabs move, and travelling with its own tab. Age is
932
- // that key. createdAt (the server's, tie-broken by id) rather than the local
933
- // `born` counter, so every device and every reload agrees on which "workspace"
934
- // is (2) — `born` is adoption order, which races.
935
- //
936
- // Numbering is recomputed over the LIVE group each time, so it stays contiguous:
937
- // closing "(2)" of three renumbers the survivor rather than leaving a hole. That
938
- // is a renumber with a visible cause the user just performed, which is the one
939
- // kind that reads as sensible.
813
+ // The "(k)" suffix is numbered by the session's AGE, never by its slot:
814
+ // numbered by encounter order, the labels stayed put while the sessions
815
+ // moved underneath them, so a reorder was invisible. Server createdAt
816
+ // rather than the local `born` counter, so every device agrees which
817
+ // "workspace" is (2). Recomputed over the LIVE group, so closing "(2)" of
818
+ // three renumbers the survivor.
940
819
  const groups = new Map<string, Tab[]>();
941
820
  for (const t of tabList) {
942
821
  const { text, fallback } = baseLabel(t);
943
- // Fallback labels are not real names: several untitled tabs all read
944
- // "New tab" with no numbering, which is the pre-existing behaviour.
822
+ // Several untitled tabs all read "New tab" with no numbering.
945
823
  if (fallback) {
946
824
  continue;
947
825
  }
@@ -1015,45 +893,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1015
893
  termInput()?.focus({ preventScroll: true });
1016
894
  }
1017
895
 
1018
- // Did the terminal input own the keyboard when the pointer press now being
1019
- // handled began? A press on tab chrome focuses the control it landed on
1020
- // (the browser's mousedown default action), so by the time the click
1021
- // handler runs the input is already blurred and only a pointerdown-time
1022
- // snapshot still knows where the keyboard came from. Recorded for the whole
1023
- // chrome by the two pointerdown listeners below (the strip and the
1024
- // switcher), not per control: chips come and go with sessions, and the fact
1025
- // belongs to the gesture rather than to what it hit.
896
+ // A press on the chrome focuses the control it hit before the click handler
897
+ // runs, so only a pointerdown-time snapshot knows whether the terminal input
898
+ // held the keyboard; recorded per gesture, since chips come and go.
1026
899
  let inputFocusedAtPress = false;
1027
900
  function noteChromePress(): void {
1028
901
  const el = termInput();
1029
- inputFocusedAtPress = el !== null && document.activeElement === el;
902
+ inputFocusedAtPress = el !== null && doc.activeElement === el;
1030
903
  }
1031
904
  function keyboardParkedOnChrome(): boolean {
1032
- const active = document.activeElement;
905
+ const active = doc.activeElement;
1033
906
  return active !== null && (bar.contains(active) || switcher.contains(active));
1034
907
  }
1035
908
 
1036
- // focusAfterSwitch applies the tab-switch focus rule. Two independent
1037
- // reasons to hand the keyboard to the terminal input:
1038
- //
1039
- // - A physical keyboard is (likely) present, so a switch should leave you
1040
- // able to type at once, with no extra tap (the iPad + Magic Keyboard
1041
- // ask). Skipped on a keyboard-less touchscreen, or every switch would
1042
- // pop the virtual keyboard (#7).
1043
- // - The press that drove this switch took the keyboard OFF the terminal —
1044
- // it focused the chip (or the x, or a switcher row) it pressed, and the
1045
- // keyboard is still parked there. Handing it back is not NEW focus, so
1046
- // it pops no soft keyboard: it restores what the press displaced. Both
1047
- // halves are required for exactly that reason — a switch with no press
1048
- // behind it (a remote adopt, ensureActive), or a press on a device where
1049
- // the input was not focused to begin with, must not summon a keyboard.
1050
- // Leaving the keyboard parked on a chip is not neutral either: the
1051
- // strip's own keydown handling reads arrows as "switch tab" and Delete
1052
- // as "close tab", so every keystroke meant for the terminal is eaten.
1053
- //
1054
- // Never while a rename field is open: it owns the keyboard until it closes,
1055
- // and a switch (the leading click of a double-click, a remote-driven
1056
- // ensureActive) would otherwise yank the caret out of the field.
909
+ // The keyboard goes to the terminal input when a physical keyboard is likely
910
+ // (on a keyboard-less touchscreen every switch would pop the soft keyboard)
911
+ // or when the press behind this switch parked it on the chrome, where the
912
+ // strip's own keydown handling would eat every keystroke; restoring what the
913
+ // press displaced pops no keyboard. Never while a rename field is open.
1057
914
  function focusAfterSwitch(): void {
1058
915
  if (editingId !== null) {
1059
916
  return;
@@ -1063,42 +920,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1063
920
  }
1064
921
  }
1065
922
 
1066
- // paintActive updates the desktop strip's active state. When the ACTIVE
1067
- // TAB CHANGES (tracked via lastRevealedActive — not on every chrome
1068
- // sync, so a user browsing a scrolled strip is never yanked back by an
1069
- // unrelated repaint), the newly active chip is brought into view in the
1070
- // overflowed scroller; inline: "nearest" is a no-op when it is already
1071
- // visible. The typeof guard covers an environment without scrollIntoView.
923
+ // Every shown chip renders active, with no difference between the two panes'
924
+ // (selection is the handle's and the cursor's to show). The selected pane's
925
+ // chip is revealed in the scroller only when THAT TAB CHANGES, so a user
926
+ // browsing a scrolled strip is never yanked back by an unrelated repaint.
1072
927
  let lastRevealedActive = "";
1073
928
  function paintActive(): void {
929
+ const open = ctx.shell.split.isOpen();
1074
930
  for (const t of tabList) {
1075
- const on = t.id === activeId;
931
+ const side = sideOf(t.id);
932
+ const on = side !== null;
1076
933
  t.el.classList.toggle("wt-tab-active", on);
1077
- // setSelected re-adds aria-selected and the roving tabindex, which would
1078
- // undo setEditing(true) on the chip hosting the rename field — and
1079
- // syncChrome runs on every status event, so that happens within a tick.
1080
- // The chip regains its semantics from endEdit's setEditing(false), which
1081
- // reads the CURRENT selected state.
934
+ t.aria.setPanel(side);
935
+ t.aria.setExpanded(open ? on : null);
936
+ // setSelected would undo setEditing(true) on the chip hosting the rename
937
+ // field within a tick; endEdit restores the semantics from the CURRENT
938
+ // state.
1082
939
  if (t.id !== editingId) {
1083
940
  t.aria.setSelected(on);
1084
941
  }
1085
942
  }
1086
- if (activeId && activeId !== lastRevealedActive) {
1087
- lastRevealedActive = activeId;
1088
- const active = tabList.find((t) => t.id === activeId);
943
+ const current = selectedId();
944
+ if (current !== null && current !== lastRevealedActive) {
945
+ lastRevealedActive = current;
946
+ const active = tabList.find((t) => t.id === current);
1089
947
  if (active && typeof active.el.scrollIntoView === "function") {
1090
948
  active.el.scrollIntoView({ block: "nearest", inline: "nearest" });
1091
949
  }
1092
950
  }
951
+ paintSplitButtons();
1093
952
  }
1094
953
 
1095
- // syncMobile updates the bottom bar: active label + dot, and the aggregate
1096
- // needs-input cue. The cue rides the active surface: a background tab
1097
- // blocked on input is glanceable, and tapping/swiping opens the list to
1098
- // resolve it (section 12).
954
+ // syncMobile updates the bottom bar: the selected pane's label + dot, and
955
+ // the aggregate needs-input cue. The cue rides the active surface: a
956
+ // background tab blocked on input is glanceable, and tapping/swiping opens
957
+ // the list to resolve it.
1099
958
  function syncMobile(): void {
1100
- const idx = tabList.findIndex((t) => t.id === activeId);
1101
- const active = idx >= 0 ? tabList[idx] : undefined;
959
+ const current = selectedId();
960
+ const active = tabList.find((t) => t.id === current);
1102
961
  swLabel.textContent = active ? active.display : "";
1103
962
  paintStatusDot(swDot, active ? statusOf(active) : "idle", active?.reports ?? false);
1104
963
  paintProgress(swProgress, active ? shownProgress(active) : PROGRESS_ABSENT);
@@ -1112,7 +971,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1112
971
  // (select = switch + collapse; x = close). Its dot/label are filled by
1113
972
  // updateRow. The element is cached in rowEls and reused across renders.
1114
973
  function buildRow(t: Tab): HTMLElement {
1115
- const row = fromHTML(SWITCHER_ROW_HTML);
974
+ const row = fromHTML(doc, SWITCHER_ROW_HTML);
1116
975
  pick(row, ".wt-switcher-row-select").addEventListener("click", () => {
1117
976
  collapseSwitcher();
1118
977
  switchTo(t.id);
@@ -1131,16 +990,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1131
990
  paintActivityMark(pick(row, ".wt-activity-mark"), t.activity, t.activityCount);
1132
991
  pick(row, ".wt-switcher-row-label").textContent = t.display;
1133
992
  }
1134
- // renderSwitcherList reconciles the expanded list to a row per OTHER tab
1135
- // (the active tab lives in the bar row), REUSING existing row elements
1136
- // rather than rebuilding, so element identity is stable and the swipe
1137
- // rotation can FLIP the same rows between positions. Order is circular
1138
- // starting AFTER the active tab, so the list reads as the queue that
1139
- // follows the current one (active #k -> k+1, k+2, ... wrapping around); as
1140
- // the active tab changes on a swipe, this order rotates by one.
993
+ // Rows are REUSED, never rebuilt, so their identity is stable for the swipe
994
+ // FLIP; the order is circular from the tab after the active one, so a swipe
995
+ // rotates it by one.
1141
996
  function renderSwitcherList(): void {
1142
997
  const n = tabList.length;
1143
- const activeIdx = tabList.findIndex((t) => t.id === activeId);
998
+ const current = selectedId();
999
+ const activeIdx = tabList.findIndex((t) => t.id === current);
1144
1000
  const start = activeIdx >= 0 ? activeIdx : 0;
1145
1001
  const desired: Tab[] = [];
1146
1002
  for (let step = 1; step < n; step++) {
@@ -1159,7 +1015,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1159
1015
  if (!keep.has(id)) {
1160
1016
  rowEls.delete(id);
1161
1017
  if (anim) {
1162
- animateRowOut(el); // collapse + fade, then remove
1018
+ animateRowOut(el, lifetime.signal); // collapse + fade, then remove
1163
1019
  } else {
1164
1020
  el.remove();
1165
1021
  }
@@ -1177,55 +1033,41 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1177
1033
  updateRow(row, t);
1178
1034
  swList.appendChild(row);
1179
1035
  if (isNew && anim) {
1180
- animateRowIn(row); // grow + fade in (the tray height follows)
1036
+ animateRowIn(row, lifetime.signal); // grow + fade in (the tray height follows)
1181
1037
  }
1182
1038
  }
1183
- // Publish the measured content height so the expanded list animates its
1184
- // max-height between 0 and the REAL content height (--wt-list-h in
1185
- // 06-mobile.css), not a fixed 50dvh far larger than the content — which
1186
- // made the open finish early and the close start late (box height =
1187
- // min(content, max-height), so the transition's stretch past the content
1188
- // moved nothing: the asymmetric, choppy toggle). scrollHeight is the full
1189
- // content height regardless of the collapsed max-height:0 clip, so this
1190
- // is valid whether measured while collapsed (on open, before the expanded
1191
- // class) or already open (a tab added/closed). Capped at 50dvh (then
1192
- // overflow-y:auto scrolls). This works without interpolate-size (iOS
1193
- // Safari lacks it), unlike a height:auto transition.
1194
- const visH = window.visualViewport?.height ?? window.innerHeight;
1039
+ // The list animates its max-height to the REAL content height, not a fixed
1040
+ // 50dvh: the stretch past the content moved nothing, so the open finished
1041
+ // early and the close started late. scrollHeight is the full height under
1042
+ // the collapsed max-height:0 clip, and this needs no interpolate-size,
1043
+ // which iOS Safari lacks.
1044
+ const visH = win.visualViewport?.height ?? win.innerHeight;
1195
1045
  switcher.style.setProperty(
1196
1046
  "--wt-list-h",
1197
1047
  `${String(Math.min(swList.scrollHeight, Math.round(visH * 0.5)))}px`,
1198
1048
  );
1199
1049
  }
1200
1050
 
1201
- // clearRows empties the list and drops the reused-row cache (after a
1202
- // collapse), so the next expand rebuilds fresh rather than reusing rows
1203
- // that might carry a stale reel transform.
1051
+ // Drops the reused-row cache too, so the next expand cannot reuse a row
1052
+ // carrying a stale reel transform.
1204
1053
  function clearRows(): void {
1205
1054
  endReelNow();
1206
1055
  swList.replaceChildren();
1207
1056
  rowEls.clear();
1208
1057
  }
1209
1058
 
1210
- // The circular-queue rotation, as a true reel: when a swipe switches the
1211
- // active tab while the list is expanded, every surviving row slides one
1212
- // slot (the rows visibly rotate past a fixed frame), the row that becomes
1213
- // active exits the leading edge, and the row that was active enters the
1214
- // trailing edge. It is a FLIP over reused row elements: prepareReel (run
1215
- // BEFORE syncChrome reconciles the list) snapshots the current row pixel
1216
- // positions and lifts the leaving row out of the flow as an absolute ghost
1217
- // so the reconcile can't reshuffle the survivors; the returned closure
1218
- // (run AFTER the reconcile) inverts every row to its old spot and releases
1219
- // it to the new one, and slides the ghost out. Pixel positions make it
1220
- // correct regardless of the row gap, list padding, or separator border.
1059
+ // The expanded list rotates as a reel on a swipe: a FLIP over reused rows.
1060
+ // prepareReel (BEFORE the reconcile) snapshots row positions and lifts the
1061
+ // leaving row out of the flow as an absolute ghost so the reconcile cannot
1062
+ // reshuffle the survivors; the returned closure (AFTER it) inverts every
1063
+ // row to its old spot and releases it. Pixel positions, so the row gap and
1064
+ // separators cannot make it wrong.
1221
1065
  const REEL_MS = 300;
1222
- let reelTimer: ReturnType<typeof setTimeout> | null = null;
1066
+ let reelTimer: number | null = null;
1223
1067
  let reelGhost: HTMLElement | null = null;
1224
- // endReelNow settles any in-flight reel immediately: drop the ghost, clear
1225
- // the row transforms, and hand overflow/position back to the stylesheet.
1226
1068
  function endReelNow(): void {
1227
1069
  if (reelTimer !== null) {
1228
- clearTimeout(reelTimer);
1070
+ win.clearTimeout(reelTimer);
1229
1071
  reelTimer = null;
1230
1072
  }
1231
1073
  if (reelGhost) {
@@ -1257,7 +1099,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1257
1099
  firstTops.set(id, el.getBoundingClientRect().top);
1258
1100
  }
1259
1101
  const listRect = swList.getBoundingClientRect();
1260
- const st = getComputedStyle(swList);
1102
+ const st = win.getComputedStyle(swList);
1261
1103
  const pitch = ghost.getBoundingClientRect().height + (parseFloat(st.rowGap) || 0);
1262
1104
  const top = (firstTops.get(leaving.id) ?? listRect.top) - listRect.top + swList.scrollTop;
1263
1105
  endReelNow(); // settle a prior reel / the drag preview before this one
@@ -1292,23 +1134,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1292
1134
  ghost.style.transition = "none";
1293
1135
  ghost.style.transform = "translateY(0)";
1294
1136
  ghost.style.opacity = "1";
1295
- // Commit the from-state (transforms + opacities) with a forced reflow
1296
- // BEFORE the to-state, so BOTH the transform and the opacity transitions
1297
- // fire from it. The prior code used a bare rAF (letting the browser
1298
- // collapse from->to into one recalc) and reverted the entering row's
1299
- // opacity to "" (no explicit end value), so the fade never animated
1300
- // (the reported "no fade in / fade out"). The modern display/visibility
1301
- // transition (transition-behavior: allow-discrete + @starting-style,
1302
- // Baseline 2024) does NOT apply here: these rows are reused and moved by
1303
- // a JS transform FLIP, not toggled via display:none, so the reliable
1304
- // path is a real reflow plus an explicit opacity transition.
1305
- swList.getBoundingClientRect(); // read forces the reflow (commit the from-state)
1306
- // Couple opacity to the SAME easing + duration as the transform so a
1307
- // row's fade tracks its DISTANCE from its target slot (each reel row
1308
- // travels one pitch): a row is transparent a pitch away (at the clipped
1309
- // edge) and only fully opaque once it settles. Entering rows fade IN as
1310
- // they rotate in, the leaving row fades OUT as it exits: no hard cutoff
1311
- // at the list edges and no permanent edge mask.
1137
+ // A forced reflow commits the from-state BEFORE the to-state, or the
1138
+ // browser collapses the two into one recalc and nothing animates. The
1139
+ // end opacity is explicit for the same reason; a bare "" never faded.
1140
+ // @starting-style does not apply: these rows are moved by a JS FLIP, not
1141
+ // toggled through display.
1142
+ swList.getBoundingClientRect();
1143
+ // Opacity shares the transform's easing so a row's fade tracks its
1144
+ // DISTANCE from its slot: transparent a pitch away, opaque once settled.
1312
1145
  const trans =
1313
1146
  "transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.25s cubic-bezier(0.2, 0, 0, 1)";
1314
1147
  for (const el of rowEls.values()) {
@@ -1319,7 +1152,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1319
1152
  ghost.style.transition = trans;
1320
1153
  ghost.style.transform = `translateY(${String(Math.round(exit))}px)`;
1321
1154
  ghost.style.opacity = "0";
1322
- reelTimer = setTimeout(endReelNow, REEL_MS);
1155
+ reelTimer = win.setTimeout(endReelNow, REEL_MS);
1323
1156
  };
1324
1157
  }
1325
1158
 
@@ -1328,12 +1161,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1328
1161
  relabelAll();
1329
1162
  paintActive();
1330
1163
  syncMobile();
1331
- // The dedicated switch button only earns its place once there are ≥2
1332
- // tabs (a single tab has nothing to switch to; expandSwitcher no-ops
1333
- // there). .wt-switcher-multi drives its collapse-when-single / animate-in
1334
- // -when-a-second-opens motion in CSS (the active chip shrinks to make
1335
- // room in lockstep, via the flex layout); aria-hidden + tabindex keep the
1336
- // collapsed button out of the a11y tree and tab order.
1164
+ // A single tab has nothing to switch to; aria-hidden and tabindex keep the
1165
+ // collapsed button out of the a11y tree and the tab order.
1337
1166
  const multiTab = tabList.length >= 2;
1338
1167
  switcher.classList.toggle("wt-switcher-multi", multiTab);
1339
1168
  swSwitch.setAttribute("aria-hidden", multiTab ? "false" : "true");
@@ -1343,50 +1172,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1343
1172
  }
1344
1173
  maybeSwipeHint();
1345
1174
  applyServerOrder();
1346
- // Hung off syncChrome for the reason applyServerOrder is: it is the one
1347
- // function every list mutation already ends with (reorder, create, adopt,
1348
- // close, reconcile), so no path can forget the out-of-page surfaces. The
1349
- // fold is idempotent and the sinks no-op when unchanged, so running it
1350
- // this often costs a comparison.
1175
+ // Every list mutation ends in syncChrome, so no path can forget the
1176
+ // out-of-page surfaces; the fold is idempotent and the sinks no-op.
1351
1177
  paintAttention();
1352
1178
  }
1353
1179
 
1354
- // The percentage is deliberately NOT written into the browser document
1355
- // title. ConEmu's spec names the taskbar/title as a display site, and a
1356
- // page's document title is the nearest analogue — but a page has exactly
1357
- // ONE title while this UI multiplexes many sessions, so any rule for
1358
- // choosing whose percentage it shows is arbitrary. Even restricted to the
1359
- // active tab it churns a surface that doubles as the browser-tab label and
1360
- // the bookmark name, and it invites reading one session's progress as the
1361
- // window's. The per-chip prefix carries the same information without the
1362
- // conflict, because each chip shows its own session. Do not add it back.
1363
- //
1364
- // What DOES go there is the unseen-cue COUNT (see paintAttention), and it
1365
- // is not a softening of this rule: a count names no session, so it needs no
1366
- // arbitrary choice, and it changes only when a cue is raised or acknowledged
1367
- // rather than on every progress tick. A per-session value in that prefix
1368
- // would be this same mistake in a new place.
1180
+ // The percentage is deliberately NOT written into the document title: a page
1181
+ // has ONE title over many sessions, so any rule for whose percentage it shows
1182
+ // is arbitrary, and it churns the browser-tab label and bookmark name. The
1183
+ // unseen-cue COUNT is not a softening of this rule: it names no session.
1369
1184
 
1370
- // applyServerOrder re-sorts the strip into the order the SERVER holds, and
1371
- // is the read half of tab-order sync: a drag on another device arrives as a
1372
- // new `order` on that session's status event, and this is what turns it into
1373
- // a moved chip here.
1374
- //
1375
- // It hangs off syncChrome — the one function every list mutation already
1376
- // ends with (reorder, create, adopt, close, reconcile) — so no path can
1377
- // forget it. Three properties make that safe to run that often:
1378
- //
1379
- // - It is a no-op when the strip already matches, decided by one pass over
1380
- // the list, so the status tick pays a comparison and nothing else.
1381
- // - It is skipped mid-drag. The pointer owns the strip then, and the DOM
1382
- // holds a preview that is deliberately not yet in tabList; re-sorting
1383
- // under the user's finger would fight the gesture.
1384
- // - It sorts by the same total order adoption inserts by (compareTabOrder),
1385
- // so the two cannot disagree about where a tab belongs.
1386
- //
1387
- // A locally-committed reorder is already applied optimistically, and the
1388
- // server echoes that same arrangement back, so the echo lands here as a
1389
- // no-op rather than as a second visible move.
1185
+ // The read half of tab-order sync, hung off syncChrome so no path can forget
1186
+ // it. Safe that often because it is a no-op when the strip already matches,
1187
+ // skipped mid-drag (the DOM then holds a preview not yet in tabList), and
1188
+ // sorted by the same order adoption inserts by.
1390
1189
  function applyServerOrder(): void {
1391
1190
  if (draggingEl !== null) {
1392
1191
  return;
@@ -1428,16 +1227,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1428
1227
  }
1429
1228
  }
1430
1229
 
1431
- // setExpandedState applies the resting expanded/collapsed state: the class
1432
- // that drives the list's max-height (and its padding/border), the aria
1433
- // flag, and (on collapse) the deferred row clear so the rows leave the a11y
1434
- // tree only after the collapse animation. It does not render rows or touch
1435
- // the inline styles the interactive drag uses; expandSwitcher and the drag
1436
- // release own those.
1230
+ // The RESTING state only: rows leave the a11y tree after the collapse
1231
+ // animation, and the inline styles the interactive drag writes are
1232
+ // expandSwitcher's and the drag release's to own.
1437
1233
  function setExpandedState(on: boolean): void {
1438
1234
  if (on) {
1439
1235
  if (collapseClearTimer !== null) {
1440
- clearTimeout(collapseClearTimer);
1236
+ win.clearTimeout(collapseClearTimer);
1441
1237
  collapseClearTimer = null;
1442
1238
  }
1443
1239
  expanded = true;
@@ -1452,9 +1248,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1452
1248
  switcher.classList.remove("wt-switcher-expanded");
1453
1249
  swCurrent.setAttribute("aria-expanded", "false");
1454
1250
  if (collapseClearTimer !== null) {
1455
- clearTimeout(collapseClearTimer);
1251
+ win.clearTimeout(collapseClearTimer);
1456
1252
  }
1457
- collapseClearTimer = setTimeout(() => {
1253
+ collapseClearTimer = win.setTimeout(() => {
1458
1254
  collapseClearTimer = null;
1459
1255
  if (!expanded) {
1460
1256
  clearRows();
@@ -1504,7 +1300,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1504
1300
  }
1505
1301
 
1506
1302
  function addTabChrome(info: StatusRecord): Tab {
1507
- const el = fromHTML(TAB_HTML);
1303
+ const el = fromHTML(doc, TAB_HTML);
1508
1304
  const label = el.querySelector<HTMLElement>(".wt-tab-label");
1509
1305
  const dot = el.querySelector<HTMLElement>(".wt-tab-dot");
1510
1306
  const activityEl = el.querySelector<HTMLElement>(".wt-activity-mark");
@@ -1523,9 +1319,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1523
1319
  // strip, where the animation never fires.
1524
1320
  if (started) {
1525
1321
  el.classList.add("wt-tab-enter");
1526
- setTimeout(() => {
1322
+ after(300, () => {
1527
1323
  el.classList.remove("wt-tab-enter");
1528
- }, 300);
1324
+ });
1529
1325
  }
1530
1326
 
1531
1327
  const tab: Tab = {
@@ -1581,13 +1377,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1581
1377
  }
1582
1378
  switchTo(tab.id);
1583
1379
  });
1584
- // Double-click renames, which is also the TOUCH entry path (a double-tap):
1585
- // a long-press on a chip starts a reorder drag on iPadOS rather than
1586
- // opening the context menu, because the chip is draggable — so the menu is
1587
- // not reachable by touch and this is. The chip's touch-action:manipulation
1588
- // (CSS) is what stops iOS eating the second tap as double-tap-to-zoom.
1589
- // The leading click switches to the tab first; Finder selects before
1590
- // renaming too, so that matches expectation.
1380
+ // Also the TOUCH rename path: a long-press on a draggable chip starts a
1381
+ // reorder drag on iPadOS instead of opening the menu. The chip's
1382
+ // touch-action: manipulation keeps iOS from reading the second tap as a zoom.
1591
1383
  el.addEventListener("dblclick", (e) => {
1592
1384
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1593
1385
  return;
@@ -1708,6 +1500,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1708
1500
  });
1709
1501
  el.addEventListener("dragend", () => {
1710
1502
  el.classList.remove("wt-tab-dragging");
1503
+ paintDropHalf(null);
1711
1504
  if (!dropped) {
1712
1505
  // No drop fired, so the drag was abandoned: Escape, or a release the
1713
1506
  // browser refused. Put the strip back — tabList still holds the order
@@ -1723,55 +1516,55 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1723
1516
  return tab;
1724
1517
  }
1725
1518
 
1726
- function switchTo(id: string, dir?: "next" | "prev"): void {
1727
- if (id === activeId) {
1728
- // Already active, so there is no switch to perform — but the FOCUS rule
1729
- // still owes an answer, because the press that delivered this click has
1730
- // already moved the keyboard onto the chip. Two pointer paths land here:
1731
- // clicking the tab that is already active, and the SECOND activation of
1732
- // one press on another tab (see create() for the evidence that this
1733
- // device delivers those). The first activation switches and focuses the
1734
- // terminal; the second one's mousedown re-focuses the chip and then fell
1735
- // out here with the keyboard stranded on it — the reported "the
1736
- // invisible input loses focus when I click another tab", and the same
1737
- // hole a plain mouse click on the active tab hit on every platform.
1738
- focusAfterSwitch();
1739
- return;
1740
- }
1519
+ /** Show `id` in the pane on `side`: the pane's current tab becomes an
1520
+ * ordinary tab, a pane elsewhere showing `id` is emptied first (a session is
1521
+ * never shown twice), and the pane is selected. False when `side` names no
1522
+ * pane that can show a tab (absent, failed or hidden) or `id` no tab. */
1523
+ function showIn(side: PaneSide, id: string, dir?: "next" | "prev"): boolean {
1524
+ const pane = ctx.shell.pane(side);
1741
1525
  const next = tabList.find((t) => t.id === id);
1742
- if (!next) {
1743
- return;
1526
+ if (pane === null || !next) {
1527
+ return false;
1744
1528
  }
1745
- // Derive a slide direction from the index delta when the caller did not
1746
- // give one (a desktop tab click, a sheet select): moving to a later tab
1747
- // slides the incoming content in from the right, an earlier tab from the
1748
- // left, so desktop switches feel like the mobile swipe.
1749
- let slide = dir;
1750
- if (slide === undefined && activeId !== null) {
1751
- const from = tabList.findIndex((t) => t.id === activeId);
1752
- const to = tabList.findIndex((t) => t.id === id);
1753
- if (from >= 0 && to >= 0) {
1754
- slide = to > from ? "next" : "prev";
1529
+ const state = pane.state();
1530
+ if (state === "failed" || state === "hidden") {
1531
+ return false;
1532
+ }
1533
+ const curId = shownIn(side);
1534
+ if (curId === id) {
1535
+ // The press that delivered this click has already moved the keyboard onto
1536
+ // the chip, so the FOCUS rule still owes an answer or the input stays
1537
+ // stranded (a click on the shown tab, or the second activation of one
1538
+ // press).
1539
+ ctx.shell.select(side);
1540
+ focusAfterSwitch();
1541
+ return true;
1542
+ }
1543
+ const from = sideOf(id);
1544
+ if (from !== null) {
1545
+ const losing = ctx.shell.pane(from);
1546
+ if (losing !== null) {
1547
+ next.view = losing.render.captureViewMemory();
1548
+ losing.clearActiveSession();
1755
1549
  }
1756
1550
  }
1757
- // Detach the current tab: save its reading position (keep its cache).
1758
- // Read through the engine's view seam, never surface.scrollTop — the
1759
- // renderer owns the mapping from scroll position to absolute line, and a
1760
- // LINE is what survives the rebuild and the background output this tab's
1761
- // session may produce before we come back (engine
1762
- // docs/scroll-position-fidelity.md §3.1).
1763
- const cur = tabList.find((t) => t.id === activeId);
1551
+ // A later tab slides in from the right, an earlier one from the left, so a
1552
+ // desktop switch feels like the mobile swipe.
1553
+ let slide = dir;
1554
+ const fromIdx = curId === null ? -1 : tabList.findIndex((t) => t.id === curId);
1555
+ const toIdx = tabList.findIndex((t) => t.id === id);
1556
+ if (slide === undefined && fromIdx >= 0) {
1557
+ slide = toIdx > fromIdx ? "next" : "prev";
1558
+ }
1559
+ // A LINE, never surface.scrollTop: the line survives the rebuild and the
1560
+ // background output this tab's session produces before we come back.
1561
+ const cur = curId === null ? undefined : tabList.find((t) => t.id === curId);
1764
1562
  if (cur) {
1765
- cur.view = ctx.render.captureViewMemory();
1766
- }
1767
- // Decide whether to animate the expanded list as a rotation: only a
1768
- // swipe to an adjacent tab while the list is open. prepareReel snapshots
1769
- // the rows BEFORE the reconcile below; the returned closure FLIPs them
1770
- // into their new slots after it.
1771
- const fromIdx = tabList.findIndex((t) => t.id === activeId);
1772
- const toIdx = tabList.findIndex((t) => t.id === next.id);
1773
- // A one-step move: adjacent, OR a wrap between the first and last tab
1774
- // (index gap n-1), since the list rotates infinitely.
1563
+ cur.view = pane.render.captureViewMemory();
1564
+ }
1565
+ // The expanded list rotates only for a swipe to an adjacent tab, wrap
1566
+ // between first and last included. prepareReel snapshots the rows BEFORE
1567
+ // the reconcile; the returned closure FLIPs them after it.
1775
1568
  const stepGap = Math.abs(toIdx - fromIdx);
1776
1569
  let playReel: (() => void) | undefined;
1777
1570
  if (
@@ -1783,207 +1576,218 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1783
1576
  ) {
1784
1577
  playReel = prepareReel(slide, next);
1785
1578
  }
1786
- // Attach the next tab: point the renderer at its cached store and
1787
- // rebuild viewport-first, so the last-known screen paints with no
1788
- // round-trip. Then let the kernel reconnect the WS to it (resume delta).
1789
- //
1790
- // The view goes in WITH the bind, which is what makes the swap atomic.
1791
- // The engine's follow flag is GLOBAL (one per kernel), so the first
1792
- // flush after a bind is gated on whatever state the tab we LEFT was in:
1793
- // binding a following tab right after being scrolled up in another left
1794
- // the controller holding, the post-flush stickToBottom() no-op'd, and the
1795
- // cached screen rendered above the viewport — a black gap until a touch
1796
- // scrolled it and re-engaged follow (the "content pops down when I
1797
- // wiggle it" symptom). bind adopts the incoming follow state
1798
- // synchronously, before the wipe, and re-asserts the incoming reading
1799
- // POSITION across the rebuild's frames until the line it names has
1800
- // actually been built. That second half is why this is no longer a
1801
- // fire-and-forget rAF: a single deferred write landed while only ~301 of
1802
- // up to 5000 rows existed, so the browser clamped it away and nothing
1803
- // retried (engine docs/scroll-position-fidelity.md §1.1, §3.3, §3.4).
1804
- activeId = next.id;
1805
- // Arriving on the tab that raised the switch-button cue resolves it
1806
- // (a swipe through the tabs must dismiss the dot, not only opening the
1807
- // list). Its current latch is acknowledged whether or not it was the
1808
- // cue's subject: the terminal is now on screen, so a reload must not
1809
- // notify about a state the user just looked at.
1579
+ bootShowedNothing = false;
1580
+ // The terminal is now on screen, so a reload must not notify about a state
1581
+ // the user just looked at.
1810
1582
  acknowledgeSwitchNotify(next.id);
1811
1583
  markCueSeen(next.id, next.dot.dataset["status"] ?? "");
1812
- try {
1813
- localStorage.setItem(ACTIVE_TAB_KEY, next.id);
1814
- } catch {
1815
- /* storage unavailable (private mode / disabled) — non-fatal */
1816
- }
1817
- ctx.render.bind(next.store, { view: next.view });
1818
- ctx.notifySwitch({ id: next.id });
1819
- // Arm on a switch only when the user is actually about to wait: either
1820
- // the bind queued a backlog worth several frames, or the incoming tab has
1821
- // no cached content at all (a first visit, so its whole screen is coming
1822
- // over the network). A revisited tab with a warm store paints from cache
1823
- // in one frame and must not flash a cue at every switch.
1584
+ // The view goes in WITH the bind, which makes the swap atomic: the pane's
1585
+ // follow flag is one per renderer, so a bind without it gated the first
1586
+ // flush on the state of the tab we LEFT and the cached screen rendered
1587
+ // above the viewport until a touch re-engaged follow.
1588
+ pane.render.bind(next.store, { view: next.view });
1589
+ pane.notifySwitch({ id: next.id });
1590
+ // Mark the reconcile as reel-driven so renderSwitcherList suppresses its
1591
+ // add/remove row animation (the reel owns row motion here); set before the
1592
+ // selection change, whose listener reconciles the list too.
1593
+ reelReconcile = playReel !== undefined;
1594
+ ctx.shell.select(side);
1595
+ scheduleLayoutWrite();
1596
+ // Only when the user is about to wait: a revisited tab with a warm store
1597
+ // paints from cache in one frame and must not flash a cue at every switch.
1824
1598
  if (
1825
- ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG ||
1826
- ctx.render.getHighestIndex() < 0
1599
+ pane.render.pendingRowCount() > CATCHUP_MIN_BACKLOG ||
1600
+ pane.render.getHighestIndex() < 0
1827
1601
  ) {
1828
- armCatchup();
1602
+ armCatchup(pane);
1829
1603
  }
1830
- flashSwitch(slide);
1831
- // Mark the reconcile as reel-driven so renderSwitcherList suppresses its
1832
- // add/remove row animation (the reel owns row motion here).
1833
- reelReconcile = playReel !== undefined;
1604
+ flashSwitch(pane.surface(), slide);
1834
1605
  syncChrome(); // reconciles the expanded list into the new order
1835
1606
  reelReconcile = false;
1836
1607
  playReel?.(); // FLIP the rows so the reorder reads as a rotation, not a reload
1837
1608
  ctx.announce(`Switched to ${next.display}`);
1838
1609
  focusAfterSwitch();
1610
+ return true;
1839
1611
  }
1840
1612
 
1841
- // armCatchup shows the "catching up" cue while the surface still has a
1842
- // large backlog of rows to build, and clearCatchup hides it and stops the
1843
- // completion poll.
1844
- //
1845
- // What it measures is the RENDER backlog (render.pendingRowCount): rows the
1846
- // store already holds that have not been built into DOM yet. That is the
1847
- // thing the user is actually waiting on, whatever produced it — a resume
1848
- // replay after a wake, the rebuild after a tab switch, or a program that
1849
- // printed a few thousand lines at once.
1850
- //
1851
- // Two earlier conditions were tried and are wrong, so do not go back to
1852
- // them. Clearing on the first screen frame fires long before a large
1853
- // restore has landed (the frame that carries the live window arrives
1854
- // first). Comparing the store's highest index against the resumeAck's
1855
- // `committed` fails for the same reason and more sharply: the window frame
1856
- // delivers the HIGHEST indices, so highest reaches the target while every
1857
- // history line below it is still in flight. Measured on a 4000-line phone
1858
- // restore: the cue cleared immediately and stayed clear for the whole fill.
1859
- // catchupWarranted: the surface still owes the user content. Either rows are
1860
- // queued for building (a resume replay, a switch rebuild, a large burst), or
1861
- // the tab holds nothing at all, which means its screen is still on the
1862
- // network. The second half matters as much as the first: without it, the
1863
- // case the cue is most needed for — switching into a tab that has never been
1864
- // viewed — has an empty queue and would never show it.
1865
- const catchupWarranted = (): boolean =>
1866
- ctx.render.pendingRowCount() > 0 || ctx.render.getHighestIndex() < 0;
1613
+ // The tab-click rule: a tab a pane shows has that pane selected; any other
1614
+ // tab fills the first empty pane, else replaces the selected pane's tab.
1615
+ function switchTo(id: string, dir?: "next" | "prev"): void {
1616
+ const shownOn = sideOf(id);
1617
+ if (shownOn !== null) {
1618
+ ctx.shell.select(shownOn);
1619
+ focusAfterSwitch();
1620
+ return;
1621
+ }
1622
+ showIn(ctx.shell.targetFor(id), id, dir);
1623
+ }
1867
1624
 
1868
- function armCatchup(): void {
1869
- if (catchupTimer === null && !catchup.classList.contains("visible")) {
1870
- catchupTimer = setTimeout(() => {
1871
- catchupTimer = null;
1872
- // Re-check before showing. The delay is anti-flicker, so it has to
1873
- // ask again at the end of it: a burst that arms the cue and then
1874
- // drains inside the delay has nothing to report, and showing it
1875
- // anyway guaranteed a visible flash on every large-but-fast burst
1876
- // (the clear path needs CATCHUP_SETTLE_MS of quiet, so the flash
1877
- // outlived the backlog it described).
1878
- if (catchupWarranted()) {
1879
- catchup.classList.add("visible");
1625
+ // The one rule every split entry point reduces to: the named side shows the
1626
+ // tab, whatever was there becomes an ordinary tab, the tab's old side becomes
1627
+ // empty, and the tab's pane is selected.
1628
+ function snap(id: string, side: PaneSide): boolean {
1629
+ if (!isPaneSide(side) || !tabList.some((t) => t.id === id)) {
1630
+ return false;
1631
+ }
1632
+ const split = ctx.shell.split;
1633
+ if (!split.isOpen() && (!split.canOpen() || !split.open())) {
1634
+ return false;
1635
+ }
1636
+ return showIn(side, id);
1637
+ }
1638
+
1639
+ // A switched-into tab's cached screen is stale until its resume delta lands,
1640
+ // so it must not read as live. The cue measures the RENDER backlog of the
1641
+ // pane it sits in, which is what the user waits on whatever produced it: not
1642
+ // the first screen frame and not the resumeAck's `committed`, because the
1643
+ // window frame delivers the HIGHEST indices first and both cleared a
1644
+ // 4000-line restore at once. The second half covers a never-viewed tab,
1645
+ // whose queue is empty while its screen is on the network.
1646
+ const catchupWarranted = (pane: PaneHandle): boolean =>
1647
+ pane.render.pendingRowCount() > 0 || pane.render.getHighestIndex() < 0;
1648
+
1649
+ interface PaneCatchup {
1650
+ readonly pane: PaneHandle;
1651
+ readonly el: HTMLElement;
1652
+ /** The anti-flicker delay before the cue shows. */
1653
+ timer: number | null;
1654
+ /** The completion poll, on rAF because the renderer has no "queue drained" event. */
1655
+ poll: number | null;
1656
+ emptySince: number;
1657
+ deadline: number;
1658
+ offScreen: Unsubscribe;
1659
+ }
1660
+ const catchups = new Map<PaneHandle, PaneCatchup>();
1661
+ function armCatchup(pane: PaneHandle): void {
1662
+ const c = catchups.get(pane);
1663
+ if (c === undefined) {
1664
+ return;
1665
+ }
1666
+ if (c.timer === null && !c.el.classList.contains("visible")) {
1667
+ c.timer = win.setTimeout(() => {
1668
+ c.timer = null;
1669
+ // A burst that drains inside the anti-flicker delay has nothing to
1670
+ // report, and the clear path needs CATCHUP_SETTLE_MS of quiet.
1671
+ if (catchupWarranted(pane)) {
1672
+ c.el.classList.add("visible");
1880
1673
  }
1881
1674
  }, 150);
1882
1675
  }
1883
- catchupDeadline = Date.now() + CATCHUP_MAX_MS;
1884
- catchupEmptySince = 0;
1885
- if (catchupPoll === null) {
1886
- pollCatchup();
1887
- }
1888
- }
1889
- // pollCatchup runs on rAF because completion is a RENDER condition and the
1890
- // renderer has no event for "queue drained". It stops itself, so the loop
1891
- // only exists while the cue does.
1892
- function pollCatchup(): void {
1893
- catchupPoll = requestAnimationFrame(() => {
1894
- catchupPoll = null;
1895
- if (catchupWarranted()) {
1896
- catchupEmptySince = 0;
1897
- } else if (catchupEmptySince === 0) {
1898
- catchupEmptySince = Date.now();
1899
- } else if (Date.now() - catchupEmptySince >= CATCHUP_SETTLE_MS) {
1900
- clearCatchup();
1676
+ c.deadline = win.Date.now() + CATCHUP_MAX_MS;
1677
+ c.emptySince = 0;
1678
+ if (c.poll === null) {
1679
+ pollCatchup(c);
1680
+ }
1681
+ }
1682
+ function pollCatchup(c: PaneCatchup): void {
1683
+ c.poll = win.requestAnimationFrame(() => {
1684
+ c.poll = null;
1685
+ if (catchupWarranted(c.pane)) {
1686
+ c.emptySince = 0;
1687
+ } else if (c.emptySince === 0) {
1688
+ c.emptySince = win.Date.now();
1689
+ } else if (win.Date.now() - c.emptySince >= CATCHUP_SETTLE_MS) {
1690
+ clearCatchup(c);
1901
1691
  return;
1902
1692
  }
1903
- if (Date.now() > catchupDeadline) {
1904
- clearCatchup();
1693
+ if (win.Date.now() > c.deadline) {
1694
+ clearCatchup(c);
1905
1695
  return;
1906
1696
  }
1907
- pollCatchup();
1697
+ pollCatchup(c);
1698
+ });
1699
+ }
1700
+ function clearCatchup(c: PaneCatchup): void {
1701
+ if (c.timer !== null) {
1702
+ win.clearTimeout(c.timer);
1703
+ c.timer = null;
1704
+ }
1705
+ if (c.poll !== null) {
1706
+ win.cancelAnimationFrame(c.poll);
1707
+ c.poll = null;
1708
+ }
1709
+ c.emptySince = 0;
1710
+ c.el.classList.remove("visible");
1711
+ }
1712
+ function dropCatchup(c: PaneCatchup): void {
1713
+ clearCatchup(c);
1714
+ c.offScreen();
1715
+ c.el.remove();
1716
+ catchups.delete(c.pane);
1717
+ }
1718
+ // Any frame can reveal a backlog worth telling the user about: a resume
1719
+ // replay after a wake or reconnect, the rebuild after a tab switch, or a
1720
+ // program that dumped thousands of lines at once. Arming off the backlog
1721
+ // itself is what makes the cue fire on a wake; the threshold keeps ordinary
1722
+ // streaming out of it, so the completion poll only exists while there is
1723
+ // something to complete.
1724
+ function watchCatchup(pane: PaneHandle): void {
1725
+ const el = doc.createElement("div");
1726
+ el.className = "wt-catchup";
1727
+ el.setAttribute("role", "status");
1728
+ el.textContent = "Catching up\u2026";
1729
+ pane.region("banner", "catchup").appendChild(el);
1730
+ catchups.set(pane, {
1731
+ pane,
1732
+ el,
1733
+ timer: null,
1734
+ poll: null,
1735
+ emptySince: 0,
1736
+ deadline: 0,
1737
+ offScreen: pane.on("wire:screen", () => {
1738
+ if (pane.render.pendingRowCount() > CATCHUP_MIN_BACKLOG) {
1739
+ armCatchup(pane);
1740
+ }
1741
+ }),
1908
1742
  });
1909
1743
  }
1910
- function clearCatchup(): void {
1911
- if (catchupTimer !== null) {
1912
- clearTimeout(catchupTimer);
1913
- catchupTimer = null;
1744
+ /** A cue for every live pane, the one a later open builds included; a pane
1745
+ * torn down or failed loses its cue with its timers. */
1746
+ function syncCatchups(): void {
1747
+ const live = new Set(ctx.shell.panes().filter((p) => p.state() !== "failed"));
1748
+ for (const c of [...catchups.values()]) {
1749
+ if (!live.has(c.pane)) {
1750
+ dropCatchup(c);
1751
+ }
1914
1752
  }
1915
- if (catchupPoll !== null) {
1916
- cancelAnimationFrame(catchupPoll);
1917
- catchupPoll = null;
1753
+ for (const pane of live) {
1754
+ if (!catchups.has(pane)) {
1755
+ watchCatchup(pane);
1756
+ }
1918
1757
  }
1919
- catchupEmptySince = 0;
1920
- catchup.classList.remove("visible");
1921
1758
  }
1922
- // flashSwitch plays the switch animation the animations feature keys off
1923
- // (a no-op when animations are absent or reduced-motion is set). With a
1924
- // direction the incoming content slides in from that side (the swipe
1925
- // feel); without one it is a plain cross-fade. The rAF re-adds the class a
1926
- // frame after clearing it so a rapid re-switch restarts the animation.
1927
- //
1928
- // The class comes off on the animation's OWN end, with the timer kept as
1929
- // the net. Two numbers had to agree and did not: the CSS duration is
1930
- // --dur-standard (0.2s) and this timer is 360ms, so the class outlived the
1931
- // animation by ~144ms (the add is deferred one rAF), which on a tab above
1932
- // ~3000 rows lands in the middle of the rebuild's drain. Reading the event
1933
- // makes the timer a fallback instead of the primary signal.
1934
- //
1935
- // The timer CANNOT be dropped, and three cases are why:
1936
- // - an interrupted animation fires no animationend, and animationcancel is
1937
- // not reliably delivered in Blink. A rapid re-switch is exactly that
1938
- // case, and it is the case the rAF above exists to serve.
1939
- // - the animations feature is optional and removes .wt-animate under
1940
- // reduced motion, so no animation runs and no event ever fires.
1941
- // - a consumer stylesheet could drop the rules entirely.
1942
- //
1943
- // The listener filters on the ONE animation name this switch expects AND on
1944
- // the class still being present. Three notes, because each was argued:
1945
- // - The name must be the expected one, not any of the three. An animation
1946
- // that COMPLETED just as a re-switch landed has its event already queued;
1947
- // the old listener is gone by dispatch time, so the NEW listener receives
1948
- // it, and a listener accepting all three names would let switch N's
1949
- // completion end switch N+1's animation a frame in.
1950
- // - The class check closes the window BEFORE the animation starts. The
1951
- // listener is attached in this task and the class lands a frame later, so
1952
- // for one frame a matching `animationend` from anywhere in the subtree
1953
- // would cancel the pending class-add and skip the animation outright. An
1954
- // animationend cannot precede its own animation, so requiring the class is
1955
- // free.
1956
- // - No generation counter, and no target check. A generation was tried and
1957
- // removed: `endSwitchAnim` removes the listener synchronously before the
1958
- // next one is added, so a stale listener cannot receive an event and the
1959
- // counter could never fire (its red check could not be made to fail). A
1960
- // target check would couple this feature to the kernel's `.term-output`
1961
- // markup, which it does not own; the residual risk is a consumer applying
1962
- // one of these three library-private keyframe names to another element
1963
- // inside the surface, and the class check already reduces that to "ends a
1964
- // running switch animation early" rather than "skips it".
1759
+ syncCatchups();
1760
+ ctx.defer(() => {
1761
+ for (const c of [...catchups.values()]) {
1762
+ dropCatchup(c);
1763
+ }
1764
+ });
1765
+ // The switch class comes off on the animation's OWN end; the timer is the
1766
+ // net, and it cannot be dropped: an interrupted animation fires no
1767
+ // animationend (animationcancel is unreliable in Blink), reduced motion
1768
+ // removes .wt-animate so no event ever fires, and a consumer stylesheet may
1769
+ // drop the rules. The listener filters on the ONE expected animation name
1770
+ // (switch N's queued completion would otherwise end switch N+1's animation)
1771
+ // AND on the class being present (the class lands a frame after the listener).
1965
1772
  const SWITCH_ANIM_NET_MS = 360;
1966
- let switchAnimTimer: ReturnType<typeof setTimeout> | null = null;
1773
+ let switchAnimTimer: number | null = null;
1967
1774
  let switchAnimFrame: number | null = null;
1968
1775
  let switchAnimOff: (() => void) | null = null;
1969
-
1970
- // Drop the classes and every pending mechanism from the previous switch.
1971
- // All three are torn down together: a stray timer or listener from switch N
1972
- // would otherwise strip the class switch N+1 has just added, and a surviving
1973
- // rAF would ADD the previous direction's class alongside the new one. That
1974
- // last one predates this change, and its consequence is not "two animations
1975
- // at once" — the cascade picks one winner for the `animation` property, and
1976
- // the winner is whichever of the three rules comes LAST in the stylesheet
1977
- // (`wt-switching-prev`), regardless of which switch the user actually made. So
1978
- // a forward switch could animate backwards.
1979
- function endSwitchAnim(surface: HTMLElement): void {
1980
- surface.classList.remove(...SWITCH_CLASSES);
1776
+ // The surface mid-animation; a switch in the other pane ends this one first.
1777
+ let switchAnimSurface: HTMLElement | null = null;
1778
+
1779
+ // All three torn down together: a surviving rAF would ADD the previous
1780
+ // direction's class beside the new one, and the cascade's winner is the LAST
1781
+ // rule in the stylesheet, so a forward switch could animate backwards.
1782
+ function endSwitchAnim(): void {
1783
+ switchAnimSurface?.classList.remove(...SWITCH_CLASSES);
1784
+ switchAnimSurface = null;
1981
1785
  if (switchAnimTimer !== null) {
1982
- clearTimeout(switchAnimTimer);
1786
+ win.clearTimeout(switchAnimTimer);
1983
1787
  switchAnimTimer = null;
1984
1788
  }
1985
1789
  if (switchAnimFrame !== null) {
1986
- cancelAnimationFrame(switchAnimFrame);
1790
+ win.cancelAnimationFrame(switchAnimFrame);
1987
1791
  switchAnimFrame = null;
1988
1792
  }
1989
1793
  if (switchAnimOff !== null) {
@@ -1992,57 +1796,36 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1992
1796
  }
1993
1797
  }
1994
1798
 
1995
- function flashSwitch(dir?: "next" | "prev"): void {
1996
- const surface = ctx.surface();
1799
+ function flashSwitch(surface: HTMLElement, dir?: "next" | "prev"): void {
1997
1800
  const cls = dir ? (`wt-switching-${dir}` as const) : ("wt-switching" as const);
1998
- endSwitchAnim(surface);
1801
+ endSwitchAnim();
1802
+ switchAnimSurface = surface;
1999
1803
  const expected = SWITCH_ANIMATIONS[cls];
2000
1804
  const onAnimEnd = (ev: AnimationEvent): void => {
2001
1805
  if (ev.animationName !== expected || !surface.classList.contains(cls)) {
2002
1806
  return;
2003
1807
  }
2004
- endSwitchAnim(surface);
1808
+ endSwitchAnim();
2005
1809
  };
2006
1810
  surface.addEventListener("animationend", onAnimEnd);
2007
1811
  switchAnimOff = (): void => {
2008
1812
  surface.removeEventListener("animationend", onAnimEnd);
2009
1813
  };
2010
- // The net is armed INSIDE the frame that adds the class, never beside it.
2011
- // Armed in this task it would be a deadline on a REQUEST to animate rather
2012
- // than a net around an animation: rAF is paused in a hidden document and
2013
- // deferred under a blocked main thread, so a 360ms timer could fire first,
2014
- // and its `endSwitchAnim` would cancel the pending class-add and skip the
2015
- // animation outright. Armed here, a frame that never runs arms nothing and
2016
- // leaves no class to strip.
2017
- switchAnimFrame = requestAnimationFrame(() => {
1814
+ // The net is armed INSIDE the frame that adds the class: rAF pauses in a
1815
+ // hidden document, so a timer armed beside it could fire first and its
1816
+ // endSwitchAnim would cancel the pending class-add and skip the animation.
1817
+ switchAnimFrame = win.requestAnimationFrame(() => {
2018
1818
  switchAnimFrame = null;
2019
1819
  surface.classList.add(cls);
2020
- switchAnimTimer = setTimeout(() => {
1820
+ switchAnimTimer = win.setTimeout(() => {
2021
1821
  switchAnimTimer = null;
2022
- endSwitchAnim(surface);
1822
+ endSwitchAnim();
2023
1823
  }, SWITCH_ANIM_NET_MS);
2024
1824
  });
2025
1825
  }
2026
1826
 
2027
- // Any frame can reveal a backlog worth telling the user about: a resume
2028
- // replay after a wake or reconnect, the rebuild after a tab switch, or a
2029
- // program that dumped thousands of lines at once. Arming off the backlog
2030
- // itself is what makes the cue fire on a wake, which is where it used to be
2031
- // blind (it was armed only by an explicit tab switch). The threshold keeps
2032
- // ordinary streaming out of it entirely, so the completion poll only exists
2033
- // while there is something to complete.
2034
- ctx.on("wire:screen", () => {
2035
- if (ctx.render.pendingRowCount() > CATCHUP_MIN_BACKLOG) {
2036
- armCatchup();
2037
- }
2038
- });
2039
-
2040
- // Live window-title updates for the active session: the engine sends a
2041
- // TITLE frame on the live socket whenever the process changes its OSC 0/2
2042
- // title, which the kernel republishes as wire:title. baseLabel prefers the
2043
- // OSC title, so applying it here updates the active tab's label at once
2044
- // rather than waiting for the next status-SSE/poll sweep (background tabs,
2045
- // which have no live socket, still refresh their title from that sweep).
1827
+ // A shown session's OSC 0/2 title arrives on its live socket and relabels
1828
+ // the tab at once; a background tab's title waits for the status sweep.
2046
1829
  ctx.on("wire:title", ({ session, title }) => {
2047
1830
  const t = tabList.find((x) => x.id === session);
2048
1831
  // Ignore a blank title (an OSC 0/2 clear the process may emit when it
@@ -2063,7 +1846,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2063
1846
  if (n < 2) {
2064
1847
  return;
2065
1848
  }
2066
- const idx = tabList.findIndex((t) => t.id === activeId);
1849
+ const current = selectedId();
1850
+ const idx = tabList.findIndex((t) => t.id === current);
2067
1851
  if (idx < 0) {
2068
1852
  return;
2069
1853
  }
@@ -2075,73 +1859,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2075
1859
  }
2076
1860
  }
2077
1861
 
2078
- // --- Desktop reorder preview: sweep, rest, slide ------------------------
2079
- //
2080
- // The old reorder moved the dragged chip on EVERY dragover, so the strip
2081
- // rearranged itself continuously under the pointer and — every chip being
2082
- // the same width — nothing on screen said which slot a release would land
2083
- // in. The chip you were dragging looked like the slot it left, the slot it
2084
- // was over, and the slot it would end up in.
2085
- //
2086
- // The preview answers a MOVING pointer and a STOPPED one differently, and the
2087
- // dragged chip stays in the flow throughout as the slot it will land in
2088
- // (`.wt-tab-dragging`, which 30-tabs.css renders as an empty dashed outline
2089
- // rather than a dimmed copy of the tab — the solid copy is the drag image
2090
- // under the pointer, and one of the two had to stop pretending to be the tab):
2091
- //
2092
- // - SWEEPING across the strip rearranges NOTHING. Crossing five tabs used to
2093
- // move all five, which is the whole complaint.
2094
- // - COMING TO REST opens the slot (trackRest -> commitSlot): one DOM move,
2095
- // the displaced chips slide from their old positions to their new ones, and
2096
- // the slot fades in at its new home.
2097
- //
2098
- // Rest is DETECTED, not waited out: REORDER_REST_MS is re-armed by movement, so
2099
- // it expires a rest window after the last MOVEMENT rather than a fixed time
2100
- // after a decision. Two shapes were tried and removed before this one, and both
2101
- // failures are worth keeping: a fixed one-second HOLD (wrong shape for a direct
2102
- // manipulation — it delayed the one case that should be instant, and grew a
2103
- // progress bar to explain the wait, which is the tell that the wait should not
2104
- // have been there), and a sweep-time LEAN of the chips a commit would displace
2105
- // (it read as a second, competing preview and its distance from the real move
2106
- // was too small to tell them apart).
2107
- //
2108
- // A release never waits either: `drop` commits whatever slot is pending
2109
- // (the drop handler commits the slot under the pointer), so dropping mid-sweep
2110
- // still lands the tab exactly where it was headed.
2111
- //
2112
- // dropTargetBefore returns the first tab whose horizontal midpoint is past
2113
- // x (the element the dragged tab should sit before), or null to drop at the
2114
- // end of the tab list. syncOrderFromDom rebuilds tabList to match the
2115
- // strip's DOM order after a drag, so position indicators, the switcher, and
2116
- // close-to-the-right/left all follow the visible order.
2117
- //
2118
- // It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
2119
- // getBoundingClientRect. That is not an optimisation, it is what makes an
2120
- // animated reorder hit-testable at all: a rect read while a chip is mid-slide
2121
- // returns the INTERPOLATED position, so the preview's own motion feeds
2122
- // back into the decision that produced it and the strip oscillates between two
2123
- // slots for as long as the pointer sits near a boundary. Layout offsets are the
2124
- // chip's position in the FLOW, which a transform does not affect at all.
2125
- //
2126
- // An earlier draft instead cached rects measured "at rest" and re-measured after
2127
- // each commit. Two things killed it: the cache had to be invalidated on anything
2128
- // that reflowed the strip (a window or embed resize changes every chip's midpoint
2129
- // without changing the chip SET, which was the only thing the cache checked), and
2130
- // it could capture a transform anyway, because a chip adopted mid-drag arrives
2131
- // mid `.wt-tab-enter` scale. Reading layout needs no cache and cannot go stale.
2132
- //
2133
- // Coordinates: chips share the scroller's offsetParent (the scroller is not
2134
- // positioned, so it is not one), which makes `el.offsetLeft - scroller.offsetLeft`
2135
- // the chip's offset inside the scroller — and the scroller has no border for that
2136
- // subtraction to skip. Layout offsets ignore scrolling, so clientX is converted
2137
- // into the same space by adding scrollLeft back, which is what lets a scrolled
2138
- // strip hit-test correctly.
1862
+ // A MOVING pointer rearranges nothing and a STOPPED one opens the slot; the
1863
+ // dragged chip stays in the flow as the slot it will land in, drawn as a
1864
+ // dashed outline because the solid copy is the drag image under the pointer.
1865
+ // Rest is detected from movement, not waited out (a fixed hold delayed the
1866
+ // one case that should be instant), and a drop commits the slot under the
1867
+ // pointer at once.
1868
+
1869
+ // The first tab whose midpoint is past x, or null for the end. Hit-tests
1870
+ // LAYOUT geometry, never a rect: a rect read mid-slide is the INTERPOLATED
1871
+ // position, so the preview's own motion fed back into the decision and the
1872
+ // strip oscillated near a boundary. Layout offsets ignore scrolling, so
1873
+ // clientX is converted by adding scrollLeft back.
2139
1874
  function dropTargetBefore(clientX: number): HTMLElement | null {
2140
1875
  const x = clientX - scroller.getBoundingClientRect().left + scroller.scrollLeft;
2141
1876
  const base = scroller.offsetLeft;
2142
1877
  for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
2143
1878
  if (el === draggingEl) {
2144
- continue; // the slot does not displace itself
1879
+ continue;
2145
1880
  }
2146
1881
  if (x < el.offsetLeft - base + el.offsetWidth / 2) {
2147
1882
  return el;
@@ -2150,22 +1885,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2150
1885
  return null;
2151
1886
  }
2152
1887
 
2153
- // applyShift writes one displacement per chip and remembers which chips are
2154
- // carrying one. `trans` is REORDER_SHIFT_TRANS to animate to the new value,
2155
- // or "none" to plant a from-state the next write animates out of.
2156
- //
2157
- // It writes the individual `translate` property, NOT `transform`, and that is
2158
- // load-bearing rather than stylistic. Declarations from a running CSS ANIMATION
2159
- // out-rank normal author declarations, inline style included, so a chip in the
2160
- // middle of `wt-slot-in` (`transform: scale(0.97)`) or `wt-tab-in`
2161
- // (`transform: scale(0.82)`) would silently ignore an inline `transform` and
2162
- // refuse to move: an Escape landing inside the slot fade would snap the dragged
2163
- // chip home while its siblings slid. `translate` is a separate property that
2164
- // composes with `transform` instead of fighting it, so the two animations can own
2165
- // one chip at the same time.
1888
+ // `translate`, NOT `transform`: a running CSS ANIMATION out-ranks inline
1889
+ // style, so a chip mid `wt-slot-in` or `wt-tab-in` (both `transform: scale`)
1890
+ // would ignore an inline transform and refuse to move. `translate` composes
1891
+ // with it instead, so the two can own one chip at the same time.
2166
1892
  function applyShift(px: ReadonlyMap<HTMLElement, number>, trans: string): void {
2167
1893
  if (shiftTimer !== null) {
2168
- clearTimeout(shiftTimer);
1894
+ win.clearTimeout(shiftTimer);
2169
1895
  shiftTimer = null;
2170
1896
  }
2171
1897
  for (const [el, dx] of px) {
@@ -2174,13 +1900,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2174
1900
  shifted.add(el);
2175
1901
  }
2176
1902
  }
2177
- // endShift hands every displaced chip back to the stylesheet. Both stages
2178
- // write the same two inline properties, so this one function ends whichever
2179
- // ran last — called before a fresh measurement, by the settle timer after a
2180
- // slide, when the drag ends, and on teardown. Idempotent.
1903
+ // Hands every displaced chip back to the stylesheet. Idempotent.
2181
1904
  function endShift(): void {
2182
1905
  if (shiftTimer !== null) {
2183
- clearTimeout(shiftTimer);
1906
+ win.clearTimeout(shiftTimer);
2184
1907
  shiftTimer = null;
2185
1908
  }
2186
1909
  for (const el of shifted) {
@@ -2190,62 +1913,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2190
1913
  shifted.clear();
2191
1914
  }
2192
1915
 
2193
- // trackRest is the whole gate, called on every dragover over the strip.
2194
- //
2195
- // Sweeping and settling are DIFFERENT actions and get different answers. A
2196
- // travelling pointer rearranges nothing at all; the moment it comes to REST the
2197
- // slot opens.
2198
- //
2199
- // Rest is detected from TWO signals, and separating them is what lets the slot
2200
- // open promptly without a fast sweep committing every slot it crosses:
2201
- //
2202
- // - a `dragover` at an unchanged position is POSITIVE evidence of a stop, so it
2203
- // commits after a short REORDER_STILL_MS confirmation. This is the signal that
2204
- // normally decides, and the reason the delay is short.
2205
- // - the absence of events is only a FALLBACK (armRestNet), for a browser that
2206
- // stops delivering dragover entirely. That one has to out-wait the drag loop's
2207
- // 350ms cadence, so it is long — and it is rarely what decides.
2208
- //
2209
- // One quiet window had to serve both jobs before this, which is why it could not be
2210
- // shortened: the timer deciding responsiveness was the same one that had to survive
2211
- // the cadence.
1916
+ // A travelling pointer rearranges nothing; the slot opens when it comes to
1917
+ // REST. A dragover at an unchanged position is POSITIVE evidence of a stop
1918
+ // and commits after the short REORDER_STILL_MS; the absence of events is only
1919
+ // a fallback that must out-wait the drag loop's 350 ms cadence.
2212
1920
  function trackRest(clientX: number): void {
2213
1921
  const dragged = draggingEl;
2214
1922
  if (!dragged) {
2215
1923
  return;
2216
1924
  }
2217
- const now = Date.now();
1925
+ const now = win.Date.now();
2218
1926
  const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
2219
1927
  restX = clientX;
2220
1928
  if (moved) {
2221
1929
  restMovedAt = now;
2222
1930
  }
2223
- // The candidate is recomputed from THIS event, every time, and the commit below
2224
- // uses that value rather than anything stored. That is the fix for a real
2225
- // unreliability, so it must not be refactored back into a pending-target field:
2226
- //
2227
- // There used to be a `restBefore` holding the pending slot, and both the
2228
- // already-there branch and the leave-the-strip paths nulled it. So a drag that
2229
- // passed through "already there" on its way somewhere else lost the pending
2230
- // state, and the stillness branch below — which then early-returned because no
2231
- // timer was armed — committed NOTHING when the pointer stopped. The tab stayed on
2232
- // its previous slot until the user jiggled the mouse to re-arm, which is exactly
2233
- // the reported "it does not let go of the old drop spot". It hit leftward drags
2234
- // hardest, because after each commit the dragged chip sits immediately left of
2235
- // where the pointer is heading, so `nextElementSibling` matches constantly.
1931
+ // Recomputed from THIS event, never a stored pending target: a stored one
1932
+ // was nulled by the already-there branch on the way past, so a stop after it
1933
+ // committed NOTHING until the mouse was jiggled.
2236
1934
  const before = dropTargetBefore(clientX);
2237
- // Already the slot it would land in, so there is nothing to commit. Only the net
2238
- // is dropped; there is no pending target left to lose.
2239
1935
  if (before === dragged.nextElementSibling) {
2240
1936
  endRestNet();
2241
1937
  return;
2242
1938
  }
2243
- // A dragover at an UNCHANGED position is positive evidence that the pointer has
2244
- // stopped, which is a far stronger signal than the absence of events — and it is
2245
- // what lets the slot open promptly instead of out-waiting the drag loop's 350ms
2246
- // cadence. The elapsed check filters the coincidence where one event of a sweep
2247
- // lands within REORDER_MOVE_EPS_PX of the previous (a reversal, or a frame whose
2248
- // motion was almost all vertical); a real stop clears it on the next event.
1939
+ // The elapsed check filters one event of a sweep landing within the epsilon
1940
+ // of the previous (a reversal, or a mostly vertical frame).
2249
1941
  if (!moved && now - restMovedAt >= REORDER_STILL_MS) {
2250
1942
  endRestNet();
2251
1943
  commitSlot(before);
@@ -2253,65 +1945,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2253
1945
  }
2254
1946
  armRestNet(before);
2255
1947
  }
2256
- // armRestNet is the no-events fallback: if dragover stops arriving altogether, no
2257
- // stationary event will ever confirm the stop, so this commits anyway. It carries
2258
- // its own target for the same reason trackRest recomputes one — a net whose target
2259
- // lived in shared mutable state was how the old pending slot got lost. Sized to
2260
- // out-wait the cadence (see REORDER_REST_MS) because it is the one timer a fast
2261
- // sweep could otherwise expire between two of its own events.
1948
+ // The no-events fallback for a browser that stops delivering dragover; it
1949
+ // carries its own target for the reason trackRest recomputes one.
2262
1950
  function armRestNet(before: HTMLElement | null): void {
2263
1951
  endRestNet();
2264
- restTimer = setTimeout(() => {
1952
+ restTimer = win.setTimeout(() => {
2265
1953
  restTimer = null;
2266
1954
  commitSlot(before);
2267
1955
  }, REORDER_REST_MS);
2268
1956
  }
2269
1957
  function endRestNet(): void {
2270
1958
  if (restTimer !== null) {
2271
- clearTimeout(restTimer);
1959
+ win.clearTimeout(restTimer);
2272
1960
  restTimer = null;
2273
1961
  }
2274
1962
  }
2275
1963
 
2276
- // flipTo runs a rearranging mutation and animates its result: every chip that
2277
- // ends up somewhere new slides there from where it was. One function for both
2278
- // directions of the preview — committing a slot, and reverting the whole
2279
- // gesture — because "the strip rearranged, show the rearrangement" is one job,
2280
- // and a revert that snapped while a commit slid would read as two different
2281
- // features.
2282
- //
2283
- // `hold` is the one chip that must NOT slide. On a commit that is the dragged
2284
- // chip: it is the slot, the pointer is already carrying a solid copy of it,
2285
- // and a hole travelling across the strip alongside that copy is two things
2286
- // moving at once. On a revert nothing is held — the drag image is gone by
2287
- // then, so the chip has to travel home itself.
1964
+ // FLIP a rearranging mutation so every chip that ends up somewhere new slides
1965
+ // there. `hold` is the one chip that must NOT slide: on a commit the dragged
1966
+ // chip, since the pointer already carries a solid copy of it.
2288
1967
  function flipTo(mutate: () => void, hold: HTMLElement | null): void {
2289
- // Reduced motion: perform the rearrangement and animate none of it. The gate
2290
- // belongs HERE and not only in the CSS, because these transitions are written
2291
- // inline and no stylesheet gate can reach them; the switcher's release reel
2292
- // guards its own call site the same way. Read live, so toggling the OS setting
2293
- // mid-drag takes effect on the very next commit.
1968
+ // The gate belongs HERE and not only in the CSS: these transitions are
1969
+ // written inline, and no stylesheet gate can reach them.
2294
1970
  if (prefersReduce()) {
2295
1971
  endShift();
2296
1972
  mutate();
2297
1973
  return;
2298
1974
  }
2299
- // FIRST — where each chip is right now, MID-SLIDE INCLUDED, so a second commit
2300
- // continues from wherever the first one got to instead of jumping back through
2301
- // rest first. The switcher's release reel captures its live swipe preview the
2302
- // same way, and for the same reason. Rects, not layout offsets: this is VISUAL
2303
- // position, which is what the animation interpolates (hit-testing wants the
2304
- // opposite, see dropTargetBefore).
1975
+ // Where each chip is right now, MID-SLIDE INCLUDED, so a second commit
1976
+ // continues from wherever the first got to. Rects, because this is VISUAL
1977
+ // position; hit-testing wants layout offsets instead.
2305
1978
  const first = new Map<HTMLElement, number>();
2306
1979
  for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
2307
1980
  if (el !== hold) {
2308
1981
  first.set(el, el.getBoundingClientRect().left);
2309
1982
  }
2310
1983
  }
2311
- endShift(); // back to the resting layout before the DOM moves
1984
+ endShift();
2312
1985
  mutate();
2313
- // LAST — the new resting layout. No style is written between the reads below,
2314
- // so they are all served from one layout pass.
2315
1986
  const invert = new Map<HTMLElement, number>();
2316
1987
  for (const [el, was] of first) {
2317
1988
  const dx = el.isConnected ? was - el.getBoundingClientRect().left : 0;
@@ -2320,53 +1991,36 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2320
1991
  }
2321
1992
  }
2322
1993
  if (invert.size === 0) {
2323
- return; // nothing moved on screen (every box measured zero)
1994
+ return;
2324
1995
  }
2325
1996
  applyShift(invert, "none");
2326
- // The read forces the reflow that COMMITS the from-state. Without it the
2327
- // browser is free to collapse the from- and to-writes into one recalc and
2328
- // no transition runs at all — the exact trap the reel documents.
1997
+ // The read forces the reflow that COMMITS the from-state; without it the two
1998
+ // writes collapse into one recalc and no transition runs.
2329
1999
  scroller.getBoundingClientRect();
2330
2000
  const rest = new Map<HTMLElement, number>();
2331
2001
  for (const el of invert.keys()) {
2332
2002
  rest.set(el, 0);
2333
2003
  }
2334
2004
  applyShift(rest, REORDER_SHIFT_TRANS);
2335
- shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
2005
+ shiftTimer = win.setTimeout(endShift, REORDER_SETTLE_MS);
2336
2006
  }
2337
2007
 
2338
- // commitSlot performs the reorder the hold earned: one DOM move, the slide
2339
- // that shows it, and the slot fading in at its new home.
2340
- //
2341
- // It moves the DOM and NOT tabList. That split is what makes the preview a
2342
- // preview: tabList stays the arrangement the gesture started from for the
2343
- // whole drag, and only a drop writes it (syncOrderFromDom). Two things fall
2344
- // out of it for free — a cancel is just "re-project tabList" (revertPreview),
2345
- // and a syncChrome arriving mid-drag from an unrelated source (a status tick,
2346
- // an OSC title) renders the committed order rather than flickering through
2347
- // whatever the pointer is hovering.
2348
- //
2349
- // The caller nulls `restTimer` BEFORE calling in (both the rest timer and
2350
- // flushRest do), so `cancelRest` is already a no-op by the time control arrives
2351
- // here: an early return must leave nothing behind that only cancelRest would
2352
- // Nothing exists pre-commit for it to have cleared, and a slide already in flight
2353
- // is owned by its own settle timer.
2008
+ // Moves the DOM and NOT tabList, which is what makes the preview a preview:
2009
+ // only a drop writes tabList, so a cancel is a re-projection and a syncChrome
2010
+ // arriving mid-drag renders the committed order.
2354
2011
  function commitSlot(before: HTMLElement | null): void {
2355
2012
  const dragged = draggingEl;
2356
2013
  if (!dragged?.isConnected) {
2357
- return; // the dragged session was closed elsewhere mid-gesture
2358
- }
2359
- // The pending slot was chosen a rest window ago, and a session closed in another
2360
- // window (an SSE push, or the poll reconcile) removes chips through dropTab while
2361
- // this gesture is still open — so the reference node may be gone by now, and
2362
- // insertBefore throws NotFoundError on a reference that is no longer a
2363
- // child, which would abandon the drop mid-way and leave DOM order and tabList
2364
- // disagreeing. null stays legal: it means "past the last chip".
2014
+ return;
2015
+ }
2016
+ // A session closed elsewhere removes chips while the gesture is open, and
2017
+ // insertBefore throws on a reference that is no longer a child. null stays
2018
+ // legal: past the last chip.
2365
2019
  if (before !== null && before.parentNode !== scroller) {
2366
2020
  return;
2367
2021
  }
2368
2022
  if (before === dragged || before === dragged.nextElementSibling) {
2369
- return; // already in that slot
2023
+ return;
2370
2024
  }
2371
2025
  flipTo(() => {
2372
2026
  scroller.insertBefore(dragged, before);
@@ -2375,21 +2029,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2375
2029
  announceTarget(dragged);
2376
2030
  }
2377
2031
 
2378
- // revertPreview puts the strip back the way the gesture found it, and is what
2379
- // makes Escape mean cancel.
2380
- //
2381
- // The old reorder had no revert path at all: dragover moved the chip on every
2382
- // event and dragend committed whatever the strip happened to be showing, so an
2383
- // abandoned drag left the tabs rearranged and the only way back was to drag
2384
- // them again. Reverting needs no saved snapshot, because tabList IS the
2385
- // snapshot — nothing but a drop writes to it — so the original arrangement is
2386
- // recovered by re-projecting it, the same projection moveTab uses.
2032
+ // Put the strip back the way the gesture found it. No saved snapshot is
2033
+ // needed: nothing but a drop writes tabList, so it IS the snapshot.
2387
2034
  function revertPreview(): void {
2388
2035
  const chips = [...scroller.querySelectorAll<HTMLElement>(".wt-tab")];
2389
2036
  const untouched =
2390
2037
  chips.length === tabList.length && chips.every((el, i) => tabList[i]?.el === el);
2391
2038
  if (untouched) {
2392
- return; // no slot ever committed; there is nothing to put back
2039
+ return;
2393
2040
  }
2394
2041
  flipTo(() => {
2395
2042
  for (const t of tabList) {
@@ -2399,42 +2046,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2399
2046
  ctx.announce("Move cancelled");
2400
2047
  }
2401
2048
 
2402
- // flashSlot restarts the slot's fade at its new home. Removing and re-adding
2403
- // the class is the restart (re-adding a class an element already carries
2404
- // restarts nothing), and the read between them is what flushes the removal to
2405
- // style so the two writes are not collapsed into one. It owns that read rather
2406
- // than depending on a caller's, so a commit can call it either side of the
2407
- // FLIP's own reflow.
2049
+ // Re-adding a class an element already carries restarts nothing, and the
2050
+ // layout read between the two writes is what keeps them from collapsing.
2408
2051
  function flashSlot(el: HTMLElement): void {
2409
2052
  endSlotFade();
2410
2053
  el.classList.remove("wt-tab-slotted");
2411
2054
  el.getBoundingClientRect();
2412
2055
  el.classList.add("wt-tab-slotted");
2413
2056
  slotFadeEl = el;
2414
- slotFadeTimer = setTimeout(() => {
2057
+ slotFadeTimer = win.setTimeout(() => {
2415
2058
  slotFadeTimer = null;
2416
2059
  endSlotFade();
2417
2060
  }, REORDER_SLOT_FADE_MS);
2418
2061
  }
2419
2062
  function endSlotFade(): void {
2420
2063
  if (slotFadeTimer !== null) {
2421
- clearTimeout(slotFadeTimer);
2064
+ win.clearTimeout(slotFadeTimer);
2422
2065
  slotFadeTimer = null;
2423
2066
  }
2424
2067
  slotFadeEl?.classList.remove("wt-tab-slotted");
2425
2068
  slotFadeEl = null;
2426
2069
  }
2427
2070
 
2428
- // The two things a reorder can say, and they are deliberately different
2429
- // sentences. commitSlot moves the DOM but NOT tabList, so a committed slot is a
2430
- // PREVIEW that Escape can still undo: announcing it as "Moved X to position 3"
2431
- // told a screen-reader user that a reversible hover state was a finished action,
2432
- // three times over on a drag that dwelt in three places, sometimes followed by
2433
- // "Move cancelled" contradicting all of it. So the preview announces a TARGET and
2434
- // only the drop announces a move.
2435
- //
2436
- // The position is read from the DOM because the DOM is what the preview moved;
2437
- // tabList still holds the order the gesture started from.
2071
+ // A committed slot is a PREVIEW Escape can still undo, so it announces a
2072
+ // TARGET and only the drop announces a move; announcing "Moved" three times
2073
+ // and then "Move cancelled" told a screen-reader user a hover was an action.
2074
+ // The position is read from the DOM, which is what the preview moved.
2438
2075
  function slotPosition(el: HTMLElement): number {
2439
2076
  return [...scroller.querySelectorAll<HTMLElement>(".wt-tab")].indexOf(el) + 1;
2440
2077
  }
@@ -2444,9 +2081,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2444
2081
  ctx.announce(`Drop position ${String(at)}`);
2445
2082
  }
2446
2083
  }
2447
- // announceMoved is the completed move, on drop. The drag path announced nothing
2448
- // at all before this, while the menu's moveTab announced every move — the same
2449
- // reorder, one of them silent to anyone who cannot see the strip rearrange.
2450
2084
  function announceMoved(el: HTMLElement): void {
2451
2085
  const tab = tabList.find((t) => t.el === el);
2452
2086
  const at = slotPosition(el);
@@ -2455,36 +2089,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2455
2089
  }
2456
2090
  }
2457
2091
 
2458
- // endReorderPreview ends the GESTURE's state: the pending rest and the slot's
2459
- // fade.
2460
- //
2461
- // It deliberately does NOT stop a slide that is already playing. A slide is an
2462
- // animation with its own settle timer, and cutting it here is exactly what
2463
- // would make a revert snap home instead of run — dragend fires immediately
2464
- // after the revert starts. dragstart and teardown add an explicit endShift()
2465
- // for the two cases where nothing may be left in flight: a fresh gesture must
2466
- // start from rest, and a torn-down feature may leave no inline style on an
2467
- // element it no longer owns.
2092
+ // Ends the GESTURE's state but NOT a slide already playing: dragend fires
2093
+ // right after a revert starts, and cutting the slide here would make the
2094
+ // revert snap home instead of run. dragstart and teardown call endShift().
2468
2095
  function endReorderPreview(): void {
2469
2096
  endRestNet();
2470
2097
  restX = null;
2471
2098
  endSlotFade();
2472
2099
  }
2473
2100
 
2474
- // abortReorderFor is the one path that cannot wait for dragend, called by every
2475
- // site that REMOVES a chip while a drag may be open (dropTab and the bulk-close
2476
- // sweep). Two removals matter, for different reasons:
2477
- //
2478
- // - the pending reference chip: commitSlot would hand a detached node to
2479
- // insertBefore. It guards that too, but withdrawing the pending slot here means
2480
- // a fresh dragover arms against a chip that still exists rather than waiting out
2481
- // a rest window against one that does not.
2482
- // - the DRAGGED chip: the gesture is over and there is no source left to deliver
2483
- // a dragend. A browser is not obliged to fire one for a removed source, and
2484
- // without this the feature would keep `draggingEl` set to a detached node for
2485
- // the rest of its life — which the document-level guard reads as "a tab drag is
2486
- // in progress" and so would preventDefault every unrelated file or text drop on
2487
- // the page from then on.
2101
+ // For every site that REMOVES a chip while a drag may be open. A browser is
2102
+ // not obliged to fire dragend for a removed source, and a `draggingEl` left
2103
+ // pointing at a detached node would make the document-level guard
2104
+ // preventDefault every unrelated file or text drop from then on.
2488
2105
  function abortReorderFor(removed: HTMLElement): void {
2489
2106
  if (removed !== draggingEl) {
2490
2107
  return;
@@ -2515,33 +2132,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2515
2132
  }
2516
2133
  }
2517
2134
 
2518
- // publishOrder sends the strip's arrangement to the server, which is the
2519
- // write half of tab-order sync: the order belongs to the session SET, so a
2520
- // drag here has to reach the other devices watching the same server.
2521
- //
2522
- // Called only from the two paths that COMMIT a reorder (a finished drag, and
2523
- // Move left/right), never from the drag preview, which repaints the DOM many
2524
- // times per gesture. The local strip is already showing the new arrangement,
2525
- // so this is optimistic; the server echoes the same order back and
2526
- // applyServerOrder then has nothing to do.
2527
- //
2528
- // Best-effort by design, with one exception. A 409 means the server's
2529
- // session set is not the one just sent — this client has not yet seen a
2530
- // session created or closed elsewhere — and the answer is to take the
2531
- // server's word: reconcileOnce adopts what it missed, and the arrangement
2532
- // that survives is the server's. Re-sending would be a fight this client
2533
- // cannot win, since its list is the stale one. Any other failure leaves the
2534
- // strip as the user arranged it for this page and lets the next reorder try
2535
- // again; a toast for a cosmetic write the user can simply repeat would be
2536
- // noise, and the arrangement is not lost data.
2537
- //
2538
- // It renumbers the tabs BEFORE sending, and that is not bookkeeping: every
2539
- // tab still carries the position the server gave it, so the applyServerOrder
2540
- // pass inside the caller's syncChrome would sort the strip straight back and
2541
- // the tab would snap to where it started. Adopting the new positions locally
2542
- // is the optimistic half of the write — the server echoes these same numbers
2543
- // and the echo then lands as a no-op. Against a server with no order route
2544
- // the numbers simply stay local, which is the old per-page behaviour.
2135
+ // The write half of tab-order sync, called only from the paths that COMMIT
2136
+ // a reorder. It renumbers BEFORE sending: every tab still carries the
2137
+ // server's old position, and the caller's applyServerOrder pass would sort
2138
+ // the strip straight back. A 409 means this client's list is stale, so the
2139
+ // server's word is taken; any other failure leaves the arrangement local.
2545
2140
  function publishOrder(): void {
2546
2141
  if (tabList.length === 0) {
2547
2142
  return;
@@ -2550,28 +2145,22 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2550
2145
  tab.order = i;
2551
2146
  });
2552
2147
  if (!started) {
2553
- return; // the bootstrap is still placing tabs; nothing to publish yet
2148
+ return;
2554
2149
  }
2555
2150
  const ids = tabList.map((t) => t.id);
2556
2151
  void api.setOrder(ids).catch((err: unknown) => {
2557
- if (err instanceof SessionAPIError && err.status === 409) {
2152
+ if (!tornDown() && err instanceof SessionAPIError && err.status === 409) {
2558
2153
  void reconcileOnce();
2559
2154
  }
2560
2155
  });
2561
2156
  }
2562
2157
 
2563
- // --- Drag preview (the ghost) ---
2564
- // WebKit renders NO automatic drag image for an element whose ancestor
2565
- // carries a filter or transform (webkit.org/b/22787) — and the strip's own
2566
- // backdrop-filter is exactly such an ancestor, so on iPadOS the preview
2567
- // came out as broken white geometry instead of the chip (desktop Chrome,
2568
- // which snapshots the element regardless, always looked right). An EXPLICIT
2569
- // drag image fixes it: a clone parked directly under .wt-root, outside the
2570
- // bar's filtered subtree yet still inside the styling boundary so the
2571
- // scoped .wt-tab rules paint it. It is laid exactly over the real chip, so
2572
- // it is indistinguishable from it in the unlikely event it ever paints, and
2573
- // dropped on the next frame — the browser has already snapshotted it by
2574
- // then (the snapshot is taken when the dragstart handler returns).
2158
+ // WebKit renders NO automatic drag image under an ancestor with a filter or
2159
+ // transform (webkit.org/b/22787), and the strip's backdrop-filter is one, so
2160
+ // the preview came out as broken white geometry on iPadOS. The clone is
2161
+ // parked under .wt-root, outside the filtered subtree but inside the styling
2162
+ // boundary, laid exactly over the chip, and dropped on the next frame, by
2163
+ // which time the browser has snapshotted it.
2575
2164
  let dragGhost: HTMLElement | null = null;
2576
2165
  function clearDragGhost(): void {
2577
2166
  dragGhost?.remove();
@@ -2598,20 +2187,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2598
2187
  // relative to the event TARGET, which is the label or the close button
2599
2188
  // when the drag starts on one of them.
2600
2189
  e.dataTransfer?.setDragImage(ghost, e.clientX - rect.left, e.clientY - rect.top);
2601
- requestAnimationFrame(clearDragGhost);
2190
+ frame(clearDragGhost);
2602
2191
  }
2603
2192
 
2604
- // moveTab shifts a tab one slot left or right in tabList and re-appends
2605
- // every tab element in list order so the DOM matches.
2606
- // It is the single-pointer / non-drag alternative to the drag-and-drop
2607
- // reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
2608
- // context menu.
2193
+ // The non-drag alternative to the drag-and-drop reorder (WCAG 2.5.7).
2609
2194
  function moveTab(id: string, delta: -1 | 1): void {
2610
- // Reordering re-appends every chip, and reparenting the focused field blurs
2611
- // it — so an open edit would be committed as a side effect of moving some
2612
- // OTHER tab, via a blur the user never performed. Resolve it here instead,
2613
- // so the outcome is chosen rather than emergent: a blur that arrives as a
2614
- // side effect of reparenting is not a decision anyone made.
2195
+ // Re-appending every chip reparents a focused field, which blurs it, so an
2196
+ // open edit would be committed by a blur the user never performed.
2615
2197
  resolveEdit("blur", false);
2616
2198
  const from = tabList.findIndex((t) => t.id === id);
2617
2199
  const to = from + delta;
@@ -2631,65 +2213,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2631
2213
  ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
2632
2214
  }
2633
2215
 
2634
- // adoptSession adds a tab for a session that exists server-side but has no
2635
- // local tab yet, e.g. one created in another browser (the server pushes it
2636
- // over the status SSE, and the poll fallback lists it). This keeps every
2637
- // client's tab set converged on the server, so mobile and desktop never
2638
- // desync. The caller runs syncChrome.
2216
+ // Add a tab for a session that exists server-side but has no local tab yet
2217
+ // (one created in another browser), so every client's tab set converges on
2218
+ // the server. The caller runs syncChrome.
2639
2219
  function adoptSession(info: SessionInfo): void {
2640
2220
  if (tabList.find((t) => t.id === info.id)) {
2641
2221
  return;
2642
2222
  }
2643
2223
  if (tombstones.active(info.id)) {
2644
- return; // just closed here; ignore a stale listing until the server reaps it
2224
+ return;
2645
2225
  }
2646
2226
  const tab = addTabChrome(info);
2647
- // Place the tab where the SERVER's shared order says it goes, not where it
2648
- // happened to arrive: the status-stream snapshot and the bootstrap's
2649
- // GET /api/sessions race each other, so arrival order is neither source's
2650
- // order and is not stable between loads. Against a server that keeps no
2651
- // order the same call falls back to age, which is creation order. See
2652
- // compareTabOrder.
2227
+ // Placed by the SERVER's order, not by arrival: the stream's snapshot and
2228
+ // the bootstrap's list race, so arrival order is not stable between loads.
2653
2229
  const at = orderedInsertIndex(tabList, info);
2654
2230
  tabList.splice(at, 0, tab);
2655
- // addTabChrome appended the chip to the scroller's end; move it to match
2656
- // the list when the arrangement put it earlier, so the DOM, the switcher
2657
- // rows, and the position announcements all read the same order.
2231
+ // addTabChrome appended the chip at the end; the DOM must read in list order.
2658
2232
  const after = tabList[at + 1];
2659
2233
  if (after) {
2660
2234
  scroller.insertBefore(tab.el, after.el);
2661
2235
  }
2662
2236
  }
2663
2237
 
2664
- // Activate the first tab when nothing is active. The bootstrap normally
2665
- // activates a tab, but if the initial apiList AND apiCreate both fail at
2666
- // load its `if (startTab)` activation is skipped, leaving activeId null
2667
- // and connectionInitiated false -- so the kernel never opens the terminal
2668
- // WS and its wake handlers no-op. When the server recovers, the status
2669
- // stream / poll adopt the existing sessions below; without this they would
2670
- // render inert (blank, never connecting) until the user taps a tab. This
2671
- // is the sibling of the '+'-retry recovery the bootstrap already handles.
2672
- // A live tab outranks an ENDED one (its dot status is fed by the same
2673
- // SSE/poll that adopted it); a corpse is only auto-activated when nothing
2674
- // else exists. "Ended" covers both ways a process goes (exited and
2675
- // crashed) — a crashed session is exactly as unable to produce output as a
2676
- // cleanly exited one, so activating it would wedge the page the same way.
2238
+ // The runtime repair for a bootstrap whose list AND create both failed: the
2239
+ // sessions the stream later adopts would otherwise render inert until the
2240
+ // user taps a tab. Gated on that one case, so a pane the closing rules
2241
+ // emptied is never refilled by a status event. A live tab outranks an ENDED
2242
+ // one, which would wedge the page the same way.
2677
2243
  function ensureActive(): void {
2678
- if (activeId !== null) {
2679
- return;
2680
- }
2681
- // ...but only once the bootstrap has adopted its list. Before that the
2682
- // view is PARTIAL, and deterministically so: tabs subscribes to the status
2683
- // stream during setup, before resolveInitialSession issues its
2684
- // GET /api/sessions, so the SSE snapshot's one-event-per-session arrives
2685
- // first and this ran on a strip holding only whichever session the server
2686
- // sent first. Activating it both picked a tab on incomplete information and
2687
- // silently disabled the saved-tab restore: the bootstrap ladder's first act
2688
- // is to return early when activeId is already set, so localStorage was
2689
- // never read and every reload landed on the oldest tab. Boot activation
2690
- // belongs to that ladder alone, which is the only site with the whole list
2691
- // AND the saved id; this stays the runtime repair it was written to be.
2692
- if (!started) {
2244
+ if (!bootShowedNothing || anyShown()) {
2693
2245
  return;
2694
2246
  }
2695
2247
  const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
@@ -2698,37 +2250,37 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2698
2250
  }
2699
2251
  }
2700
2252
 
2701
- // create is the coalesced door: while one create is in flight, every caller
2702
- // asking for another gets THAT one, so a single gesture can only ever open
2703
- // one terminal. Spawning a session is expensive and non-idempotent, and one
2704
- // press does not reliably arrive as one activation — the web-terminal-kiro
2705
- // server logged two POST /api/sessions 0-3ms apart (three times in one
2706
- // afternoon, both answered 201) for single "+" taps from an iPad + Magic
2707
- // Keyboard, so the user got two terminals and had to close one. Sharing the
2708
- // in-flight promise needs no threshold to make that decision, unlike a
2709
- // click-level debounce: a duplicate activation lands while the POST is still
2710
- // open and collapses into it, while a deliberate second tap (the POST
2711
- // resolves in milliseconds) still opens a second terminal. It also stops "+"
2712
- // mashing from queueing sessions across the server's 503 install window,
2713
- // where createSessionHonouringRetry legitimately waits minutes.
2253
+ // One press does not reliably arrive as one activation (two POSTs 0-3 ms
2254
+ // apart from a single "+" tap were measured), and sharing the in-flight
2255
+ // promise needs no threshold: a duplicate lands while the POST is open and
2256
+ // collapses into it, while a deliberate second tap still opens a second
2257
+ // terminal. It also stops "+" mashing from queueing sessions across the
2258
+ // server's retry window. The coalesced second caller's target is ignored.
2714
2259
  let creating: Promise<void> | null = null;
2715
- function create(): Promise<void> {
2716
- creating ??= openNewSession().finally(() => {
2260
+ function create(target?: PaneSide): Promise<void> {
2261
+ creating ??= openNewSession(target).finally(() => {
2717
2262
  creating = null;
2718
2263
  });
2719
2264
  return creating;
2720
2265
  }
2721
2266
 
2722
- async function openNewSession(): Promise<void> {
2267
+ async function openNewSession(target?: PaneSide): Promise<void> {
2268
+ // The handle faces the pane the new tab is headed for from the start,
2269
+ // not from the POST's answer.
2270
+ if (target !== undefined && ctx.shell.split.isOpen() && !anyShown()) {
2271
+ ctx.shell.select(target);
2272
+ }
2723
2273
  let info: SessionInfo;
2724
2274
  try {
2725
- info = await createSessionHonouringRetry(api, ctx, () => tornDown);
2275
+ info = await createSessionHonouringRetry(api, ctx, lifetime.signal);
2726
2276
  } catch (err) {
2277
+ if (tornDown()) {
2278
+ return;
2279
+ }
2727
2280
  // A create can still fail transiently (network, server error); tell
2728
2281
  // the user rather than throwing. A 503 has already been retried on the
2729
2282
  // server's own schedule by this point, so reaching here means it never
2730
- // became ready -- say so with the server's own words when it gave any,
2731
- // rather than the generic message that used to cover both cases.
2283
+ // became ready: say so with the server's own words when it gave any.
2732
2284
  ctx.toast(
2733
2285
  err instanceof SessionAPIError && err.serverMessage !== undefined
2734
2286
  ? `Couldn't open a terminal: ${err.serverMessage}`
@@ -2744,17 +2296,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2744
2296
  tabList.push(tab);
2745
2297
  }
2746
2298
  // Suppress the swipe reel for a create: the list should grow and fade the
2747
- // new row in (animateRowIn) rather than rotate. switchTo still slides the
2748
- // terminal content and updates the active chip.
2299
+ // new row in (animateRowIn) rather than rotate. showIn still slides the
2300
+ // terminal content and updates the shown chip.
2749
2301
  creatingTab = true;
2750
- switchTo(tab.id);
2302
+ // The asked-for side holds only while that pane is still empty: the split
2303
+ // may have closed or the pane been filled during the round trip.
2304
+ const side =
2305
+ target !== undefined && ctx.shell.pane(target)?.state() === "empty"
2306
+ ? target
2307
+ : ctx.shell.targetFor(tab.id);
2308
+ showIn(side, tab.id);
2751
2309
  creatingTab = false;
2752
2310
  }
2753
2311
 
2754
- // dropTab removes a tab's chrome + cache and re-homes the active session.
2755
- // remote=true also DELETEs the server session (a user close); remote=false
2756
- // is a local drop for a session the server already ended (an SSE removed
2757
- // event or a poll that no longer lists it), so no redundant DELETE is sent.
2312
+ // dropTab removes a tab's chrome + cache and, for a shown tab, decides what
2313
+ // its pane shows next. remote=true also DELETEs the server session (a user
2314
+ // close); remote=false is a local drop for a session the server already
2315
+ // ended (an SSE removed event or a poll that no longer lists it), so no
2316
+ // redundant DELETE is sent.
2758
2317
  async function dropTab(id: string, remote: boolean): Promise<void> {
2759
2318
  const idx = tabList.findIndex((t) => t.id === id);
2760
2319
  if (idx < 0) {
@@ -2762,24 +2321,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2762
2321
  }
2763
2322
  // Closing the only remaining tab: spawn its replacement BEFORE removing
2764
2323
  // this one, so the strip never empties and the "+" never teleports to the
2765
- // far left (then jumps back) while the create POST is in flight. create()
2766
- // adds the new tab before the "+" and switches to it; dropping the old
2767
- // one is then an ordinary non-last close (length > 1, so this intercept
2768
- // does not re-fire -> no unbounded recursion). remote-only (a user
2769
- // close). If create() fails it adds nothing (and toasts), so keep the
2770
- // existing tab rather than stranding the user on an empty strip.
2324
+ // far left while the create POST is in flight. The replacement lands where
2325
+ // a click would (the other pane, while the split is open), and dropping
2326
+ // the old tab is then an ordinary non-last close, so this intercept does
2327
+ // not re-fire. A user close only.
2771
2328
  if (remote && tabList.length === 1 && tabList[0]?.id === id) {
2772
2329
  await create();
2773
- // Drop the old tab only if a replacement actually landed (create()
2774
- // adds nothing and toasts on failure); otherwise keep the existing tab
2775
- // rather than stranding the user on an empty strip. The replacement is
2776
- // a different session, so dropping the old one is a non-last close and
2777
- // this intercept does not re-fire.
2330
+ // A failed create adds nothing (and toasts): keep the existing tab
2331
+ // rather than stranding the user on an empty strip.
2778
2332
  if (tabList.some((t) => t.id !== id)) {
2779
2333
  await dropTab(id, true);
2780
2334
  }
2781
2335
  return;
2782
2336
  }
2337
+ // Which pane shows the tab decides the transaction below; read before
2338
+ // anything moves.
2339
+ const side = sideOf(id);
2340
+ const splitOpen = ctx.shell.split.isOpen();
2783
2341
  const [tab] = tabList.splice(idx, 1);
2784
2342
  if (!tab) {
2785
2343
  return;
@@ -2800,7 +2358,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2800
2358
  // sit in storage forever.
2801
2359
  acknowledgeSwitchNotify(id);
2802
2360
  forgetCueSeen(id);
2803
- notifier.forget(id); // no more notifications can arrive for a gone session
2361
+ ctx.shell.notifications.forget(id);
2804
2362
  tab.aria.remove();
2805
2363
  // Remove immediately (no exit animation): a lingering element made the
2806
2364
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -2810,107 +2368,137 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2810
2368
  // before, and both stop existing on the next line.
2811
2369
  abortReorderFor(tab.el);
2812
2370
  tab.el.remove();
2813
- ctx.dropSession(id);
2814
- // If this close empties the expanded list (only one tab remains),
2815
- // collapse the switcher FIRST so the whole tray animates shut with the
2816
- // last row still in it — otherwise it sits open-but-empty with the
2817
- // separator shown and the keyboard button hidden. Collapsing flips
2818
- // `expanded` off, so the syncChrome (and any re-home switchTo) below skip
2819
- // the list reconcile and leave the row for the collapse to sweep away
2820
- // (clearRows runs after the collapse transition).
2371
+ // Each connection forgets the session exactly once: the showing pane's
2372
+ // through clearActiveSession (which closes its socket and empties the
2373
+ // pane), every other through dropSessionExcept.
2374
+ if (side !== null && splitOpen) {
2375
+ ctx.shell.pane(side)?.clearActiveSession();
2376
+ ctx.shell.dropSessionExcept(id, side);
2377
+ } else {
2378
+ ctx.dropSession(id);
2379
+ }
2380
+ // A close that empties the expanded list collapses the switcher FIRST, so
2381
+ // the tray animates shut with the last row still in it; `expanded` going off
2382
+ // makes the syncChrome below leave that row for the collapse to sweep away.
2821
2383
  if (expanded && tabList.length < 2) {
2822
2384
  collapseSwitcher();
2823
2385
  }
2824
2386
  syncChrome(); // reflect the drop immediately (count, position)
2825
- if (remote) {
2826
- try {
2827
- await api.close(id);
2828
- } catch {
2829
- ctx.toast("Couldn't close the terminal on the server");
2830
- }
2831
- }
2832
- if (activeId === id) {
2833
- // Switch to a neighbor, or spawn a fresh session if this was the last.
2387
+ // What the panes show next is decided here, before the server's answer:
2388
+ // selection must not sit on an emptied pane for a DELETE round trip.
2389
+ let replacement: Promise<void> | undefined;
2390
+ if (side !== null && !splitOpen) {
2391
+ // One pane: switch to a neighbor, or spawn a fresh session if this was
2392
+ // the last.
2834
2393
  const neighbor = tabList[idx] ?? tabList[idx - 1];
2835
- activeId = null;
2836
2394
  if (neighbor) {
2837
2395
  switchTo(neighbor.id);
2838
2396
  } else {
2839
- await create();
2397
+ replacement = create();
2398
+ }
2399
+ } else if (side !== null) {
2400
+ // While the split is open the pane stays empty and a new tab lands in
2401
+ // the other pane, so a close never promotes a tab nobody clicked.
2402
+ const other = otherSide(side);
2403
+ if (shownIn(other) !== null) {
2404
+ ctx.shell.select(other);
2405
+ focusAfterSwitch();
2406
+ } else if (!anyShown()) {
2407
+ replacement = create(other);
2840
2408
  }
2409
+ scheduleLayoutWrite();
2841
2410
  }
2411
+ if (remote) {
2412
+ try {
2413
+ await api.close(id);
2414
+ } catch {
2415
+ if (!tornDown()) {
2416
+ ctx.toast("Couldn't close the terminal on the server");
2417
+ }
2418
+ }
2419
+ }
2420
+ await replacement;
2842
2421
  }
2843
2422
 
2844
2423
  async function close_(id: string): Promise<void> {
2845
2424
  await dropTab(id, true);
2846
2425
  }
2847
2426
 
2848
- // closeMany closes a set of tabs at once. Each tab is a running agent, so
2849
- // it confirms when closing two or more. It removes their chrome + cache,
2850
- // re-homes the active tab to a survivor if it was among them, DELETEs each
2851
- // server session, and guarantees at least one terminal stays open (a fresh
2852
- // one is spawned only when every tab was closed). closeAll / closeOthers /
2853
- // closeToRight / closeToLeft are thin wrappers that pick the id set.
2427
+ // Each tab is a running agent, so closing two or more asks first. This path
2428
+ // drops tabs itself, so every per-tab state dropTab releases is released here.
2854
2429
  async function closeMany(ids: readonly string[]): Promise<void> {
2855
2430
  const victims = tabList.filter((t) => ids.includes(t.id));
2856
2431
  if (victims.length === 0) {
2857
2432
  return;
2858
2433
  }
2859
- if (victims.length >= 2 && !window.confirm(`Close ${String(victims.length)} terminals?`)) {
2434
+ if (victims.length >= 2 && !win.confirm(`Close ${String(victims.length)} terminals?`)) {
2860
2435
  return;
2861
2436
  }
2862
- const closingActive = activeId !== null && ids.includes(activeId);
2863
- // The bulk-close path drops tabs itself rather than through dropTab, so it
2864
- // needs the same mid-edit guard: an open editor on a tab being removed must
2865
- // be abandoned, or editingId outlives its chip and permanently suppresses
2866
- // focus-on-switch.
2437
+ const selectedBefore = selectedId();
2438
+ const closingSelected = selectedBefore !== null && ids.includes(selectedBefore);
2439
+ const splitOpen = ctx.shell.split.isOpen();
2440
+ // An editor left open on a removed tab keeps editingId alive past its chip
2441
+ // and suppresses focus-on-switch for good.
2867
2442
  if (editingId !== null && ids.includes(editingId)) {
2868
2443
  endEdit();
2869
2444
  }
2445
+ let emptiedPane = false;
2870
2446
  for (const t of victims) {
2871
2447
  const idx = tabList.indexOf(t);
2872
2448
  if (idx >= 0) {
2873
2449
  tabList.splice(idx, 1);
2874
2450
  }
2875
- // Tombstone briefly so a stale status snapshot/poll that predates the
2876
- // server reaping these sessions cannot re-adopt (flash back) a just-
2877
- // closed tab -- mirrors the single-close path in dropTab (h-f2).
2451
+ // A stale status snapshot that predates the server's reap must not
2452
+ // re-adopt a just-closed tab.
2878
2453
  tombstones.add(t.id);
2879
- acknowledgeSwitchNotify(t.id); // a cue for a closed tab is moot
2880
- // ...and its stored acknowledgement goes with it, for the reason dropTab
2881
- // gives: the session is gone, so the entry would only sit in storage
2882
- // until the 200-entry cap evicted it. The bulk path drops tabs itself
2883
- // rather than through dropTab, so every per-session store it touches has
2884
- // to be listed here too.
2454
+ acknowledgeSwitchNotify(t.id);
2885
2455
  forgetCueSeen(t.id);
2456
+ ctx.shell.notifications.forget(t.id);
2886
2457
  t.aria.remove();
2887
- abortReorderFor(t.el); // same reason as dropTab: a live drag holds this node
2458
+ abortReorderFor(t.el); // a live drag holds this node
2888
2459
  t.el.remove();
2889
- ctx.dropSession(t.id);
2890
- }
2891
- // Re-home the live view before the DELETEs: if the active tab was closed,
2892
- // attach a survivor; otherwise just refresh the chrome.
2893
- if (closingActive) {
2894
- activeId = null;
2895
- const survivor = tabList[0];
2896
- if (survivor) {
2897
- switchTo(survivor.id);
2460
+ const side = sideOf(t.id);
2461
+ if (side !== null && splitOpen) {
2462
+ ctx.shell.pane(side)?.clearActiveSession();
2463
+ ctx.shell.dropSessionExcept(t.id, side);
2464
+ emptiedPane = true;
2898
2465
  } else {
2899
- syncChrome();
2466
+ ctx.dropSession(t.id);
2900
2467
  }
2468
+ }
2469
+ // Re-home before the DELETEs: selection must not sit on an emptied pane for
2470
+ // a round trip. Split open, the emptied panes stay empty (no promotion).
2471
+ if (!splitOpen && closingSelected && tabList[0]) {
2472
+ switchTo(tabList[0].id);
2901
2473
  } else {
2902
2474
  syncChrome();
2475
+ if (splitOpen && closingSelected) {
2476
+ const shownSide = ctx.shell.panes().find((p) => p.state() === "shown")?.side;
2477
+ if (shownSide !== undefined) {
2478
+ ctx.shell.select(shownSide);
2479
+ }
2480
+ }
2481
+ if (emptiedPane) {
2482
+ scheduleLayoutWrite();
2483
+ }
2903
2484
  }
2485
+ // One terminal always stays open, and an open split always shows one tab.
2486
+ let replacement: Promise<void> | undefined;
2487
+ if (tabList.length === 0 || (splitOpen && !anyShown())) {
2488
+ replacement = create(ctx.shell.panes().find((p) => p.state() === "empty")?.side);
2489
+ }
2490
+ // The person asked for these closes, so every DELETE still goes out past a
2491
+ // teardown; only what the page shows for them stops.
2904
2492
  for (const t of victims) {
2905
2493
  try {
2906
2494
  await api.close(t.id);
2907
2495
  } catch {
2908
- ctx.toast("Couldn't close a terminal on the server");
2496
+ if (!tornDown()) {
2497
+ ctx.toast("Couldn't close a terminal on the server");
2498
+ }
2909
2499
  }
2910
2500
  }
2911
- if (tabList.length === 0) {
2912
- await create(); // there is always at least one terminal open
2913
- }
2501
+ await replacement;
2914
2502
  }
2915
2503
 
2916
2504
  function closeOthers(id: string): Promise<void> {
@@ -2930,35 +2518,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2930
2518
  return closeMany(tabList.map((t) => t.id));
2931
2519
  }
2932
2520
 
2933
- // --- Inline rename (R5) ---
2934
2521
  // The edit surface is the label's OWN box, so the width the user types into
2935
- // is the width the label will have. An <input>, not contenteditable: real
2936
- // text-input semantics, IME support, a mobile keyboard, and no rich-HTML
2937
- // paste surface.
2938
- //
2939
- // editingId is also the stand-down flag for every chip handler that would
2940
- // otherwise fight the field (click-to-switch, the arrow/Delete keys,
2941
- // focus-on-switch, the drag) — see the guards at each site.
2522
+ // is the width the label will have. editingId is also the stand-down flag
2523
+ // for every chip handler that would otherwise fight the field.
2942
2524
  let editingId: string | null = null;
2943
2525
  let editInput: HTMLInputElement | null = null;
2944
- // Where focus goes when the edit ends. R9: back to the chip when the user
2945
- // arrived from the keyboard (F2 — they were navigating the strip and should
2946
- // still be), to the terminal otherwise. Recorded at entry because the exit
2947
- // paths cannot tell them apart.
2526
+ // Recorded at entry because the exit paths cannot tell a keyboard-started
2527
+ // edit (focus returns to the chip) from a pointer one (to the terminal).
2948
2528
  let editFrom: "keyboard" | "pointer" = "pointer";
2949
- // Tab ids with pinned-name requests in flight, COUNTED per id. While a count
2950
- // is held, a status record carrying a DIFFERENT value is applied for display
2951
- // but does not supersede the request: SSE delivery order and REST mutation
2952
- // order are not one total order, so a record sampled BEFORE our PUT can
2953
- // arrive after it, and treating it as newer authority would suppress the
2954
- // request's own rollback and its failure toast.
2955
- // Counted rather than a Set of ids, because two renames of one tab can be out
2956
- // at once and the invariant is "no request of OURS is in flight", not "no id
2957
- // is marked": a bare Set let the first request's completion clear the marker
2958
- // while the second's PUT was still open, and the second rename then failed
2959
- // silently. A per-request token would carry more information, but the only
2960
- // reader asks whether ANY request is out for this id, and the failure handler
2961
- // already distinguishes its own supersession by (born, nameSeq).
2529
+ // Pinned-name requests in flight, COUNTED per id: two renames of one tab can
2530
+ // be out at once, and a bare Set let the first completion clear the marker
2531
+ // while the second's PUT was still open, so that rename failed silently.
2962
2532
  const namesInFlight = new Map<string, number>();
2963
2533
 
2964
2534
  /** restoreFocusAfterEdit sends focus where the entry path implies. The
@@ -2995,7 +2565,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2995
2565
  t.el.draggable = true;
2996
2566
  // Restore the tab semantics from the CURRENT selected state: the active
2997
2567
  // tab can change while an edit is open on a different chip.
2998
- t.aria.setEditing(false, t.id === activeId);
2568
+ t.aria.setEditing(false, isShown(t.id));
2999
2569
  }
3000
2570
 
3001
2571
  /** commitRename applies a finished edit. An empty value CLEARS the pin, but
@@ -3073,17 +2643,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3073
2643
  if (!t) {
3074
2644
  return;
3075
2645
  }
3076
- const input = document.createElement("input");
2646
+ const input = doc.createElement("input");
3077
2647
  input.type = "text";
3078
2648
  input.className = "wt-tab-rename";
3079
2649
  input.setAttribute("aria-label", "Rename terminal");
3080
- // Prefill with the PIN, not the rendered label. t.display is presentation
3081
- // output: for an unpinned tab it is the automatic title, the "New tab"
3082
- // fallback, or a de-duplication suffix relabelAll generated (`auth (2)`).
3083
- // Since a blur commits a non-empty value, prefilling it meant opening the
3084
- // editor and clicking away silently pinned presentation text as the user's
3085
- // chosen name. An unpinned tab therefore starts EMPTY, with the current
3086
- // label as the placeholder so the user still sees what they are replacing.
2650
+ // The PIN, never the rendered label: a blur commits a non-empty value, so a
2651
+ // prefilled automatic title or de-dup suffix would be pinned by a click-away.
2652
+ // An unpinned tab starts EMPTY with the label as its placeholder.
3087
2653
  input.value = t.pinnedTitle ?? "";
3088
2654
  input.placeholder = t.display;
3089
2655
  // maxLength is a UI affordance only, in UTF-16 code units; the server
@@ -3107,7 +2673,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3107
2673
  // Drop the tab semantics while a textbox lives in the chip, and stop the
3108
2674
  // chip being draggable: a draggable ancestor interferes with drag-selecting
3109
2675
  // text, and dragging the tab you are renaming is meaningless.
3110
- t.aria.setEditing(true, t.id === activeId);
2676
+ t.aria.setEditing(true, isShown(t.id));
3111
2677
  t.el.draggable = false;
3112
2678
  editingId = id;
3113
2679
  editInput = input;
@@ -3145,21 +2711,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3145
2711
  });
3146
2712
  }
3147
2713
 
3148
- /** resolveEdit closes the open edit and applies its value under the rules for
3149
- * HOW it was closed. One function rather than a commit path per caller,
3150
- * because the empty-value rule differs and every caller that got it wrong
3151
- * did so by hand-inlining a variant:
3152
- *
3153
- * - `"confirm"` (Enter) is the only deliberate confirmation, so it is the
3154
- * only mode that may CLEAR a pin from an emptied field.
3155
- * - `"blur"` commits a non-empty value (Finder and Explorer both do;
3156
- * discarding on a stray click is hostile) and reverts an empty one — a
3157
- * blur is loss of focus, not destructive intent, and on a tablet
3158
- * dismissing the soft keyboard IS a blur.
3159
- * - `"cancel"` (Escape) applies nothing.
3160
- *
3161
- * `focus` is opt-out for the callers that are not a user finishing an edit
3162
- * (a reorder, a pre-empting second edit): they should not move focus. */
2714
+ /** Close the open edit under the rules for HOW it was closed. `"confirm"`
2715
+ * (Enter) is the only mode that may CLEAR a pin from an emptied field;
2716
+ * `"blur"` commits a non-empty value and reverts an empty one, because a
2717
+ * blur is loss of focus rather than destructive intent, and dismissing a
2718
+ * tablet's soft keyboard IS a blur; `"cancel"` applies nothing. `focus` is
2719
+ * opt-out for a caller that is not the user finishing an edit. */
3163
2720
  function resolveEdit(mode: "confirm" | "blur" | "cancel", focus = true): void {
3164
2721
  const id = editingId;
3165
2722
  if (id === null) {
@@ -3184,38 +2741,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3184
2741
  }
3185
2742
  }
3186
2743
 
3187
- // hideTabMenu / showTabMenu drive the right-click menu. showTabMenu rebuilds
3188
- // the items for the target tab (disabled states reflect its position) and
3189
- // clamps into the visible viewport, flipping above the pointer near the
3190
- // bottom edge (mirrors context-menu.ts).
3191
- //
3192
- // menuSwallow covers the contextmenu-then-touchend race: iPadOS Safari
3193
- // raises a context menu from a LONG-PRESS, and the same gesture emits a
3194
- // click on release which onDocClickMenu would read as a click-away.
3195
- // menuTouch records whether the press that raised the menu was a finger,
3196
- // since only touch emits that trailing click.
3197
- //
3198
- // The window is a fixed 350ms, so arming it here — mid-press, when the
3199
- // platform delivers `contextmenu` — only covers a release that comes within
3200
- // 350ms; hold the chip a beat longer and the menu still dismissed itself on
3201
- // release. menuOpenedInPress carries the fact that THIS press opened the
3202
- // menu through to its pointerup, which re-arms on the release edge. The
3203
- // flag is what keeps that re-arm off an unrelated later tap, whose click is
3204
- // a genuine dismiss.
3205
- const menuSwallow = createClickSwallow();
2744
+ // A long-press raises `contextmenu` mid-press and emits a click on release,
2745
+ // which onDocClickMenu would read as a click-away. The swallow window is
2746
+ // fixed, so arming it at `contextmenu` missed a release held a beat longer;
2747
+ // menuOpenedInPress carries the fact through to pointerup, which re-arms on
2748
+ // the release edge, and keeps that re-arm off an unrelated later tap.
2749
+ const menuSwallow = createClickSwallow(win);
3206
2750
  let menuTouch = false;
3207
2751
  let menuOpenedInPress = false;
3208
- // One listener on the bar rather than one per chip: chips are created and
3209
- // destroyed as sessions come and go, and both facts recorded here (the
3210
- // gesture's pointer type, and whether it took the keyboard off the
3211
- // terminal) belong to the gesture, not to a chip.
2752
+ // One listener on the bar: both facts recorded belong to the gesture, not to
2753
+ // a chip, and chips come and go with sessions.
3212
2754
  bar.addEventListener(
3213
2755
  "pointerdown",
3214
2756
  (e) => {
3215
2757
  menuTouch = e.pointerType === "touch";
3216
2758
  menuOpenedInPress = false;
3217
2759
  noteChromePress();
3218
- notifier.gesture();
2760
+ ctx.shell.notifications.gesture();
3219
2761
  },
3220
2762
  { passive: true },
3221
2763
  );
@@ -3237,7 +2779,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3237
2779
  "pointerdown",
3238
2780
  () => {
3239
2781
  noteChromePress();
3240
- notifier.gesture();
2782
+ ctx.shell.notifications.gesture();
3241
2783
  },
3242
2784
  { passive: true },
3243
2785
  );
@@ -3246,7 +2788,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3246
2788
  tabMenu.replaceChildren();
3247
2789
  }
3248
2790
  function tabMenuItem(label: string, disabled: boolean, run: () => void): void {
3249
- const b = document.createElement("button");
2791
+ const b = doc.createElement("button");
3250
2792
  b.type = "button";
3251
2793
  b.setAttribute("role", "menuitem");
3252
2794
  b.textContent = label;
@@ -3261,7 +2803,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3261
2803
  tabMenu.appendChild(b);
3262
2804
  }
3263
2805
  function tabMenuSeparator(): void {
3264
- const hr = document.createElement("div");
2806
+ const hr = doc.createElement("div");
3265
2807
  hr.className = "wt-tab-menu-sep";
3266
2808
  hr.setAttribute("role", "separator");
3267
2809
  tabMenu.appendChild(hr);
@@ -3294,6 +2836,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3294
2836
  tabMenuItem("Move right", idx >= n - 1, () => {
3295
2837
  moveTab(id, 1);
3296
2838
  });
2839
+ if (ctx.shell.split.enabled) {
2840
+ const shownOn = sideOf(id);
2841
+ const fits = rowFitsSplit();
2842
+ tabMenuItem("Snap to left", !fits || shownOn === "left", () => {
2843
+ snap(id, "left");
2844
+ });
2845
+ tabMenuItem("Snap to right", !fits || shownOn === "right", () => {
2846
+ snap(id, "right");
2847
+ });
2848
+ tabMenuSeparator();
2849
+ }
3297
2850
  tabMenuItem("Close", false, () => {
3298
2851
  void close_(id);
3299
2852
  });
@@ -3334,7 +2887,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3334
2887
  }
3335
2888
  let seen = false;
3336
2889
  try {
3337
- seen = localStorage.getItem(SWIPE_HINT_KEY) === "1";
2890
+ seen = win.localStorage.getItem(SWIPE_HINT_KEY) === "1";
3338
2891
  } catch {
3339
2892
  /* storage unavailable; show once per session via hintShown */
3340
2893
  }
@@ -3342,19 +2895,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3342
2895
  return;
3343
2896
  }
3344
2897
  try {
3345
- localStorage.setItem(SWIPE_HINT_KEY, "1");
2898
+ win.localStorage.setItem(SWIPE_HINT_KEY, "1");
3346
2899
  } catch {
3347
2900
  /* ignore */
3348
2901
  }
3349
2902
  ctx.toast("Swipe to switch terminals");
3350
2903
  }
3351
2904
 
3352
- // applyStatus updates one tab's dot (status + reveal) + titles from a status
3353
- // record (shared by the SSE monitor and the polling fallback). It takes the
3354
- // record whole rather than as positional fields: both callers already hold
3355
- // one, and its title fields are interchangeable strings that as positional
3356
- // parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
3357
- // separately because it is normalised (reportsOf) before it gets here.
2905
+ // Takes the record whole: its title fields are interchangeable strings that
2906
+ // as positional parameters would be swappable. `reports` travels separately
2907
+ // because it is normalised (reportsOf) first.
3358
2908
  function applyStatus(rec: StatusRecord, reports: boolean): void {
3359
2909
  const t = tabList.find((tab) => tab.id === rec.id);
3360
2910
  if (!t) {
@@ -3370,13 +2920,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3370
2920
  t.order = rec.order;
3371
2921
  }
3372
2922
  paintStatusDot(t.dot, rec.status, reports);
3373
- // The OSC 9;4 percentage. Only an explicitly PRESENT value updates the
3374
- // tab: the polling fallback lists SessionInfo, which carries no
3375
- // percentage at all, and reading that absence as "cleared" would blank a
3376
- // live bar on every poll tick. The spec's own clear (OSC 9;4;0, or the
3377
- // abbreviated form) arrives as an explicit -1; the only other clear is
3378
- // the process ending, applied at render time (renderedProgress). No
3379
- // timer, and nothing special about 100%.
2923
+ // Only a PRESENT percentage updates the tab: the polling fallback lists
2924
+ // SessionInfo, which carries none, and reading absence as cleared would
2925
+ // blank a live bar every tick. A clear arrives as an explicit -1.
3380
2926
  if (rec.progressValue !== undefined) {
3381
2927
  t.progress = normalizeProgress(rec.progressValue);
3382
2928
  }
@@ -3393,52 +2939,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3393
2939
  t.activityCount = normalizeActivityCount(rec.activityCount);
3394
2940
  }
3395
2941
  paintActivityMark(t.activityEl, t.activity, t.activityCount);
3396
- // A session that reports activity is a session whose program speaks OSC 9,
3397
- // so it is also one that may post a notification: arm the permission
3398
- // request for the next user gesture (see notify.ts).
2942
+ // A program that speaks OSC 9 may also post a notification: arm the
2943
+ // permission request for the next user gesture.
3399
2944
  if (reports) {
3400
- notifier.arm();
3401
- }
3402
- // Latest-wins background-tab notification for the switch button's dot: a
3403
- // background tab (not the active one) reaching "input" (needs you) or
3404
- // "done" (turn finished) raises the cue in that colour; each qualifying
3405
- // event overwrites the prior one, and expandSwitcher clears it when the
3406
- // list opens. The active surface keeps its own needs-input cue (see
3407
- // syncMobile); this is the moved + upgraded, glanceable version on the
3408
- // dedicated button.
3409
- //
3410
- // Both statuses are LATCHED server-side, so this runs on re-delivered
3411
- // state as well as on genuine transitions: every SSE (re)open pushes a
3412
- // snapshot and the poll fallback re-lists every few seconds. What makes a
3413
- // dismissal stick across those is cueSeen — a latch this viewer already
3414
- // acknowledged raises nothing, while a status that moved on drops the
3415
- // acknowledgement so the next latch is a fresh cue.
3416
- //
3417
- // Two inputs, one per question, and the ORDER keeps them apart. The
3418
- // acknowledgement asks whether this reader has SEEN the tab's real state:
3419
- // RAW status, first, and only for a cue — a non-cue one has nothing to
3420
- // acknowledge and has to reach the forget below. The raise and the forget
3421
- // ask whether anything should be ANNOUNCED: folded. Off the fold the
3422
- // acknowledgement misses a watched turn until its task ends; off the raw
3423
- // status the forget makes the "" sentinel pointless.
2945
+ ctx.shell.notifications.arm();
2946
+ }
2947
+ // The switch button's latest-wins dot. Cue statuses are LATCHED server-side
2948
+ // and re-delivered on every snapshot, so cueSeen is what makes a dismissal
2949
+ // stick. The acknowledgement reads the RAW status (a non-cue must reach the
2950
+ // forget below); the raise and the forget read the FOLDED one.
3424
2951
  const raw = statusOf(t);
3425
2952
  const cue = cueStatusOf(t);
3426
- if (rec.id === activeId && pageVisible() && isCueStatus(raw)) {
3427
- // The user is looking at this terminal as it latches, so there is
3428
- // nothing to notify — and nothing to re-raise once they move away.
3429
- //
3430
- // BOTH halves are required, for the reason notify.ts states for its own
3431
- // suppression rule: keyed on "is this the active tab" alone, this
3432
- // swallowed the cue of the very session the user left running. That is
3433
- // the single-session case — one tab is necessarily the active one — and
3434
- // it is precisely the case the out-of-page surfaces exist for, so a
3435
- // latch arriving on a HIDDEN page now raises them and is acknowledged
3436
- // when the user comes back (see onPageVisible).
2953
+ if (isShown(rec.id) && pageVisible() && isCueStatus(raw)) {
2954
+ // BOTH halves: keyed on the shown tabs alone, this swallowed the cue of
2955
+ // the one session the user left running on a hidden page, the case the
2956
+ // out-of-page surfaces exist for.
3437
2957
  markCueSeen(rec.id, raw);
3438
2958
  } else if (cue === "") {
3439
- // A blanked cue is NO INFORMATION, not a state, so nothing is raised and
3440
- // the acknowledgement map is left alone: forgetting here would re-raise
3441
- // the cue from scratch the moment the background task ended.
2959
+ // NO INFORMATION: forgetting here would re-raise the cue from scratch
2960
+ // the moment the background task ended.
3442
2961
  } else if (!isCueStatus(cue)) {
3443
2962
  forgetCueSeen(rec.id);
3444
2963
  } else if (isUnseenCue(cue, rec.id, cueSeen)) {
@@ -3446,158 +2965,234 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3446
2965
  switchNotifyId = rec.id;
3447
2966
  paintSwitchDot();
3448
2967
  }
3449
- // Record the raw server title; the displayed label (fallback + de-dup)
3450
- // is recomputed by relabelAll via syncChrome, which the callers run
3451
- // right after applyStatus. Ignore a BLANK title: a status sweep (or the
3452
- // process clearing its OSC 0/2 window title) reports an empty string,
3453
- // and overwriting a good label with it dropped an idle tab back to "New
3454
- // tab". Hold the last known title until a genuine (non-blank) one
3455
- // arrives; the derived-from-input fallback is likewise sticky.
3456
- // The typeof check is not redundant with the type: rec comes from
3457
- // unvalidated server JSON, and a missing title would make .trim() throw
3458
- // here and abort the caller's whole reconcile loop.
2968
+ // A BLANK title (a status sweep, or the process clearing its OSC 0/2
2969
+ // title) must not drop an idle tab back to "New tab". The typeof check is
2970
+ // live: rec is unvalidated server JSON, and a missing title would abort
2971
+ // the caller's whole reconcile loop.
3459
2972
  if (typeof rec.title === "string" && rec.title.trim() !== "") {
3460
2973
  t.title = rec.title;
3461
2974
  }
3462
- // The pinned name is likewise authoritative and un-guarded, and "" is the
3463
- // meaningful value: it is how a clear made in ANOTHER browser reaches this
3464
- // one. A blank-guard here would make a remote un-rename invisible.
3465
- //
3466
- // The nameSeq bump is gated on the value actually DIFFERING from what we
3467
- // believe. The wire carries pinnedTitle on every status event, so bumping
3468
- // unconditionally would mark an in-flight local rename as superseded by
3469
- // the server's echo of that same rename — and its failure handler would
3470
- // then decline to roll back or explain itself. A differing value really is
3471
- // newer authority (a remote rename or clear), and supersedes.
2975
+ // "" is meaningful here: it is how a clear made in ANOTHER browser reaches
2976
+ // this one. The bump is gated on a DIFFERING value because the wire echoes
2977
+ // pinnedTitle on every event, and an unconditional bump would mark an
2978
+ // in-flight local rename as superseded by its own echo.
3472
2979
  if (rec.pinnedTitle !== undefined && rec.pinnedTitle !== (t.pinnedTitle ?? "")) {
3473
2980
  t.pinnedTitle = rec.pinnedTitle;
3474
- // Only a value arriving with no request of ours in flight is newer
3475
- // AUTHORITY. During a pending request the record may predate our own PUT
3476
- // (SSE delivery and REST mutation are not one total order), and bumping
3477
- // would suppress that request's rollback and its failure toast.
2981
+ // SSE delivery and REST mutation are not one total order: during a
2982
+ // pending request the record may predate our own PUT, and bumping would
2983
+ // suppress that request's rollback and its failure toast.
3478
2984
  if (!namesInFlight.has(rec.id)) {
3479
2985
  t.nameSeq++;
3480
2986
  }
3481
2987
  }
3482
2988
  }
3483
2989
 
3484
- // Live status: the activity monitor (SSE push) when present, else a poll of
3485
- // GET /api/sessions. Either way, dots + titles update and vanished sessions
3486
- // drop; the poll additionally learns of a background exit the SSE would
3487
- // have pushed (section 22.5).
3488
- let offStatus: (() => void) | undefined;
3489
- let offStreamOpen: (() => void) | undefined;
3490
- let pollTimer: ReturnType<typeof setInterval> | null = null;
3491
- // One-shot list reconcile shared by both status sources: adopt every
3492
- // session the server lists, then drop tabs it no longer lists. The poll
3493
- // fallback runs it on a timer; the SSE path runs it on every stream
3494
- // (re)open — the reopen against a RESTARTED manager is the moment
3495
- // zombie tabs must drop, because the fresh server's snapshot carries no
3496
- // tombstones for sessions it never knew, so those tabs would otherwise
3497
- // spin "Reconnecting…" forever (judgement finding sf-2).
3498
- // Guarded against overlapping runs: a server slower than the trigger
3499
- // cadence (but within the 15s API timeout) would otherwise race tabList
3500
- // mutation. Skip the extra run instead.
3501
- let reconciling = false;
3502
- const reconcileOnce = async (): Promise<void> => {
3503
- if (reconciling) {
2990
+ // One list reconcile for both status sources: adopt every session the
2991
+ // server lists, then drop tabs it no longer lists. The SSE path runs it on
2992
+ // every (re)open because a RESTARTED manager's snapshot carries no
2993
+ // tombstones for sessions it never knew, so those tabs would otherwise spin
2994
+ // "Reconnecting…" forever. An overlapping call joins the run in flight
2995
+ // rather than starting another, so a caller that awaits it sees a listing.
2996
+ let reconciling: Promise<void> | null = null;
2997
+ const reconcileOnce = (): Promise<void> => {
2998
+ reconciling ??= reconcileNow().finally(() => {
2999
+ reconciling = null;
3000
+ });
3001
+ return reconciling;
3002
+ };
3003
+ async function reconcileNow(): Promise<void> {
3004
+ // The listing is authoritative only for tabs that existed when it was
3005
+ // requested: a tab adopted while the GET was in flight is invisible to
3006
+ // it, and dropping it here cascaded into a duplicate replacement session.
3007
+ const epochAtList = tabEpoch;
3008
+ let list: SessionInfo[];
3009
+ try {
3010
+ list = await api.list();
3011
+ } catch {
3012
+ return; // transient; try again on the next trigger
3013
+ }
3014
+ if (tornDown()) {
3015
+ return;
3016
+ }
3017
+ const seen = new Set(list.map((s) => s.id));
3018
+ for (const info of list) {
3019
+ adoptSession(info); // add sessions created elsewhere (no local tab)
3020
+ applyStatus(info, reportsOf(info.reportsActivity));
3021
+ }
3022
+ // Already gone server-side, so no DELETE; tabs born after the snapshot
3023
+ // wait for the next reconcile.
3024
+ const gone = tabList
3025
+ .filter((t) => !seen.has(t.id) && t.born <= epochAtList)
3026
+ .map((t) => t.id);
3027
+ for (const id of gone) {
3028
+ await dropTab(id, false);
3029
+ }
3030
+ ensureActive();
3031
+ syncChrome();
3032
+ }
3033
+ function warnLayoutOnce(what: string, err?: unknown): void {
3034
+ if (warnedLayout) {
3504
3035
  return;
3505
3036
  }
3506
- reconciling = true;
3037
+ warnedLayout = true;
3038
+ console.warn(`web-terminal-ui: ${what}; the pane layout is not persisted`, err);
3039
+ }
3040
+ function currentLayout(): PaneLayout | null {
3041
+ const left = ctx.shell.pane("left")?.session.id ?? null;
3042
+ const right = ctx.shell.pane("right")?.session.id ?? null;
3043
+ if (left === null && right === null) {
3044
+ return null;
3045
+ }
3046
+ const split = ctx.shell.split.state();
3047
+ return {
3048
+ left,
3049
+ right,
3050
+ handle: split.committedRatio,
3051
+ selected: split.selected,
3052
+ open: split.open,
3053
+ };
3054
+ }
3055
+ async function writeLayout(layout: PaneLayout, retryOnStale: boolean): Promise<void> {
3507
3056
  try {
3508
- // Snapshot the mutation epoch BEFORE the list round-trip: the listing
3509
- // is authoritative only for tabs that already existed when it was
3510
- // requested. A tab adopted while the GET was in flight (the boot
3511
- // race: the bootstrap's create vs this stream-open reconcile) is
3512
- // invisible to the returned snapshot, and dropping it here cascaded
3513
- // into a duplicate replacement session (dropTab's last-tab intercept
3514
- // spawns one) — the double-create boot bug.
3515
- const epochAtList = tabEpoch;
3516
- let list: SessionInfo[];
3517
- try {
3518
- list = await api.list();
3519
- } catch {
3520
- return; // transient; try again on the next trigger
3057
+ await api.setLayout(layout);
3058
+ } catch (err) {
3059
+ if (tornDown()) {
3060
+ return;
3521
3061
  }
3522
- const seen = new Set(list.map((s) => s.id));
3523
- for (const info of list) {
3524
- adoptSession(info); // add sessions created elsewhere (no local tab)
3525
- applyStatus(info, reportsOf(info.reportsActivity));
3062
+ if (err instanceof SessionAPIError && err.status === 409 && retryOnStale) {
3063
+ // A side names a session the server no longer has: this client's view
3064
+ // is behind, so re-list and write the reconciled row once more. The
3065
+ // reconcile's own change is folded into that one write.
3066
+ await reconcileOnce();
3067
+ const reconciled = tornDown() ? null : currentLayout();
3068
+ if (reconciled !== null) {
3069
+ layoutDirty = false;
3070
+ await writeLayout(reconciled, false);
3071
+ }
3072
+ return;
3526
3073
  }
3527
- // A tab the server no longer lists was reaped/closed elsewhere (or
3528
- // died with a restarted manager): drop it locally (no DELETE — it
3529
- // is already gone). Tabs born after the list snapshot are spared this
3530
- // round; the next reconcile sees the server truth for them.
3531
- const gone = tabList
3532
- .filter((t) => !seen.has(t.id) && t.born <= epochAtList)
3533
- .map((t) => t.id);
3534
- for (const id of gone) {
3535
- await dropTab(id, false);
3074
+ if (err instanceof SessionAPIError && err.status === 404) {
3075
+ warnLayoutOnce("this server has no GET/PUT /api/sessions/layout route");
3076
+ return;
3536
3077
  }
3537
- ensureActive();
3538
- syncChrome();
3539
- } finally {
3540
- reconciling = false;
3078
+ warnLayoutOnce("could not write the pane layout", err);
3541
3079
  }
3542
- };
3080
+ }
3081
+ function flushLayout(): void {
3082
+ layoutDirty = false;
3083
+ const layout = currentLayout();
3084
+ if (layout === null || tornDown()) {
3085
+ return;
3086
+ }
3087
+ layoutInFlight = writeLayout(layout, true).finally(() => {
3088
+ layoutInFlight = null;
3089
+ if (layoutDirty && !tornDown()) {
3090
+ flushLayout();
3091
+ }
3092
+ });
3093
+ }
3094
+ // Every change of the shown tab writes the record; coalesced so a switch
3095
+ // that clears and shows sends one PUT, and held while no tab is shown.
3096
+ function scheduleLayoutWrite(): void {
3097
+ if (tornDown() || applyingRecord) {
3098
+ return;
3099
+ }
3100
+ if (layoutInFlight !== null) {
3101
+ layoutDirty = true;
3102
+ return;
3103
+ }
3104
+ if (layoutWrite !== null) {
3105
+ return;
3106
+ }
3107
+ layoutWrite = win.setTimeout(() => {
3108
+ layoutWrite = null;
3109
+ flushLayout();
3110
+ }, 0);
3111
+ }
3112
+ // Of the split's state, `open`, the remembered handle share and the selected
3113
+ // pane are the record's; a drag preview and a resize clamp are not.
3114
+ let lastSplit = ctx.shell.split.state();
3115
+ ctx.defer(
3116
+ ctx.shell.split.onChange((s) => {
3117
+ const changed =
3118
+ s.open !== lastSplit.open ||
3119
+ s.committedRatio !== lastSplit.committedRatio ||
3120
+ s.selected !== lastSplit.selected;
3121
+ lastSplit = s;
3122
+ if (changed) {
3123
+ scheduleLayoutWrite();
3124
+ }
3125
+ }),
3126
+ );
3127
+ // The shell empties a pane on a close of the split, drops a failed pane's
3128
+ // tab from the shown set and moves selection on its own drivers; the chrome
3129
+ // and the record read the panes, so both follow.
3130
+ ctx.defer(
3131
+ ctx.shell.onPanesChange(() => {
3132
+ markSurfaces();
3133
+ syncCatchups();
3134
+ syncChrome();
3135
+ scheduleLayoutWrite();
3136
+ }),
3137
+ );
3138
+ ctx.defer(ctx.shell.onSelectionChange(syncChrome));
3139
+ if (ctx.shell.split.enabled) {
3140
+ // The controller reports a resize only while the split is open; the
3141
+ // buttons' width rule holds while it is closed too.
3142
+ const rowResize = new win.ResizeObserver(paintSplitButtons);
3143
+ rowResize.observe(ctx.shell.root);
3144
+ ctx.defer(() => {
3145
+ rowResize.disconnect();
3146
+ });
3147
+ }
3543
3148
  if (monitor) {
3544
- offStatus = monitor.onStatus((s) => {
3149
+ const offStatus = monitor.onStatus((s) => {
3545
3150
  if (s.removed) {
3546
3151
  void dropTab(s.id, false); // already gone server-side; no DELETE
3547
3152
  return;
3548
3153
  }
3549
- // Adopt a session created in another browser so all clients converge.
3550
- // The status record IS the session's wire shape (SessionStatus extends
3551
- // SessionInfo), so it flows through whole.
3552
3154
  adoptSession(s);
3553
3155
  applyStatus(s, reportsOf(s.reportsActivity));
3554
3156
  ensureActive();
3555
3157
  syncChrome();
3556
- // A notification is an EVENT the engine delivers once, on the sweep
3557
- // that first observes it, so it is handled here rather than in
3558
- // applyStatus (which also runs on re-delivered STATE). Delivered after
3559
- // syncChrome so the notification's title is the label the user would
3560
- // see. The status record is the notification's carrier whether or not
3561
- // the server has a classifier installed.
3562
- notifier.deliver(s, {
3563
- sessionIsActive: s.id === activeId,
3158
+ // A notification is an EVENT delivered once, so it is handled here and
3159
+ // not in applyStatus, which also runs on re-delivered STATE. After
3160
+ // syncChrome, so its title is the label the user would see.
3161
+ ctx.shell.notifications.deliver(s, {
3162
+ sessionIsActive: isShown(s.id),
3564
3163
  label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
3565
- // What clicking the notification does. The platform's own click
3566
- // default focuses the page; this supplies the other half, landing the
3567
- // user on the session that raised the notification rather than on
3568
- // whichever tab they last left active. switchTo already no-ops on an
3569
- // id that is gone, which is the case that matters here: the prompt may
3570
- // have been answered, or the tab closed, between post and click.
3164
+ // switchTo no-ops on an id that is gone, which is the case that
3165
+ // matters: the tab may have closed between post and click.
3571
3166
  activate: () => {
3572
3167
  switchTo(s.id);
3573
3168
  },
3574
3169
  });
3575
3170
  });
3576
- offStreamOpen = monitor.onStreamOpen?.(() => {
3171
+ ctx.defer(offStatus);
3172
+ const offStreamOpen = monitor.onStreamOpen?.(() => {
3577
3173
  void reconcileOnce();
3578
3174
  });
3175
+ if (offStreamOpen) {
3176
+ ctx.defer(offStreamOpen);
3177
+ }
3579
3178
  } else {
3580
3179
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
3581
- pollTimer = setInterval(() => {
3180
+ const pollTimer = win.setInterval(() => {
3582
3181
  void reconcileOnce();
3583
3182
  }, pollMs);
3183
+ ctx.defer(() => {
3184
+ win.clearInterval(pollTimer);
3185
+ });
3584
3186
  }
3585
3187
 
3586
- // --- Event wiring ---
3587
- // No input observer. The ENGINE owns a session's name, so no preset does
3588
- // per-keystroke title work in the browser, and the name is identical for
3589
- // every client attached to the session — including one that attaches later.
3590
- // Observe (never consume) keydowns to detect a physical keyboard: a
3591
- // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
3592
- // for a keyboard folio with no trackpad (no fine pointer to key off).
3593
- const offHwKey = ctx.registerKeydown((ev) => {
3188
+ // A hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
3189
+ // for a keyboard folio with no trackpad.
3190
+ ctx.registerKeydown((ev) => {
3594
3191
  if (!sawHardwareKey && looksLikeHardwareKey(ev)) {
3595
3192
  sawHardwareKey = true;
3596
3193
  }
3597
3194
  return false;
3598
3195
  });
3599
- // Drive the reorder preview while dragging a tab over the strip. A sweep only
3600
- // rearranges nothing; the slot opens when the pointer comes to rest (trackRest).
3601
3196
  bar.addEventListener("dragover", (e) => {
3602
3197
  if (!draggingEl) {
3603
3198
  return;
@@ -3608,12 +3203,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3608
3203
  }
3609
3204
  trackRest(e.clientX);
3610
3205
  });
3611
- // Commit on drop — and, load-bearing, CANCEL the drop's default action. An
3612
- // uncancelled drop leaves the browser free to act on the drag payload
3613
- // itself, and WebKit's action is to LOAD it as a URL: with no drop handler
3614
- // at all, dropping a tab on iPadOS navigated the page to /<session-id>
3615
- // instead of reordering. Chrome and Firefox ignore an unhandled reorder
3616
- // drop, which is why the strip looked fine on the desktop.
3206
+ // The preventDefault is load-bearing: WebKit's default for an uncancelled
3207
+ // drop is to LOAD the payload as a URL, so dropping a tab on iPadOS
3208
+ // navigated the page to /<session-id>.
3617
3209
  bar.addEventListener("drop", (e) => {
3618
3210
  const moved = draggingEl;
3619
3211
  if (!moved) {
@@ -3621,27 +3213,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3621
3213
  }
3622
3214
  e.preventDefault();
3623
3215
  dropped = true;
3624
- // A release decides, and it decides by POSITION: commit the slot the pointer is
3625
- // actually over rather than whatever a timer had pending. commitSlot no-ops when
3626
- // the tab already sits there, so a drop with nothing to move costs nothing.
3216
+ // A release decides by POSITION, not by whatever slot a timer had pending.
3627
3217
  endRestNet();
3628
3218
  commitSlot(dropTargetBefore(e.clientX));
3629
3219
  syncOrderFromDom();
3630
- announceMoved(moved); // the ONE completed-move announcement per gesture
3220
+ announceMoved(moved);
3631
3221
  });
3632
- // Leaving the strip withdraws a pending slot. The document dragover below does
3633
- // this too and covers more ground, but it cannot cover the case that matters most
3634
- // here: the strip is docked at the viewport EDGE, so a pointer can leave it by
3635
- // leaving the window entirely, and then no other element in the document receives a
3636
- // dragover to notice with. A pointer that leaves the window is also, by definition,
3637
- // one that has stopped moving over the strip — so without this the rest window
3638
- // would run down and open a slot the pointer had already abandoned.
3639
- //
3640
- // relatedTarget is what makes dragleave usable at all: it fires on every
3641
- // child-to-child transition inside the bar (chip to label, label to close), and
3642
- // the next target being inside the bar is exactly how those are told apart. A
3643
- // null relatedTarget means the pointer left the window, which is the case this
3644
- // exists for.
3222
+ // The strip is docked at the viewport EDGE, so a pointer can leave it by
3223
+ // leaving the window, and then no document dragover fires to withdraw the
3224
+ // pending slot; the rest window would open a slot the pointer abandoned.
3225
+ // dragleave fires on every child-to-child transition inside the bar, and a
3226
+ // null relatedTarget is the pointer leaving the window.
3645
3227
  bar.addEventListener("dragleave", (e) => {
3646
3228
  if (!draggingEl) {
3647
3229
  return;
@@ -3651,50 +3233,77 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3651
3233
  endRestNet();
3652
3234
  }
3653
3235
  });
3654
- // A tab released anywhere OTHER than the strip — over the terminal, over
3655
- // any other chrome — must be inert, not a navigation, so claim the whole
3656
- // document as a drop target for the life of a tab drag and swallow the drop
3657
- // there too. Gated on draggingEl so an unrelated drag (a file dropped on the
3658
- // page) is left entirely to the browser.
3236
+ // The half of the pane row a drag is over, or null off the row, over the
3237
+ // switcher, or while the row is too narrow for two panes (the strip is the
3238
+ // caller's early return). An empty pane is `inert`, so a tab dragged over it
3239
+ // hits the shell root underneath.
3240
+ function dropHalf(e: DragEvent): PaneSide | null {
3241
+ const root = ctx.shell.root;
3242
+ const target = e.target as Node;
3243
+ if (
3244
+ !ctx.shell.split.enabled ||
3245
+ !rowFitsSplit() ||
3246
+ !root.contains(target) ||
3247
+ switcher.contains(target)
3248
+ ) {
3249
+ return null;
3250
+ }
3251
+ const rect = root.getBoundingClientRect();
3252
+ return e.clientX < rect.left + rect.width / 2 ? "left" : "right";
3253
+ }
3254
+ function paintDropHalf(half: PaneSide | null): void {
3255
+ ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
3256
+ ctx.shell.root.classList.toggle("wt-drop-right", half === "right");
3257
+ }
3258
+ // A tab released anywhere OTHER than the strip must be inert, not a
3259
+ // navigation, so the whole document is a drop target for the life of a tab
3260
+ // drag; over a half of the pane row it is a snap. Gated on draggingEl so a
3261
+ // file dropped on the page is the browser's.
3659
3262
  const onDocTabDrop = (e: DragEvent): void => {
3660
3263
  if (!draggingEl) {
3661
3264
  return;
3662
3265
  }
3663
3266
  e.preventDefault();
3664
3267
  if (bar.contains(e.target as Node)) {
3665
- return; // on the strip: the bar's own handlers own it
3268
+ paintDropHalf(null);
3269
+ return;
3666
3270
  }
3271
+ const half = dropHalf(e);
3667
3272
  if (e.type === "drop") {
3668
- // Released off the strip, which CANCELS. The strip is the drop zone and it
3669
- // is a generous one (a --touch-target chip plus the bar's own padding, ~61px
3670
- // at the viewport edge), and once the pointer has left it this same handler
3671
- // has already declared there is no candidate slot out here. Committing a
3672
- // slot anyway would persist an arrangement chosen at a position the user
3673
- // visibly left, and worse, the outcome would depend on whether the browser
3674
- // chose to emit `drop` at all: an accepted document drop committed while a
3675
- // refused release fell through to dragend and reverted. That distinction is
3676
- // invisible to the person doing the dragging.
3677
- //
3678
- // So: leave `dropped` false, withdraw anything pending, and let dragend run the
3679
- // revert. Nothing is lost silently — the revert animates and announces, so a
3680
- // mis-release reads as "that did not take" rather than as a mystery.
3273
+ // A release off the strip CANCELS the reorder: `dropped` stays false and
3274
+ // dragend runs the revert. Committing a slot here would persist an
3275
+ // arrangement chosen at a position the user visibly left, and would
3276
+ // depend on whether the browser emitted `drop` at all.
3681
3277
  endRestNet();
3278
+ paintDropHalf(null);
3279
+ if (half !== null) {
3280
+ const id = tabList.find((t) => t.el === draggingEl)?.id;
3281
+ if (id !== undefined) {
3282
+ snap(id, half);
3283
+ }
3284
+ }
3682
3285
  return;
3683
3286
  }
3684
- // dragover, off the strip: no candidate slot exists out here, so withdraw a
3685
- // pending one rather than let the rest window open a gap for a target the pointer
3686
- // has already left. A slot already COMMITTED stays put; the pointer may yet come
3687
- // back onto the strip and release there.
3287
+ if (e.dataTransfer && half !== null) {
3288
+ e.dataTransfer.dropEffect = "move";
3289
+ }
3290
+ paintDropHalf(half);
3291
+ // No candidate slot exists off the strip; a COMMITTED slot stays put.
3688
3292
  endRestNet();
3689
3293
  };
3690
- document.addEventListener("dragover", onDocTabDrop);
3691
- document.addEventListener("drop", onDocTabDrop);
3294
+ doc.addEventListener("dragover", onDocTabDrop);
3295
+ doc.addEventListener("drop", onDocTabDrop);
3296
+ ctx.defer(() => {
3297
+ doc.removeEventListener("dragover", onDocTabDrop);
3298
+ doc.removeEventListener("drop", onDocTabDrop);
3299
+ });
3692
3300
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
3693
3301
  // stopPropagation so it is not read as a tap/swipe on the row surface.
3694
3302
  swClose.addEventListener("click", (e) => {
3695
3303
  e.stopPropagation();
3696
- if (activeId !== null) {
3697
- void close_(activeId);
3304
+ const current = selectedId();
3305
+ if (current !== null) {
3306
+ void close_(current);
3698
3307
  }
3699
3308
  });
3700
3309
  // Dismiss the tab context menu on an outside click, on Escape, and on a
@@ -3707,7 +3316,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3707
3316
  }
3708
3317
  hideTabMenu();
3709
3318
  };
3710
- document.addEventListener("click", onDocClickMenu);
3319
+ doc.addEventListener("click", onDocClickMenu);
3320
+ ctx.defer(() => {
3321
+ doc.removeEventListener("click", onDocClickMenu);
3322
+ });
3711
3323
  // A right-click anywhere other than a tab (the terminal content, elsewhere,
3712
3324
  // or a native browser menu) dismisses the tab menu. A right-click ON a tab
3713
3325
  // is handled by that tab's own contextmenu handler (which reopens it), and
@@ -3717,27 +3329,25 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3717
3329
  hideTabMenu();
3718
3330
  }
3719
3331
  };
3720
- document.addEventListener("contextmenu", onDocContextMenu);
3721
- // Only a scroll that moves the menu's ANCHOR dismisses it, which is the
3722
- // strip's own horizontal scroller and nothing else: the menu is placed
3723
- // root-relative (menu-position.ts rebases onto the offsetParent), so any
3724
- // scroll that moves .wt-root moves the menu with it, and the terminal's
3725
- // scroller does not move the chip at all.
3726
- //
3727
- // The target check is load-bearing, not a micro-optimization. This listener
3728
- // is capture-phase on window, so it sees EVERY scroll in the document —
3729
- // including the terminal surface auto-scrolling to the bottom on each chunk
3730
- // of output. Without the check, an agent printing into the TUI closed the
3731
- // tab menu within a frame of it opening, which is unusable on exactly the
3732
- // screen the menu exists for (a busy multi-agent strip).
3332
+ doc.addEventListener("contextmenu", onDocContextMenu);
3333
+ ctx.defer(() => {
3334
+ doc.removeEventListener("contextmenu", onDocContextMenu);
3335
+ });
3336
+ // Only a scroll of the strip's own scroller moves the menu's anchor. The
3337
+ // target check is load-bearing: this capture-phase listener sees EVERY
3338
+ // scroll, and without it the terminal auto-scrolling on each chunk of
3339
+ // output closed the menu within a frame of it opening.
3733
3340
  const onScrollMenu = (e: Event): void => {
3734
3341
  const target = e.target;
3735
- if (target instanceof Element && target.closest(".wt-tab-scroll")) {
3342
+ if (target instanceof win.Element && target.closest(".wt-tab-scroll")) {
3736
3343
  hideTabMenu();
3737
3344
  }
3738
3345
  };
3739
- window.addEventListener("scroll", onScrollMenu, true);
3740
- const offMenuKey = ctx.registerKeydown((ev) => {
3346
+ win.addEventListener("scroll", onScrollMenu, true);
3347
+ ctx.defer(() => {
3348
+ win.removeEventListener("scroll", onScrollMenu, true);
3349
+ });
3350
+ ctx.registerKeydown((ev) => {
3741
3351
  if (ev.key === "Escape" && tabMenu.classList.contains("visible")) {
3742
3352
  ev.preventDefault();
3743
3353
  hideTabMenu();
@@ -3745,19 +3355,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3745
3355
  }
3746
3356
  return false;
3747
3357
  });
3748
- // A tap on the terminal/background dismisses an open mobile overlay — the
3749
- // expanded tab list or the key grid — rather than opening the keyboard, so
3750
- // tap 1 closes and tap 2 opens the keyboard. Runs in the capture phase and
3751
- // stops propagation, so the kernel's surface tap-to-focus never fires for
3752
- // the dismissing tap. Taps inside the switcher or the key grid are left to
3753
- // their own controls; when nothing is open the tap falls through untouched.
3358
+ // A tap on the terminal dismisses an open mobile overlay (the expanded tab
3359
+ // list or the key grid) rather than opening the keyboard. Capture phase with
3360
+ // stopPropagation, so the surface tap-to-focus never fires for that tap.
3754
3361
  const onDocTapDismiss = (e: PointerEvent): void => {
3755
- // A swipe gesture on the switcher owns the pointer; its own end logic
3756
- // (endHorizontal/endVertical, via the window-level endOnUp) resolves the
3757
- // outcome. Stand down here so this capture-phase handler's stopPropagation
3758
- // cannot swallow that window-bubble pointerup -- which, when setPointerCapture
3759
- // failed and the finger released outside the switcher, would strand gActive=true
3760
- // and brick all future swipes until reload.
3362
+ // A swipe owns the pointer and resolves in the window-level pointerup;
3363
+ // swallowing that here (when setPointerCapture failed and the finger
3364
+ // released outside the switcher) stranded gActive and bricked every swipe.
3761
3365
  if (gActive) {
3762
3366
  return;
3763
3367
  }
@@ -3767,11 +3371,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3767
3371
  return;
3768
3372
  }
3769
3373
  const target = e.target as HTMLElement | null;
3770
- // The tab strip counts as chrome like the switcher: without it, the
3771
- // desktop-strip keyboard button's own pointerup landed here, closed the
3772
- // grid, and the button's click then re-opened it — so tapping the button
3773
- // to CLOSE the grid never worked (a wide-touchscreen / landscape-phone
3774
- // bug; the switcher's kb button was already exempt via .wt-switcher).
3374
+ // The strip counts as chrome: the desktop keyboard button's own pointerup
3375
+ // otherwise closed the grid here and its click re-opened it.
3775
3376
  const inChrome =
3776
3377
  target !== null &&
3777
3378
  (target.closest(".wt-switcher") !== null ||
@@ -3787,18 +3388,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3787
3388
  }
3788
3389
  e.stopPropagation();
3789
3390
  };
3790
- document.addEventListener("pointerup", onDocTapDismiss, true);
3391
+ doc.addEventListener("pointerup", onDocTapDismiss, true);
3392
+ ctx.defer(() => {
3393
+ doc.removeEventListener("pointerup", onDocTapDismiss, true);
3394
+ });
3791
3395
 
3792
- // Interactive drag on the bar (mobile). The gesture follows the finger
3793
- // live rather than only acting on release: after a small axis-lock move it
3794
- // commits to horizontal (slide the terminal content to preview a tab
3795
- // switch) or vertical (grow/shrink the tab list under the bar). On release
3796
- // it snaps: a horizontal drag past a quarter-width commits the switch (else
3797
- // springs back); a vertical drag past the halfway point snaps the list open
3798
- // (else closed). A near-stationary release is a tap that toggles the list
3799
- // (the click listener). swCurrent has touch-action:none so these drags
3800
- // never scroll the page, and the pointer is captured so a drag that leaves
3801
- // the bar still delivers its move/up here.
3396
+ // The bar's drag follows the finger live: after the axis lock it previews a
3397
+ // tab switch (horizontal) or grows the tab list (vertical), and snaps on
3398
+ // release past a quarter-width or the halfway point. The pointer is captured
3399
+ // so a drag that leaves the bar still delivers its move/up here.
3802
3400
  let gDownX = 0;
3803
3401
  let gDownY = 0;
3804
3402
  let gAxis: "h" | "v" | null = null;
@@ -3864,19 +3462,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3864
3462
  // released without committing, then hands overflow/position back to CSS.
3865
3463
  function springRowsBack(): void {
3866
3464
  const rows = [...rowEls.values()];
3867
- requestAnimationFrame(() => {
3465
+ frame(() => {
3868
3466
  for (const el of rows) {
3869
3467
  el.style.transition = "transform 0.2s ease-out";
3870
3468
  el.style.transform = "translateY(0)";
3871
3469
  }
3872
3470
  });
3873
3471
  if (reelTimer !== null) {
3874
- clearTimeout(reelTimer);
3472
+ win.clearTimeout(reelTimer);
3875
3473
  }
3876
- reelTimer = setTimeout(endReelNow, 220);
3474
+ reelTimer = win.setTimeout(endReelNow, 220);
3877
3475
  }
3878
3476
  function endHorizontal(dx: number, releaseT: number, canceled: boolean): void {
3879
- const width = ctx.surface().clientWidth || window.innerWidth;
3477
+ const width = ctx.surface().clientWidth || win.innerWidth;
3880
3478
  const dir = dx < 0 ? 1 : -1;
3881
3479
  // Commit on a flick (quick + fast + far enough) or once dragged past a
3882
3480
  // quarter width; a cancel/capture-loss never commits (springs back). The
@@ -3897,11 +3495,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3897
3495
  el.style.transition = spring;
3898
3496
  el.style.transform = "";
3899
3497
  }
3900
- window.setTimeout(() => {
3498
+ after(220, () => {
3901
3499
  for (const el of swipeEls) {
3902
3500
  el.style.transition = "";
3903
3501
  }
3904
- }, 220);
3502
+ });
3905
3503
  return;
3906
3504
  }
3907
3505
  // Commit: switchTo slides the incoming terminal in from the side and (when
@@ -3924,18 +3522,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3924
3522
  el.style.transition = "none";
3925
3523
  el.style.transform = `translateX(${String(Math.round(slide))}px)`;
3926
3524
  }
3927
- requestAnimationFrame(() => {
3525
+ frame(() => {
3928
3526
  for (const el of swipeEls) {
3929
3527
  el.style.transition = "transform 0.25s cubic-bezier(0.2, 0, 0, 1)";
3930
3528
  el.style.transform = "translateX(0)";
3931
3529
  }
3932
3530
  });
3933
- window.setTimeout(() => {
3531
+ after(320, () => {
3934
3532
  for (const el of swipeEls) {
3935
3533
  el.style.transition = "";
3936
3534
  el.style.transform = "";
3937
3535
  }
3938
- }, 320);
3536
+ });
3939
3537
  }
3940
3538
 
3941
3539
  function beginVertical(): void {
@@ -3955,7 +3553,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3955
3553
  // Bound the interactive drag against the VISUAL viewport (the region above the soft
3956
3554
  // keyboard), matching the switcher's kb-inset bottom anchor, so the list can't grow
3957
3555
  // past the visible area with the keyboard open.
3958
- const visH = window.visualViewport?.height ?? window.innerHeight;
3556
+ const visH = win.visualViewport?.height ?? win.innerHeight;
3959
3557
  gTargetMax = Math.min(swList.scrollHeight, Math.round(visH * 0.5));
3960
3558
  gStartMax = expanded ? gTargetMax : 0;
3961
3559
  swList.style.maxHeight = `${String(gStartMax)}px`;
@@ -4012,7 +3610,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4012
3610
  !canceled &&
4013
3611
  Math.abs(dx) >= SWIPE_MIN_PX &&
4014
3612
  Math.abs(dx) > Math.abs(dy) * 1.5 &&
4015
- modes.getMouseMode() === 0
3613
+ ctx.modes.getMouseMode() === 0
4016
3614
  ) {
4017
3615
  // No pointermove locked an axis (a flick with no intermediate move, or
4018
3616
  // a synthetic down/up): fall back to a discrete switch from the net
@@ -4060,7 +3658,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4060
3658
  if (Math.abs(dx) > Math.abs(dy)) {
4061
3659
  // Horizontal switch preview, unless a mouse-mode app is capturing
4062
3660
  // drags (leave the bar swipe inert then, matching the old gate).
4063
- if (modes.getMouseMode() !== 0) {
3661
+ if (ctx.modes.getMouseMode() !== 0) {
4064
3662
  gActive = false;
4065
3663
  swiped = true;
4066
3664
  gestureAbort?.abort(); // gesture abandoned; drop the window listeners
@@ -4120,9 +3718,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4120
3718
  gestureAbort?.abort();
4121
3719
  gestureAbort = new AbortController();
4122
3720
  const opts = { passive: true, signal: gestureAbort.signal };
4123
- window.addEventListener("pointermove", onGestureMove, opts);
4124
- window.addEventListener("pointerup", endOnUp, opts);
4125
- window.addEventListener("pointercancel", endOnCancel, opts);
3721
+ win.addEventListener("pointermove", onGestureMove, opts);
3722
+ win.addEventListener("pointerup", endOnUp, opts);
3723
+ win.addEventListener("pointercancel", endOnCancel, opts);
4126
3724
  },
4127
3725
  { passive: true },
4128
3726
  );
@@ -4145,38 +3743,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4145
3743
  toggleSwitcher();
4146
3744
  });
4147
3745
 
4148
- // The kernel-driven bootstrap (sessionOwner.resolveInitialSession): list
4149
- // existing sessions or create the first one, adopt them, pick the start
4150
- // tab, bind the renderer to its store — and RETURN its ref rather than
4151
- // switching; the kernel performs the switch through the same path a tab
4152
- // switch uses. A null return (nothing could be listed or spawned) keeps
4153
- // the chrome up with the "+" retry alive, and the kernel — which now sees
4154
- // the failure directly — dismisses the loading overlay over it.
4155
- resolveImpl = async (): Promise<SessionRef | null> => {
4156
- // Initial population: list existing sessions, or create the first one.
4157
- let sessions: SessionInfo[];
4158
- try {
4159
- sessions = await api.list();
4160
- } catch {
4161
- sessions = [];
4162
- }
4163
- // Spawn a fresh session unless a LIVE one is listed. An ended session
4164
- // (exited cleanly OR crashed) is viewable history, not a working
4165
- // terminal — booting a page whose every session has died (the agent
4166
- // exited: a sign-in dead end, a crash) onto a corpse was the
4167
- // stuck-loading wedge. The dead ones are still adopted below (switch to
4168
- // them to read their final screen; close them by hand).
3746
+ // The bootstrap (paneLayoutOwner.resolveInitialLayout): list existing
3747
+ // sessions or create the first one, adopt them, pick the start tab from the
3748
+ // server's layout record, and show it. A false return (nothing could be
3749
+ // listed or spawned) keeps the chrome up with the "+" retry alive, and the
3750
+ // terminal dismisses the loading overlay over it. The record is read once
3751
+ // and never written back by a read.
3752
+ const resolveInitialLayout = async (): Promise<boolean> => {
3753
+ const [listed, layoutRead] = await Promise.allSettled([api.list(), api.getLayout()]);
3754
+ if (tornDown()) {
3755
+ return false;
3756
+ }
3757
+ let sessions: SessionInfo[] = listed.status === "fulfilled" ? listed.value : [];
3758
+ let layout: PaneLayout | null = null;
3759
+ if (layoutRead.status === "fulfilled") {
3760
+ layout = layoutRead.value;
3761
+ if (layout === null) {
3762
+ warnLayoutOnce("this server has no GET/PUT /api/sessions/layout route");
3763
+ }
3764
+ } else {
3765
+ warnLayoutOnce("could not read the pane layout", layoutRead.reason);
3766
+ }
3767
+ // A fresh session unless a LIVE one is listed: an ended session is viewable
3768
+ // history, not a working terminal, and a boot onto one would hang the
3769
+ // loading state. The dead ones are still adopted below.
4169
3770
  if (!sessions.some((s) => !isEndedStatus(s.status))) {
4170
3771
  try {
4171
- sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
3772
+ sessions = [...sessions, await createSessionHonouringRetry(api, ctx, lifetime.signal)];
4172
3773
  } catch (err) {
4173
- // Match the runtime create() path: toast and leave the chrome up so
4174
- // "+" can retry. Any exited sessions stay adopted (frozen screen +
4175
- // "Session ended" is still better than a blank page). A throw here
4176
- // would also be survivable (the kernel treats a rejected resolver
4177
- // as null), but the toast is the better UX. A 503 was already
4178
- // retried on the server's schedule, so this is the give-up path;
4179
- // carry the server's explanation when it gave one.
3774
+ if (tornDown()) {
3775
+ return false;
3776
+ }
3777
+ // The give-up path (a 503 was already retried on the server's schedule):
3778
+ // toast with the server's explanation and leave the chrome up so "+"
3779
+ // can retry, as the runtime create() does.
4180
3780
  ctx.toast(
4181
3781
  err instanceof SessionAPIError && err.serverMessage !== undefined
4182
3782
  ? `Couldn't open a terminal: ${err.serverMessage}`
@@ -4184,122 +3784,115 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4184
3784
  );
4185
3785
  }
4186
3786
  }
4187
- // Adopt (dedup) rather than blindly push. The status SSE pushes a
4188
- // snapshot of the existing sessions on open, and tabs subscribes
4189
- // (monitor.onStatus) BEFORE this list resolves, so a session may
4190
- // already have a tab by the time the list lands. A straight push
4191
- // doubled every already-adopted session (6 tabs from 3 across a fresh
4192
- // load), and paintActive then lit both copies of the active id
4193
- // ("2 active tabs" that move together).
3787
+ // Adopt rather than push: the status stream's open snapshot may already
3788
+ // have given a listed session its tab, since the subscription precedes
3789
+ // this list.
4194
3790
  for (const info of sessions) {
4195
3791
  adoptSession(info);
4196
3792
  }
4197
3793
  // From here on, tabs added at runtime (create / adopt) animate in.
4198
3794
  started = true;
4199
- // Something already activated a tab while the list was in flight. The
4200
- // chrome mounts synchronously, so a user can tap a chip (or a delivered
4201
- // notification can be clicked) before this resolves, and an explicit
4202
- // gesture outranks the saved id — return null and leave that switch alone;
4203
- // the kernel sees connectionInitiated and leaves the loading overlay to
4204
- // the normal ready path. ensureActive is deliberately NOT one of these
4205
- // callers during boot (see its `started` gate): when it was, the SSE
4206
- // snapshot won this race on every load and the ladder below never ran.
4207
- if (activeId !== null) {
4208
- return null;
3795
+ // A tap on a chip while the list was in flight outranks the record;
3796
+ // ensureActive is deliberately NOT one of these callers during boot (see
3797
+ // its bootShowedNothing gate), or the SSE snapshot would win this race on
3798
+ // every load.
3799
+ if (anyShown()) {
3800
+ return true;
4209
3801
  }
4210
- // Activate the previously-active session if it still exists, else the
4211
- // first (oldest). Session ids are stable server-side, so a page reload
4212
- // reconnects to the tab the user left on instead of always the oldest.
4213
- // Live sessions outrank ended ones (exited or crashed): the saved id is
4214
- // honored only while its session is still live (a reload used to restore
4215
- // straight onto the corpse of a died-while-away session and wedge
4216
- // there), and the default is the oldest LIVE tab. Only when nothing is
4217
- // live (the fresh-spawn above failed too) does a dead tab start — a
4218
- // frozen final screen with the "Session ended" banner beats a blank
4219
- // page.
3802
+ // The record's sessions where they still exist, else the oldest LIVE tab:
3803
+ // a reload must not restore onto the corpse of a session that died while
3804
+ // away. Only when nothing is live does a dead tab start, a frozen final
3805
+ // screen beating a blank page.
4220
3806
  const liveIds = new Set(sessions.filter((s) => !isEndedStatus(s.status)).map((s) => s.id));
4221
3807
  const oldestLive = tabList.find((t) => liveIds.has(t.id));
4222
- let startTab = oldestLive ?? tabList[0];
4223
- try {
4224
- const savedId = localStorage.getItem(ACTIVE_TAB_KEY);
4225
- if (savedId !== null && savedId !== "") {
4226
- const saved = tabList.find((x) => x.id === savedId);
4227
- if (saved && (liveIds.has(saved.id) || oldestLive === undefined)) {
4228
- startTab = saved;
3808
+ const saved = (id: string | null): Tab | undefined => {
3809
+ const tab = id === null ? undefined : tabList.find((x) => x.id === id);
3810
+ return tab && (liveIds.has(tab.id) || oldestLive === undefined) ? tab : undefined;
3811
+ };
3812
+ // Shown directly rather than through showIn: a boot announces no switch
3813
+ // and no selection, animates none, and writes no record.
3814
+ const showAtBoot = (side: PaneSide, tab: Tab): boolean => {
3815
+ const pane = ctx.shell.pane(side);
3816
+ if (pane === null) {
3817
+ return false;
3818
+ }
3819
+ pane.render.bind(tab.store);
3820
+ pane.notifySwitch({ id: tab.id });
3821
+ return true;
3822
+ };
3823
+ let shown = false;
3824
+ if (layout?.open === true) {
3825
+ const sides: Record<PaneSide, Tab | undefined> = {
3826
+ left: saved(layout.left),
3827
+ right: saved(layout.right),
3828
+ };
3829
+ // The record's side while it still has a tab to show, else the side that has one.
3830
+ const selected: PaneSide = sides[layout.selected]
3831
+ ? layout.selected
3832
+ : sides.left
3833
+ ? "left"
3834
+ : "right";
3835
+ applyingRecord = true;
3836
+ try {
3837
+ if ((sides.left || sides.right) && ctx.shell.restoreSplit(layout.handle, selected)) {
3838
+ for (const side of ["left", "right"] as const) {
3839
+ const tab = sides[side];
3840
+ if (tab) {
3841
+ shown = showAtBoot(side, tab) || shown;
3842
+ }
3843
+ }
4229
3844
  }
3845
+ } finally {
3846
+ applyingRecord = false;
4230
3847
  }
4231
- } catch {
4232
- /* storage unavailable — fall back to the oldest live tab */
4233
3848
  }
4234
- if (!startTab) {
4235
- return null;
3849
+ if (!shown) {
3850
+ const startTab =
3851
+ saved(
3852
+ layout === null ? null : layout.selected === "right" ? layout.right : layout.left,
3853
+ ) ??
3854
+ oldestLive ??
3855
+ tabList[0];
3856
+ shown = startTab !== undefined && showAtBoot(ctx.shell.targetFor(startTab.id), startTab);
4236
3857
  }
4237
- activeId = startTab.id;
4238
- try {
4239
- localStorage.setItem(ACTIVE_TAB_KEY, startTab.id);
4240
- } catch {
4241
- /* storage unavailable — non-fatal */
3858
+ if (!shown) {
3859
+ bootShowedNothing = true;
3860
+ return false;
4242
3861
  }
4243
- ctx.render.bind(startTab.store);
4244
3862
  syncChrome();
4245
3863
  focusInput();
4246
- return { id: startTab.id };
3864
+ return true;
4247
3865
  };
3866
+ live = { resolveInitialLayout, shownIn, showIn };
4248
3867
 
4249
3868
  return {
4250
3869
  api: {
4251
3870
  create,
4252
3871
  close: close_,
4253
3872
  switchTo,
4254
- list: () =>
4255
- tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
3873
+ snap,
3874
+ list: () => tabList.map((t) => ({ id: t.id, title: t.display, active: isShown(t.id) })),
4256
3875
  },
4257
3876
  teardown() {
4258
- // Restore the out-of-page surfaces first, while the sinks still exist.
4259
- // The kernel clears the title prefix on its own teardown, but the app
4260
- // badge and the icon links are OUR state and nothing else will clear
4261
- // them: the badge is OS-level and outlives the page entirely, and a
4262
- // destroyed terminal in an embedded panel leaves the document's icon
4263
- // pointed at a status variant for a tab that no longer exists.
4264
- // Change-gated, so this is a no-op when nothing was lit.
4265
- attention.apply(NO_ATTENTION);
4266
- tornDown = true;
4267
- resolveImpl = null;
4268
- offStatus?.();
4269
- offStreamOpen?.();
4270
- offHwKey();
4271
- offArmed?.();
4272
- offMenuKey();
4273
- document.removeEventListener("click", onDocClickMenu);
4274
- document.removeEventListener("visibilitychange", onPageVisible);
4275
- window.removeEventListener("pagehide", onPageGone);
4276
- window.removeEventListener("pageshow", onPageBack);
4277
- document.removeEventListener("contextmenu", onDocContextMenu);
4278
- document.removeEventListener("pointerup", onDocTapDismiss, true);
4279
- document.removeEventListener("dragover", onDocTabDrop);
4280
- document.removeEventListener("drop", onDocTabDrop);
4281
- window.removeEventListener("scroll", onScrollMenu, true);
4282
- if (pollTimer !== null) {
4283
- clearInterval(pollTimer);
3877
+ live = null;
3878
+ if (layoutWrite !== null) {
3879
+ win.clearTimeout(layoutWrite);
3880
+ layoutWrite = null;
4284
3881
  }
4285
- barResize.disconnect();
4286
- swReserve.disconnect();
4287
- stopInkCentring();
4288
- surface.classList.remove("wt-with-tabbar");
4289
3882
  varRoot.style.removeProperty("--wt-tabbar-h");
4290
- root?.classList.remove("wt-tabbed");
3883
+ root.classList.remove("wt-tabbed");
4291
3884
  varRoot.style.removeProperty("--wt-reserve-bottom");
4292
- endSwitchAnim(surface);
3885
+ endSwitchAnim();
4293
3886
  if (collapseClearTimer !== null) {
4294
- clearTimeout(collapseClearTimer);
3887
+ win.clearTimeout(collapseClearTimer);
4295
3888
  }
4296
3889
  endReelNow();
4297
3890
  gestureAbort?.abort(); // drop any in-flight gesture's window listeners
4298
3891
  endEdit(); // abandon an open rename without issuing a request
4299
3892
  clearDragGhost();
3893
+ paintDropHalf(null);
4300
3894
  endReorderPreview();
4301
3895
  endShift(); // leave no inline style on a chip this feature no longer owns
4302
- clearCatchup();
4303
3896
  hideTabMenu();
4304
3897
  for (const t of tabList) {
4305
3898
  t.aria.remove();
@@ -4312,7 +3905,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4312
3905
  deskKb.remove();
4313
3906
  tabMenu.remove();
4314
3907
  switcher.remove();
4315
- catchup.remove();
4316
3908
  },
4317
3909
  };
4318
3910
  },