@cplieger/web-terminal-ui 8.5.0 → 8.6.0-dev.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.5.0",
3
+ "version": "8.6.0-dev.2",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "MPL-2.0",
@@ -56,20 +56,20 @@
56
56
  "@stryker-mutator/vitest-runner": "10.0.0",
57
57
  "@types/node": "26.6.4",
58
58
  "@typescript/native": "npm:typescript@7.0.2",
59
- "@vitest/browser": "5.0.3",
60
- "@vitest/browser-playwright": "5.0.3",
61
- "@vitest/coverage-v8": "5.0.3",
59
+ "@vitest/browser": "5.0.4",
60
+ "@vitest/browser-playwright": "5.0.4",
61
+ "@vitest/coverage-v8": "5.0.4",
62
62
  "eslint": "10.12.0",
63
63
  "fast-check": "4.10.2",
64
64
  "html-validate": "11.16.2",
65
- "knip": "6.40.0",
65
+ "knip": "6.41.0",
66
66
  "playwright": "1.64.0",
67
67
  "prettier": "3.9.9",
68
68
  "stylelint": "17.16.0",
69
69
  "stylelint-config-standard": "40.0.0",
70
70
  "typescript": "npm:@typescript/typescript6@6.0.2",
71
71
  "typescript-eslint": "8.71.1",
72
- "vitest": "5.0.3"
72
+ "vitest": "5.0.4"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"
@@ -20,7 +20,13 @@ import { snapToDevicePixels } from "./device-snap.js";
20
20
  import { centreChipLabels } from "./ink-centre.js";
21
21
  import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
22
22
  import { createAddressBar, type AddressBar, type RouteOrigin } from "./address-bar.js";
23
- import { formatTabRoute, parseTabAlias, parseTabRoute, type TabAlias } from "./route.js";
23
+ import {
24
+ formatTabRoute,
25
+ parseTabAlias,
26
+ parseTabRoute,
27
+ type TabAlias,
28
+ type TabRoute,
29
+ } from "./route.js";
24
30
  import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
25
31
  import {
26
32
  CUE_SEEN_KEY,
@@ -169,9 +175,10 @@ export interface TabsOptions {
169
175
  attentionIcons?: boolean;
170
176
  }
171
177
 
172
- /** Session creation can be TEMPORARILY refused: a host answers 503 with
173
- * `Retry-After` and a body message while it installs tools on first boot, a
174
- * window that can run twenty minutes. Only 503 retries, on the server's own
178
+ /** The layout read, the session list and session creation can be TEMPORARILY
179
+ * refused: a host answers 503 with `Retry-After` and a body message while it
180
+ * installs tools on first boot or restores its sessions, a window that can run
181
+ * twenty minutes. Only 503 retries, on the server's own
175
182
  * schedule and repeating its own explanation. The bound is ELAPSED TIME, not an
176
183
  * attempt count, which at `Retry-After: 5` would give up in a minute; every
177
184
  * iteration sleeps at least the hint, so this is never a hot loop, and the user
@@ -228,20 +235,83 @@ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void>
228
235
  });
229
236
  }
230
237
 
238
+ /** How long a run of refusals has waited and when it last told the user. */
239
+ interface RefusalWait {
240
+ readonly startedAt: number;
241
+ // Null, NOT a 0 sentinel: elapsed is legitimately 0 when a host on the same
242
+ // machine refuses inside the first millisecond.
243
+ lastAnnouncedAt: number | null;
244
+ }
245
+
246
+ /** Wait out one temporary refusal: a 503, on the server's own `Retry-After`,
247
+ * repeating its own explanation. False means give up: `err` is not a 503, or
248
+ * the run of refusals has spent `CREATE_RETRY_MAX_TOTAL_MS`. An abort ends the
249
+ * wait early; the caller checks its signal next. */
250
+ async function waitOutRefusal(
251
+ err: unknown,
252
+ wait: RefusalWait,
253
+ ctx: TerminalContext,
254
+ signal: AbortSignal,
255
+ ): Promise<boolean> {
256
+ const win = windowOf(ctx.shell.root.ownerDocument);
257
+ const elapsed = win.Date.now() - wait.startedAt;
258
+ if (
259
+ !(err instanceof SessionAPIError) ||
260
+ err.status !== 503 ||
261
+ elapsed >= CREATE_RETRY_MAX_TOTAL_MS
262
+ ) {
263
+ return false;
264
+ }
265
+ // "waiting", not "retrying": the server only said "not yet", and the first
266
+ // announcement fires before any retry has happened.
267
+ if (
268
+ wait.lastAnnouncedAt === null ||
269
+ elapsed - wait.lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS
270
+ ) {
271
+ wait.lastAnnouncedAt = elapsed;
272
+ const reason = err.serverMessage ?? "Server is not ready yet";
273
+ ctx.toast(`${reason}, waiting…`, 8000);
274
+ ctx.announce(`${reason}, waiting…`);
275
+ }
276
+ // Unthrottled: the overlay is the only surface visible before the first
277
+ // frame, and it replaces text in place.
278
+ ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
279
+ await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
280
+ return true;
281
+ }
282
+
283
+ const isRefusal = (err: unknown): boolean => err instanceof SessionAPIError && err.status === 503;
284
+
285
+ interface SessionAPIClient {
286
+ list(): Promise<SessionInfo[]>;
287
+ getLayout(): Promise<PaneLayout | null>;
288
+ create(): Promise<SessionInfo>;
289
+ close(id: string): Promise<void>;
290
+ }
291
+
292
+ /** Close a session a page created after it was torn down: nothing holds it. */
293
+ function closeOrphan(api: Pick<SessionAPIClient, "close">, id: string): void {
294
+ void api.close(id).catch((err: unknown) => {
295
+ console.warn(
296
+ `web-terminal-ui: session ${id} was created after teardown and could not be closed`,
297
+ err,
298
+ );
299
+ });
300
+ }
301
+
231
302
  /** Create a session, honouring the server's retry schedule, for as long as
232
303
  * `signal` stays live: an abort ends the wait and throws its reason, and a
233
304
  * create that finishes after the abort is closed rather than returned, because
234
305
  * the page that asked for it has no tab left to hold it. */
235
306
  async function createSessionHonouringRetry(
236
- api: { create: () => Promise<SessionInfo>; close: (id: string) => Promise<void> },
307
+ api: Pick<SessionAPIClient, "create" | "close">,
237
308
  ctx: TerminalContext,
238
309
  signal: AbortSignal,
239
310
  ): Promise<SessionInfo> {
240
- const win = windowOf(ctx.shell.root.ownerDocument);
241
- const startedAt = win.Date.now();
242
- // Null, NOT a 0 sentinel: elapsed is legitimately 0 when a host on the same
243
- // machine refuses inside the first millisecond.
244
- let lastAnnouncedAt: number | null = null;
311
+ const wait: RefusalWait = {
312
+ startedAt: windowOf(ctx.shell.root.ownerDocument).Date.now(),
313
+ lastAnnouncedAt: null,
314
+ };
245
315
  for (;;) {
246
316
  signal.throwIfAborted();
247
317
  let info: SessionInfo;
@@ -249,41 +319,131 @@ async function createSessionHonouringRetry(
249
319
  info = await api.create();
250
320
  } catch (err) {
251
321
  signal.throwIfAborted();
252
- const elapsed = win.Date.now() - startedAt;
253
- if (
254
- !(err instanceof SessionAPIError) ||
255
- err.status !== 503 ||
256
- elapsed >= CREATE_RETRY_MAX_TOTAL_MS
257
- ) {
322
+ if (!(await waitOutRefusal(err, wait, ctx, signal))) {
258
323
  throw err;
259
324
  }
260
- // "waiting", not "retrying": the server only said "not yet", and the first
261
- // announcement fires before any retry has happened.
262
- if (lastAnnouncedAt === null || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
263
- lastAnnouncedAt = elapsed;
264
- const reason = err.serverMessage ?? "Server is not ready yet";
265
- ctx.toast(`${reason}, waiting…`, 8000);
266
- ctx.announce(`${reason}, waiting…`);
267
- }
268
- // Unthrottled: the overlay is the only surface visible before the first
269
- // frame, and it replaces text in place.
270
- ctx.loadingReason(`${err.serverMessage ?? "Server is not ready yet"}, waiting…`);
271
- await retryWait(win, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS, signal);
272
325
  continue;
273
326
  }
274
327
  if (signal.aborted) {
275
- void api.close(info.id).catch((err: unknown) => {
276
- console.warn(
277
- `web-terminal-ui: session ${info.id} was created after teardown and could not be closed`,
278
- err,
279
- );
280
- });
328
+ closeOrphan(api, info.id);
281
329
  signal.throwIfAborted();
282
330
  }
283
331
  return info;
284
332
  }
285
333
  }
286
334
 
335
+ /** What a settled server answered: its sessions, its layout record (null on a
336
+ * 404, an unreadable or a malformed record, each warned), and whether the list
337
+ * itself succeeded. */
338
+ interface Settled {
339
+ readonly sessions: SessionInfo[];
340
+ readonly layout: PaneLayout | null;
341
+ readonly listed: boolean;
342
+ }
343
+
344
+ const NOTHING_SETTLED: Settled = { sessions: [], layout: null, listed: false };
345
+
346
+ /** settleSessionSet's create failed for good; `settled` is what was listed. */
347
+ class SettleCreateFailed extends Error {
348
+ readonly settled: Settled;
349
+ constructor(cause: unknown, settled: Settled) {
350
+ super("web-terminal-ui: could not create a session", { cause });
351
+ this.name = "SettleCreateFailed";
352
+ this.settled = settled;
353
+ }
354
+ }
355
+
356
+ /** Read the layout, then list, then (when nothing live is listed) create one
357
+ * session, starting the turn again after any 503, so a
358
+ * create refused mid-restore never adds a tab beside the restored ones. A
359
+ * non-503 layout read proves the host was not restoring when it was served, so
360
+ * a list issued after it sees the whole restored set. Past
361
+ * `CREATE_RETRY_MAX_TOTAL_MS` a refused layout read or list returns the last
362
+ * good list, or rethrows the 503; a create that fails for good, a spent 503
363
+ * included, throws `SettleCreateFailed` carrying the list, an abort its
364
+ * reason. */
365
+ async function settleSessionSet(
366
+ api: SessionAPIClient,
367
+ ctx: TerminalContext,
368
+ signal: AbortSignal,
369
+ warnLayout: (what: string, err?: unknown) => void,
370
+ ): Promise<Settled> {
371
+ const wait: RefusalWait = {
372
+ startedAt: windowOf(ctx.shell.root.ownerDocument).Date.now(),
373
+ lastAnnouncedAt: null,
374
+ };
375
+ let last: Settled | null = null;
376
+ // True to go round again; false once the refusals spent their budget.
377
+ const waited = async (err: unknown): Promise<boolean> => {
378
+ signal.throwIfAborted();
379
+ const again = await waitOutRefusal(err, wait, ctx, signal);
380
+ signal.throwIfAborted();
381
+ return again;
382
+ };
383
+ for (;;) {
384
+ signal.throwIfAborted();
385
+ let layout: PaneLayout | null = null;
386
+ try {
387
+ layout = await api.getLayout();
388
+ if (layout === null) {
389
+ warnLayout("this server has no GET/PUT /api/sessions/layout route");
390
+ }
391
+ } catch (err) {
392
+ if (isRefusal(err)) {
393
+ if (await waited(err)) {
394
+ continue;
395
+ }
396
+ if (last !== null) {
397
+ return last;
398
+ }
399
+ throw err;
400
+ }
401
+ signal.throwIfAborted();
402
+ warnLayout("could not read the pane layout", err);
403
+ }
404
+ let sessions: SessionInfo[] = [];
405
+ let listed = false;
406
+ try {
407
+ sessions = await api.list();
408
+ listed = true;
409
+ } catch (err) {
410
+ if (isRefusal(err)) {
411
+ if (await waited(err)) {
412
+ continue;
413
+ }
414
+ if (last !== null) {
415
+ return last;
416
+ }
417
+ throw err;
418
+ }
419
+ signal.throwIfAborted();
420
+ }
421
+ const settled: Settled = { sessions, layout, listed };
422
+ if (listed) {
423
+ last = settled;
424
+ }
425
+ // An ended session is viewable history, not a working terminal.
426
+ if (sessions.some((s) => !isEndedStatus(s.status))) {
427
+ return settled;
428
+ }
429
+ let info: SessionInfo;
430
+ try {
431
+ info = await api.create();
432
+ } catch (err) {
433
+ if (isRefusal(err) && (await waited(err))) {
434
+ continue;
435
+ }
436
+ signal.throwIfAborted();
437
+ throw new SettleCreateFailed(err, settled);
438
+ }
439
+ if (signal.aborted) {
440
+ closeOrphan(api, info.id);
441
+ signal.throwIfAborted();
442
+ }
443
+ return { sessions: [...sessions, info], layout, listed };
444
+ }
445
+ }
446
+
287
447
  interface ShowOptions {
288
448
  readonly dir?: "next" | "prev" | undefined;
289
449
  /** Leave the selection where it is, unless the selected pane shows nothing. */
@@ -787,6 +947,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
787
947
  // Set by a bootstrap that showed nothing, cleared by the first showIn: the
788
948
  // one case a status event may pick a tab to show.
789
949
  let bootShowedNothing = false;
950
+ // While a reconcile that may resync onto a restarted server runs, a tab
951
+ // arriving on the stream is held rather than placed by arrival, which would
952
+ // beat the address and the record to the panes (withPlacementHold). A depth,
953
+ // because two stream reopens inside one long settle each hold.
954
+ let holdDepth = 0;
955
+ let heldPlacements: string[] = [];
956
+ // Whether a resync ran inside the current outermost hold; its release then
957
+ // places none of the held tabs, so a late arrival cannot replace a pane the
958
+ // address and the record restored.
959
+ let holdResynced = false;
960
+ // While a resync settles, the address bar is left alone so the dead tabs it
961
+ // drops write no history entries (scheduleAddress).
962
+ let settling = false;
790
963
  // The coalesced layout write (scheduleLayoutWrite): one PUT per macrotask,
791
964
  // one in flight at a time, and a change during the flight sends the latest
792
965
  // state once it lands, so an older record can never become the server's.
@@ -1078,7 +1251,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1078
1251
  const heldKeyboard = keyboardAtPressOf(rowClose);
1079
1252
  rowClose.addEventListener("click", (e) => {
1080
1253
  e.stopPropagation();
1081
- void dropTab(t.id, true, heldKeyboard());
1254
+ void dropTab(t.id, { remote: true, keyboardHeld: heldKeyboard() });
1082
1255
  });
1083
1256
  return row;
1084
1257
  }
@@ -1540,7 +1713,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1540
1713
  const heldKeyboard = keyboardAtPressOf(close);
1541
1714
  close.addEventListener("click", (e) => {
1542
1715
  e.stopPropagation();
1543
- void dropTab(tab.id, true, heldKeyboard());
1716
+ void dropTab(tab.id, { remote: true, keyboardHeld: heldKeyboard() });
1544
1717
  });
1545
1718
  // Middle-click closes the tab (#8). Suppress the middle-click default on
1546
1719
  // mousedown so the browser's autoscroll/paste affordance does not fire.
@@ -1685,7 +1858,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1685
1858
  ctx.shell.select(side);
1686
1859
  }
1687
1860
  scheduleLayoutWrite();
1688
- address?.schedule("push");
1861
+ scheduleAddress("push");
1689
1862
  // Only when the user is about to wait: a revisited tab with a warm store
1690
1863
  // paints from cache in one frame and must not flash a cue at every switch.
1691
1864
  if (
@@ -2402,7 +2575,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2402
2575
  // emptied is never refilled by a status event. A live tab outranks an ENDED
2403
2576
  // one, which would wedge the page the same way.
2404
2577
  function ensureActive(): void {
2405
- if (!bootShowedNothing || anyShown()) {
2578
+ if (holdDepth > 0 || !bootShowedNothing || anyShown()) {
2406
2579
  return;
2407
2580
  }
2408
2581
  const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
@@ -2444,6 +2617,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2444
2617
  // This client's own new session can reach the stream before its POST
2445
2618
  // answers, so the placement waits until a create in flight has shown its tab.
2446
2619
  function showCreatedElsewhere(id: string): void {
2620
+ if (holdDepth > 0) {
2621
+ heldPlacements.push(id);
2622
+ return;
2623
+ }
2447
2624
  if (creating !== null) {
2448
2625
  void creating.then(() => {
2449
2626
  showCreatedElsewhere(id);
@@ -2467,11 +2644,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2467
2644
  // the user rather than throwing. A 503 has already been retried on the
2468
2645
  // server's own schedule by this point, so reaching here means it never
2469
2646
  // became ready: say so with the server's own words when it gave any.
2470
- ctx.toast(
2471
- err instanceof SessionAPIError && err.serverMessage !== undefined
2472
- ? `Could not open a terminal. ${err.serverMessage}`
2473
- : "Could not open a terminal",
2474
- );
2647
+ toastCreateFailure(err);
2475
2648
  return;
2476
2649
  }
2477
2650
  // The status SSE may have adopted this session during the POST round-trip
@@ -2509,13 +2682,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2509
2682
  }
2510
2683
 
2511
2684
  // dropTab removes a tab's chrome + cache and, for a shown tab, decides what
2512
- // its pane shows next. remote=true also DELETEs the server session (a user
2513
- // close); remote=false is a local drop for a session the server already
2514
- // ended (an SSE removed event or a poll that no longer lists it), so no
2515
- // redundant DELETE is sent. keyboardHeld: the terminal input held the
2516
- // keyboard when the press on this tab's close button began, so focus goes
2517
- // back to it once the button that took it is gone.
2518
- async function dropTab(id: string, remote: boolean, keyboardHeld = false): Promise<void> {
2685
+ // its pane shows next. remote also DELETEs the session (a user close);
2686
+ // without it the server already ended it. keyboardHeld: the terminal input
2687
+ // held the keyboard when the press on this tab's close button began, so
2688
+ // focus returns to it. quiet: the caller (a resync) places what the panes
2689
+ // show next, so no pane transaction or focus move runs.
2690
+ async function dropTab(
2691
+ id: string,
2692
+ opts: { remote: boolean; keyboardHeld?: boolean; quiet?: boolean },
2693
+ ): Promise<void> {
2694
+ const { remote, keyboardHeld = false, quiet = false } = opts;
2519
2695
  const idx = tabList.findIndex((t) => t.id === id);
2520
2696
  if (idx < 0) {
2521
2697
  return;
@@ -2530,7 +2706,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2530
2706
  // A failed create adds nothing (and toasts): keep the existing tab
2531
2707
  // rather than stranding the user on an empty strip.
2532
2708
  if (tabList.some((t) => t.id !== id)) {
2533
- await dropTab(id, true);
2709
+ await dropTab(id, { remote: true });
2534
2710
  }
2535
2711
  return;
2536
2712
  }
@@ -2573,8 +2749,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2573
2749
  tab.el.remove();
2574
2750
  // Each connection forgets the session exactly once: the showing pane's
2575
2751
  // through clearActiveSession (which closes its socket and empties the
2576
- // pane), every other through dropSessionExcept.
2577
- if (side !== null && splitOpen) {
2752
+ // pane), every other through dropSessionExcept. A quiet drop empties a
2753
+ // single view's pane too, since no switch will replace what it shows.
2754
+ if (side !== null && (splitOpen || quiet)) {
2578
2755
  ctx.shell.pane(side)?.clearActiveSession();
2579
2756
  ctx.shell.dropSessionExcept(id, side);
2580
2757
  } else {
@@ -2589,24 +2766,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2589
2766
  syncChrome(); // reflect the drop immediately (count, position)
2590
2767
  // What the panes show next is decided here, before the server's answer:
2591
2768
  // selection must not sit on an emptied pane for a DELETE round trip.
2592
- let replacement: Promise<void> | undefined;
2593
- if (side !== null && splitOpen && shownIn(otherSide(side)) !== null) {
2594
- ctx.shell.split.closeSide(side);
2595
- focusAfterSwitch();
2596
- } else if (side !== null) {
2597
- if (splitOpen) {
2598
- ctx.shell.split.close();
2599
- }
2600
- const neighbor = tabList[idx] ?? tabList[idx - 1];
2601
- if (neighbor) {
2602
- switchTo(neighbor.id);
2603
- } else {
2604
- replacement = create();
2769
+ if (!quiet) {
2770
+ placeAfterDrop(idx, side, splitOpen);
2771
+ if (typing || keyboardHeld) {
2772
+ focusInput();
2605
2773
  }
2606
2774
  }
2607
- if (typing || keyboardHeld) {
2608
- focusInput();
2609
- }
2610
2775
  if (remote) {
2611
2776
  try {
2612
2777
  await api.close(id);
@@ -2616,11 +2781,35 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2616
2781
  }
2617
2782
  }
2618
2783
  }
2619
- await replacement;
2784
+ }
2785
+
2786
+ // The pane transaction for a dropped tab that `side` showed (null: none).
2787
+ function placeAfterDrop(idx: number, side: PaneSide | null, splitOpen: boolean): void {
2788
+ if (side === null) {
2789
+ return;
2790
+ }
2791
+ if (splitOpen && shownIn(otherSide(side)) !== null) {
2792
+ ctx.shell.split.closeSide(side);
2793
+ focusAfterSwitch();
2794
+ return;
2795
+ }
2796
+ if (splitOpen) {
2797
+ ctx.shell.split.close();
2798
+ }
2799
+ const neighbor = tabList[idx] ?? tabList[idx - 1];
2800
+ if (neighbor) {
2801
+ switchTo(neighbor.id);
2802
+ return;
2803
+ }
2804
+ // Only the server removes a last tab here (a user's close of it took the
2805
+ // create-first branch of dropTab). Its server either restarted or saw
2806
+ // another device replace it, so settle rather than create a second tab.
2807
+ // Not awaited: this may run inside the reconcile slot itself.
2808
+ void inReconcileSlot(() => resync([]));
2620
2809
  }
2621
2810
 
2622
2811
  async function close_(id: string): Promise<void> {
2623
- await dropTab(id, true);
2812
+ await dropTab(id, { remote: true });
2624
2813
  }
2625
2814
 
2626
2815
  // Each tab is a running agent, so closing two or more asks first. This path
@@ -3108,7 +3297,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3108
3297
  const alias = parseTabAlias(rec.alias) ?? undefined;
3109
3298
  if (alias !== t.alias) {
3110
3299
  t.alias = alias;
3111
- address?.schedule("replace");
3300
+ scheduleAddress("replace");
3112
3301
  }
3113
3302
  }
3114
3303
  paintStatusDot(t.dot, rec.status, reports);
@@ -3177,15 +3366,55 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3177
3366
  // server lists, then drop tabs it no longer lists. The SSE path runs it on
3178
3367
  // every (re)open because a RESTARTED manager's snapshot carries no
3179
3368
  // tombstones for sessions it never knew, so those tabs would otherwise spin
3180
- // "Reconnecting…" forever. An overlapping call joins the run in flight
3181
- // rather than starting another, so a caller that awaits it sees a listing.
3369
+ // "Reconnecting…" forever. reconcileOnce joins the run in flight, so a
3370
+ // caller that awaits it sees a listing; inReconcileSlot queues behind it.
3182
3371
  let reconciling: Promise<void> | null = null;
3183
- const reconcileOnce = (): Promise<void> => {
3184
- reconciling ??= reconcileNow().finally(() => {
3185
- reconciling = null;
3186
- });
3187
- return reconciling;
3188
- };
3372
+ // Runs fn in the single reconcile slot: it never joins, it queues behind the
3373
+ // run in flight. The slot never rejects, so a failed run cannot skip the
3374
+ // runs queued behind it, and only the run still stored clears it.
3375
+ function inReconcileSlot(fn: () => Promise<void>): Promise<void> {
3376
+ const slot: Promise<void> = (reconciling ?? Promise.resolve())
3377
+ .then(() => fn())
3378
+ .catch((err: unknown) => {
3379
+ console.warn("web-terminal-ui: tab reconcile failed", err);
3380
+ })
3381
+ .finally(() => {
3382
+ if (reconciling === slot) {
3383
+ reconciling = null;
3384
+ }
3385
+ });
3386
+ reconciling = slot;
3387
+ return slot;
3388
+ }
3389
+ const reconcileOnce = (): Promise<void> => reconciling ?? inReconcileSlot(reconcileNow);
3390
+ // Placement of a tab arriving on the stream waits until fn ends. Then, unless
3391
+ // a resync ran meanwhile, the held tabs still open are placed as they would
3392
+ // have been; either way a page that booted onto nothing gets its repair.
3393
+ async function withPlacementHold(fn: () => Promise<void>): Promise<void> {
3394
+ holdDepth++;
3395
+ try {
3396
+ await fn();
3397
+ } finally {
3398
+ holdDepth--;
3399
+ if (holdDepth === 0) {
3400
+ const held = holdResynced ? [] : heldPlacements;
3401
+ heldPlacements = [];
3402
+ holdResynced = false;
3403
+ for (const id of held) {
3404
+ if (tabList.some((t) => t.id === id)) {
3405
+ showCreatedElsewhere(id);
3406
+ }
3407
+ }
3408
+ ensureActive();
3409
+ }
3410
+ }
3411
+ }
3412
+ // While settling, the dead tabs a resync drops must write no history entry.
3413
+ function scheduleAddress(mode: "push" | "replace"): void {
3414
+ if (!settling) {
3415
+ address?.schedule(mode);
3416
+ }
3417
+ }
3189
3418
  async function reconcileNow(): Promise<void> {
3190
3419
  // The listing is authoritative only for tabs that existed when it was
3191
3420
  // requested: a tab adopted while the GET was in flight is invisible to
@@ -3201,6 +3430,16 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3201
3430
  return;
3202
3431
  }
3203
3432
  const seen = new Set(list.map((s) => s.id));
3433
+ // Every tab the listing could have named is missing from it: the server
3434
+ // restarted (or replaced every tab). Decided before the adopt loop, so the
3435
+ // tabs it lists are placed by the address and the record, not by arrival.
3436
+ // Because this branch takes every such case, the gone loop below drops a
3437
+ // strict subset of the known tabs and never reaches dropTab's last-tab arm.
3438
+ const known = tabList.filter((t) => t.born <= epochAtList).map((t) => t.id);
3439
+ if (known.length > 0 && known.every((id) => !seen.has(id))) {
3440
+ await resync(known);
3441
+ return;
3442
+ }
3204
3443
  for (const info of list) {
3205
3444
  adoptSession(info); // add sessions created elsewhere (no local tab)
3206
3445
  applyStatus(info, reportsOf(info.reportsActivity));
@@ -3211,11 +3450,63 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3211
3450
  .filter((t) => !seen.has(t.id) && t.born <= epochAtList)
3212
3451
  .map((t) => t.id);
3213
3452
  for (const id of gone) {
3214
- await dropTab(id, false);
3453
+ await dropTab(id, { remote: false });
3215
3454
  }
3216
3455
  ensureActive();
3217
3456
  syncChrome();
3218
3457
  }
3458
+ // Rebuild the page onto a server whose sessions replaced `known` (a restart,
3459
+ // or the server's last tab removed): drop them without placing anything,
3460
+ // settle the new set, then place it the way a boot does, by the address and
3461
+ // the record. Runs inside the reconcile slot.
3462
+ async function resync(known: readonly string[]): Promise<void> {
3463
+ await withPlacementHold(async () => {
3464
+ holdResynced = true;
3465
+ const typing = keyboardInPaneInput();
3466
+ settling = true;
3467
+ let placed: "kept" | "shown" | "none" = "none";
3468
+ try {
3469
+ for (const id of known) {
3470
+ await dropTab(id, { remote: false, quiet: true });
3471
+ }
3472
+ // showSettled reopens a split through restoreSplit, which refuses an
3473
+ // open one; closed under applyingRecord so no record write goes out.
3474
+ if (ctx.shell.split.isOpen()) {
3475
+ applyingRecord = true;
3476
+ try {
3477
+ ctx.shell.split.close();
3478
+ } finally {
3479
+ applyingRecord = false;
3480
+ }
3481
+ }
3482
+ const settled = await settleOrToast();
3483
+ if (tornDown()) {
3484
+ return;
3485
+ }
3486
+ for (const info of settled.sessions) {
3487
+ adoptSession(info);
3488
+ applyStatus(info, reportsOf(info.reportsActivity));
3489
+ }
3490
+ // Read now, so an address the user navigated to during the wait wins.
3491
+ const route =
3492
+ address !== null && settled.listed ? parseTabRoute(win.location.hash) : null;
3493
+ // No stale-tab toast: the user did not open this address in this act.
3494
+ placed = showSettled(settled, route, false);
3495
+ } finally {
3496
+ settling = false;
3497
+ if (placed === "none") {
3498
+ bootShowedNothing = true;
3499
+ }
3500
+ if (!tornDown()) {
3501
+ syncChrome();
3502
+ if (typing) {
3503
+ focusInput();
3504
+ }
3505
+ scheduleAddress("replace");
3506
+ }
3507
+ }
3508
+ });
3509
+ }
3219
3510
  function warnLayoutOnce(what: string, err?: unknown): void {
3220
3511
  if (warnedLayout) {
3221
3512
  return;
@@ -3411,7 +3702,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3411
3702
  syncCatchups();
3412
3703
  syncChrome();
3413
3704
  scheduleLayoutWrite();
3414
- address?.schedule("push");
3705
+ scheduleAddress("push");
3415
3706
  }),
3416
3707
  );
3417
3708
  ctx.defer(ctx.shell.onSelectionChange(syncChrome));
@@ -3427,7 +3718,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3427
3718
  if (monitor) {
3428
3719
  const offStatus = monitor.onStatus((s) => {
3429
3720
  if (s.removed) {
3430
- void dropTab(s.id, false); // already gone server-side; no DELETE
3721
+ void dropTab(s.id, { remote: false }); // already gone server-side; no DELETE
3431
3722
  return;
3432
3723
  }
3433
3724
  adoptSession(s);
@@ -3448,8 +3739,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3448
3739
  });
3449
3740
  });
3450
3741
  ctx.defer(offStatus);
3742
+ // The first open joins the boot's reconcile. A reopen queues a fresh one
3743
+ // under a placement hold, so its decision comes from a list issued after
3744
+ // the reopen and a restarted server's tabs are placed by resync.
3745
+ let streamOpens = 0;
3451
3746
  const offStreamOpen = monitor.onStreamOpen?.(() => {
3452
- void reconcileOnce();
3747
+ streamOpens++;
3748
+ if (streamOpens === 1) {
3749
+ void reconcileOnce();
3750
+ } else {
3751
+ void withPlacementHold(() => inReconcileSlot(reconcileNow));
3752
+ }
3453
3753
  });
3454
3754
  if (offStreamOpen) {
3455
3755
  ctx.defer(offStreamOpen);
@@ -4257,62 +4557,85 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4257
4557
  toggleSwitcher();
4258
4558
  });
4259
4559
 
4260
- // The bootstrap (paneLayoutOwner.resolveInitialLayout): list existing
4261
- // sessions or create the first one, adopt them, pick the start tab from the
4262
- // server's layout record, and show it. A false return (nothing could be
4263
- // listed or spawned) keeps the chrome up with the "+" retry alive, and the
4560
+ // The bootstrap (paneLayoutOwner.resolveInitialLayout): settle the session
4561
+ // set (creating the first session when nothing live is listed), adopt it,
4562
+ // pick the start tab and show it. A false return (nothing could be listed
4563
+ // or spawned) keeps the chrome up with the "+" retry alive, and the
4264
4564
  // terminal dismisses the loading overlay over it. The record is read once
4265
4565
  // and never written back by a read.
4266
4566
  const resolveInitialLayout = async (): Promise<boolean> => {
4267
- const [listed, layoutRead] = await Promise.allSettled([api.list(), api.getLayout()]);
4567
+ const settled = await settleOrToast();
4268
4568
  if (tornDown()) {
4269
4569
  return false;
4270
4570
  }
4271
- let sessions: SessionInfo[] = listed.status === "fulfilled" ? listed.value : [];
4272
- let layout: PaneLayout | null = null;
4273
- if (layoutRead.status === "fulfilled") {
4274
- layout = layoutRead.value;
4275
- if (layout === null) {
4276
- warnLayoutOnce("this server has no GET/PUT /api/sessions/layout route");
4277
- }
4278
- } else {
4279
- warnLayoutOnce("could not read the pane layout", layoutRead.reason);
4280
- }
4281
- // A fresh session unless a LIVE one is listed: an ended session is viewable
4282
- // history, not a working terminal, and a boot onto one would hang the
4283
- // loading state. The dead ones are still adopted below.
4284
- if (!sessions.some((s) => !isEndedStatus(s.status))) {
4285
- try {
4286
- sessions = [...sessions, await createSessionHonouringRetry(api, ctx, lifetime.signal)];
4287
- } catch (err) {
4288
- if (tornDown()) {
4289
- return false;
4290
- }
4291
- // The give-up path (a 503 was already retried on the server's schedule):
4292
- // toast with the server's explanation and leave the chrome up so "+"
4293
- // can retry, as the runtime create() does.
4294
- ctx.toast(
4295
- err instanceof SessionAPIError && err.serverMessage !== undefined
4296
- ? `Could not open a terminal. ${err.serverMessage}`
4297
- : "Could not open a terminal",
4298
- );
4299
- }
4300
- }
4301
4571
  // Adopt rather than push: the status stream's open snapshot may already
4302
4572
  // have given a listed session its tab, since the subscription precedes
4303
4573
  // this list.
4304
- for (const info of sessions) {
4574
+ for (const info of settled.sessions) {
4305
4575
  adoptSession(info);
4306
4576
  }
4307
4577
  // From here on, tabs added at runtime (create / adopt) animate in.
4308
4578
  started = true;
4309
- // A tap on a chip while the list was in flight outranks the record;
4310
- // ensureActive is deliberately NOT one of these callers during boot (see
4311
- // its bootShowedNothing gate), or the SSE snapshot would win this race on
4312
- // every load.
4579
+ // Read only with the server's list in hand, so a failed list never
4580
+ // declares a tab gone, and only once settled, so a wait keeps its intent.
4581
+ const route = address !== null && settled.listed ? parseTabRoute(win.location.hash) : null;
4582
+ switch (showSettled(settled, route, address?.bootOrigin() === "deeplink")) {
4583
+ case "kept":
4584
+ address?.start(settled.listed);
4585
+ return true;
4586
+ case "shown":
4587
+ syncChrome();
4588
+ focusInput();
4589
+ address?.start(settled.listed);
4590
+ return true;
4591
+ case "none":
4592
+ bootShowedNothing = true;
4593
+ address?.start(false);
4594
+ return false;
4595
+ }
4596
+ };
4597
+
4598
+ // settleSessionSet, toasting a failure the way a failed create always has
4599
+ // and carrying on with whatever was listed.
4600
+ async function settleOrToast(): Promise<Settled> {
4601
+ try {
4602
+ return await settleSessionSet(api, ctx, lifetime.signal, warnLayoutOnce);
4603
+ } catch (err) {
4604
+ if (tornDown()) {
4605
+ return NOTHING_SETTLED;
4606
+ }
4607
+ const failure = err instanceof SettleCreateFailed ? err : null;
4608
+ toastCreateFailure(failure === null ? err : failure.cause);
4609
+ return failure?.settled ?? NOTHING_SETTLED;
4610
+ }
4611
+ }
4612
+
4613
+ // The give-up path (a 503 was already retried on the server's schedule):
4614
+ // toast with the server's explanation and leave the chrome up so "+" can
4615
+ // retry.
4616
+ function toastCreateFailure(err: unknown): void {
4617
+ ctx.toast(
4618
+ err instanceof SessionAPIError && err.serverMessage !== undefined
4619
+ ? `Could not open a terminal. ${err.serverMessage}`
4620
+ : "Could not open a terminal",
4621
+ );
4622
+ }
4623
+
4624
+ // Place a settled set: "kept" when a pane already shows a tab (a tap while
4625
+ // the list was in flight outranks the record), "shown" when the address,
4626
+ // the record or a fallback placed one, "none" when nothing could be shown.
4627
+ // toastStale says whether an address naming a closed tab is news to the user.
4628
+ function showSettled(
4629
+ settled: Settled,
4630
+ route: TabRoute | null,
4631
+ toastStale: boolean,
4632
+ ): "kept" | "shown" | "none" {
4633
+ const { sessions, layout } = settled;
4634
+ // ensureActive is deliberately NOT one of this function's callers during
4635
+ // boot (see its bootShowedNothing gate), or the SSE snapshot would win
4636
+ // this race on every load.
4313
4637
  if (anyShown()) {
4314
- address?.start(listed.status === "fulfilled");
4315
- return true;
4638
+ return "kept";
4316
4639
  }
4317
4640
  // The record's sessions where they still exist, else the oldest LIVE tab:
4318
4641
  // a reload must not restore onto the corpse of a session that died while
@@ -4337,13 +4660,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4337
4660
  };
4338
4661
  let shown = false;
4339
4662
  // The address outranks the record when it names an open tab: it is the
4340
- // explicit instruction for this load. Read only with the server's list in
4341
- // hand, so a failed list never declares a tab gone.
4342
- const route =
4343
- address !== null && listed.status === "fulfilled"
4344
- ? parseTabRoute(win.location.hash)
4345
- : null;
4346
- if (route !== null && address !== null) {
4663
+ // explicit instruction for this load.
4664
+ if (route !== null) {
4347
4665
  const [wantLeft, wantRight] =
4348
4666
  route.kind === "single" ? [route.tab, null] : [route.left, route.right];
4349
4667
  const fromRoute = (alias: TabAlias | null): Tab | undefined => {
@@ -4358,7 +4676,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4358
4676
  // refused here only because a boot never lands on a corpse.
4359
4677
  const gone = (alias: TabAlias | null): boolean =>
4360
4678
  alias !== null && tabByAlias(alias) === undefined;
4361
- if ((gone(wantLeft) || gone(wantRight)) && address.bootOrigin() === "deeplink") {
4679
+ if ((gone(wantLeft) || gone(wantRight)) && toastStale) {
4362
4680
  ctx.toast(STALE_TAB_NOTICE);
4363
4681
  }
4364
4682
  applyingRecord = true;
@@ -4427,16 +4745,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4427
4745
  tabList[0];
4428
4746
  shown = startTab !== undefined && showAtBoot(ctx.shell.targetFor(startTab.id), startTab);
4429
4747
  }
4430
- if (!shown) {
4431
- bootShowedNothing = true;
4432
- address?.start(false);
4433
- return false;
4434
- }
4435
- syncChrome();
4436
- focusInput();
4437
- address?.start(listed.status === "fulfilled");
4438
- return true;
4439
- };
4748
+ return shown ? "shown" : "none";
4749
+ }
4440
4750
  live = { resolveInitialLayout, shownIn, showIn };
4441
4751
 
4442
4752
  return {