@cplieger/web-terminal-ui 4.6.0 → 4.7.1

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.
@@ -13,16 +13,23 @@ import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/
13
13
  import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
15
  import { fromHTML } from "../dom.js";
16
- import { placeMenuAt } from "../menu-position.js";
16
+ import { createClickSwallow, placeMenuAt } from "../menu-position.js";
17
17
  import type { SessionInfo, Tab } from "./model.js";
18
18
  import {
19
19
  ACTIVE_TAB_KEY,
20
+ MAX_PINNED_NAME,
20
21
  STATUS_EXITED,
21
22
  SWIPE_HINT_KEY,
23
+ SessionAPIError,
24
+ TAB_ORDER_KEY,
22
25
  baseLabel,
23
- createInputTitleDeriver,
24
26
  createSessionAPI,
25
27
  createTombstones,
28
+ hasPinnedName,
29
+ orderedInsertIndex,
30
+ parseTabOrder,
31
+ sanitizePinnedName,
32
+ serializeTabOrder,
26
33
  } from "./model.js";
27
34
  import {
28
35
  TAB_HTML,
@@ -59,6 +66,14 @@ const DEFAULT_API_BASE = "/api/sessions";
59
66
  // (pointer: coarse) where touch matters.
60
67
  // Default cadence for the no-activityMonitor polling fallback.
61
68
  const DEFAULT_POLL_MS = 4000;
69
+ // The drag data type carrying a reordered tab's session id. A PRIVATE type, not
70
+ // text/plain, deliberately: WebKit resolves dropped plain text as a URL and
71
+ // NAVIGATES to it when no handler cancels the drop, so a bare session id read as
72
+ // a relative path — dropping a tab on iPadOS loaded /<session-id> instead of
73
+ // reordering. A custom type also keeps the id out of the system pasteboard when
74
+ // the drag leaves the browser, which is what MDN's recommended-drag-types
75
+ // guidance prescribes for data specific to one application.
76
+ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
62
77
  // Tab context-menu viewport clamping + the flip-above-the-pointer gap live in
63
78
  // the shared point-anchored positioner (menu-position.ts), shared with the
64
79
  // terminal context menu (formerly two hand-synced copies of the same math).
@@ -92,14 +107,6 @@ export interface TabsOptions {
92
107
  * toolbar should be built with { externalToggle: true } so its own top-right
93
108
  * toggle is hidden and the grid opens above the bar. */
94
109
  keyboardToggle?: TerminalFeature<MobileToolbarApi>;
95
- /** Prefer the input-derived title over the process OSC 0/2 title. Default
96
- * false (OSC-first: a program that sets its own window title wins, else the
97
- * last submitted line). Set true for an agent shell whose program emits a
98
- * non-empty but useless OSC title (kiro-cli under web-terminal-kiro): the label then
99
- * follows the latest submitted line (live) or the persisted client title (on
100
- * reload), and the unreliable OSC title is ignored entirely. presetAgentTabbed
101
- * enables this. */
102
- preferInputTitle?: boolean;
103
110
  /** Presume every session reports activity (an agent shell, where the program
104
111
  * always emits OSC 9;4 progress): each tab's dot is visible as idle from
105
112
  * creation instead of popping in seconds later when the agent has booted
@@ -118,6 +125,105 @@ export interface TabsOptions {
118
125
  // to latch a "physical keyboard present" flag that also covers a keyboard-only
119
126
  // Smart Keyboard Folio (which, unlike a Magic Keyboard, adds no trackpad and so
120
127
  // does not match `any-pointer: fine`).
128
+ /** Session creation can be legitimately and TEMPORARILY refused, which is not the
129
+ * same event as a broken server. web-terminal-kiro answers 503 with
130
+ * `Retry-After: 5` and a body message while its tool engine installs the
131
+ * manifest's tools on first boot, a window its own HEALTHCHECK budgets 20
132
+ * minutes for. That state used to reach the user as the same fixed "Couldn't
133
+ * open a terminal" toast as a 500, with no retry: the page looked broken while
134
+ * the server was deliberately waiting, and `/api/health` reported healthy at the
135
+ * same time, so the two channels contradicted each other.
136
+ *
137
+ * So honour what the server published: retry on its own schedule, and repeat its
138
+ * own explanation rather than inventing library wording for a host-specific
139
+ * condition. Only 503 retries; a 429 rate limit, a 4xx, or a 500 still fails
140
+ * fast, because those are not "come back shortly".
141
+ *
142
+ * The bound is ELAPSED TIME, not an attempt count. An attempt cap interacts
143
+ * badly with the server's hint: at web-terminal-kiro's `Retry-After: 5` a dozen
144
+ * attempts is only a minute, while the window this exists for can run twenty
145
+ * (toolbelt's boot job is bounded at 30 minutes, which is why that app's
146
+ * HEALTHCHECK carries --start-period=20m), so the retry would give up long
147
+ * before the server was ready and the whole fix would miss its case. Waiting is
148
+ * cheap here because every iteration sleeps at least the server's hint, so this
149
+ * is never a hot loop. The user is re-told periodically rather than once,
150
+ * because a page that silently retries for twenty minutes is its own kind of
151
+ * broken. */
152
+ const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
153
+ const CREATE_RETRY_FALLBACK_MS = 5000;
154
+ const CREATE_RETRY_REANNOUNCE_MS = 60000;
155
+
156
+ /** orderSignature collapses an arrangement into one comparable string, so the
157
+ * persist path can decide "did the order change?" with a string compare instead
158
+ * of re-serializing and re-writing on every status tick. NUL is the separator
159
+ * because a session id can never contain one. */
160
+ function orderSignature(ids: readonly string[]): string {
161
+ return ids.join("\u0000");
162
+ }
163
+
164
+ /** readSavedOrder loads the persisted tab arrangement, or [] when there is none
165
+ * to trust. Storage itself can throw — Safari in private mode, a disabled
166
+ * third-party context, an embedder's iframe — and an unreadable arrangement is
167
+ * never fatal: the strip falls back to the server's creation order, exactly as
168
+ * it behaved before arrangements were persisted. */
169
+ function readSavedOrder(): string[] {
170
+ try {
171
+ return parseTabOrder(localStorage.getItem(TAB_ORDER_KEY));
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ /** writeSavedOrder persists an arrangement, best-effort: a full quota or an
178
+ * unavailable store must not break a reorder the user just performed on screen.
179
+ * The in-memory arrangement stays authoritative for this page either way. */
180
+ function writeSavedOrder(ids: readonly string[]): void {
181
+ try {
182
+ localStorage.setItem(TAB_ORDER_KEY, serializeTabOrder(ids));
183
+ } catch {
184
+ /* storage unavailable or full — the order still holds for this page */
185
+ }
186
+ }
187
+
188
+ async function createSessionHonouringRetry(
189
+ api: { create: () => Promise<SessionInfo> },
190
+ ctx: TerminalContext,
191
+ isTornDown: () => boolean,
192
+ ): Promise<SessionInfo> {
193
+ const startedAt = Date.now();
194
+ let lastAnnouncedAt = 0;
195
+ for (;;) {
196
+ try {
197
+ return await api.create();
198
+ } catch (err) {
199
+ const elapsed = Date.now() - startedAt;
200
+ if (
201
+ !(err instanceof SessionAPIError) ||
202
+ err.status !== 503 ||
203
+ elapsed >= CREATE_RETRY_MAX_TOTAL_MS
204
+ ) {
205
+ throw err;
206
+ }
207
+ // First refusal always speaks; after that only every
208
+ // CREATE_RETRY_REANNOUNCE_MS, so a multi-minute wait is neither silent nor
209
+ // a toast storm.
210
+ if (lastAnnouncedAt === 0 || elapsed - lastAnnouncedAt >= CREATE_RETRY_REANNOUNCE_MS) {
211
+ lastAnnouncedAt = elapsed;
212
+ const waiting = err.serverMessage ?? "Server is not ready yet";
213
+ ctx.toast(`${waiting}; retrying`, 8000);
214
+ ctx.announce(`${waiting}; retrying`);
215
+ }
216
+ await new Promise((resolve) => {
217
+ window.setTimeout(resolve, err.retryAfterMs ?? CREATE_RETRY_FALLBACK_MS);
218
+ });
219
+ // A reload or destroy during the wait must not resurrect a create.
220
+ if (isTornDown()) {
221
+ throw err;
222
+ }
223
+ }
224
+ }
225
+ }
226
+
121
227
  function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
122
228
  if (ev.ctrlKey || ev.metaKey || ev.altKey) {
123
229
  return true;
@@ -141,7 +247,6 @@ function looksLikeHardwareKey(ev: KeyboardEvent): boolean {
141
247
 
142
248
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
143
249
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
144
- const preferInputTitle = opts.preferInputTitle ?? false;
145
250
  const presumeReports = opts.presumeReports ?? false;
146
251
  // reportsOf floors the server's per-session reportsActivity flag with the
147
252
  // consumer's presumption (see TabsOptions.presumeReports): an agent shell
@@ -160,6 +265,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
160
265
  // setup), while the bootstrap needs setup-scoped state — so it delegates to
161
266
  // a closure setup() wires. resolveImpl is nulled on teardown.
162
267
  let resolveImpl: (() => Promise<SessionRef | null>) | null = null;
268
+ // Set on teardown so an in-flight create RETRY (which sleeps between attempts,
269
+ // potentially across a multi-minute install window) cannot resurrect a session
270
+ // for a feature that is already gone.
271
+ let tornDown = false;
163
272
  return {
164
273
  name: "tabs",
165
274
  sessionOwner: {
@@ -463,6 +572,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
463
572
  // /api/sessions) that predates the server reaping the session does not
464
573
  // re-adopt (flash back) a closed tab.
465
574
  const tombstones = createTombstones();
575
+ // The user's persisted tab arrangement (model.ts TAB_ORDER_KEY): read ONCE
576
+ // at setup, before any session can be adopted, so every adopt — from the
577
+ // status-stream snapshot or from the bootstrap's list, in either order —
578
+ // can place its tab at the saved position. Read-only for the life of the
579
+ // page: it is the arrangement to RESTORE, and the live strip must not
580
+ // overwrite it (see persistOrder).
581
+ const savedOrder: readonly string[] = readSavedOrder();
582
+ // Signature of the arrangement currently on disk, so persistOrder can run
583
+ // from syncChrome (which fires on every status tick) and still write only
584
+ // when the sequence actually changed. Seeded from what was read, so a
585
+ // window that simply restored an arrangement writes nothing.
586
+ let persistedSig = orderSignature(savedOrder);
466
587
  // The expanded mobile list's row elements, keyed by tab id. Rows are
467
588
  // reused across re-renders (reconcile, not rebuild) so a swipe can FLIP the
468
589
  // same elements from their old slots to their new ones (the rotation).
@@ -514,14 +635,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
514
635
  // read "New tab" without a numeric suffix.
515
636
  const counts = new Map<string, number>();
516
637
  for (const t of tabList) {
517
- const { text, fallback } = baseLabel(t, preferInputTitle);
638
+ const { text, fallback } = baseLabel(t);
518
639
  if (!fallback) {
519
640
  counts.set(text, (counts.get(text) ?? 0) + 1);
520
641
  }
521
642
  }
522
643
  const seen = new Map<string, number>();
523
644
  for (const t of tabList) {
524
- const { text, fallback } = baseLabel(t, preferInputTitle);
645
+ const { text, fallback } = baseLabel(t);
525
646
  let display = text;
526
647
  if (!fallback && (counts.get(text) ?? 0) > 1) {
527
648
  const k = (seen.get(text) ?? 0) + 1;
@@ -536,26 +657,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
536
657
  }
537
658
  }
538
659
 
539
- // --- Fallback title derivation from the user's submitted lines ---
540
- // The line-editor state machine lives in model.ts
541
- // (createInputTitleDeriver); this wires its submissions to the active
542
- // tab. Used only when the process sets no OSC window title of its own
543
- // (baseLabel prefers tab.title). The kernel routes input to the active
544
- // session, so an input observer's bytes belong to activeId. Every
545
- // non-empty submitted line updates the fallback title (the last one
546
- // wins) and is persisted server-side (engine PUT .../title) so a reload
547
- // and other devices recover this "latest message" label; the engine uses
548
- // it only when the program emits no OSC title.
549
- const titleDeriver = createInputTitleDeriver((line) => {
550
- const t = tabList.find((x) => x.id === activeId);
551
- if (!t) {
552
- return; // no active tab
553
- }
554
- t.derived = line;
555
- void api.setTitle(t.id, t.derived);
556
- syncChrome();
557
- });
558
-
559
660
  function focusInput(): void {
560
661
  ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
561
662
  }
@@ -572,7 +673,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
572
673
  for (const t of tabList) {
573
674
  const on = t.id === activeId;
574
675
  t.el.classList.toggle("wt-tab-active", on);
575
- t.aria.setSelected(on);
676
+ // setSelected re-adds aria-selected and the roving tabindex, which would
677
+ // undo setEditing(true) on the chip hosting the rename field — and
678
+ // syncChrome runs on every status event, so that happens within a tick.
679
+ // The chip regains its semantics from endEdit's setEditing(false), which
680
+ // reads the CURRENT selected state.
681
+ if (t.id !== editingId) {
682
+ t.aria.setSelected(on);
683
+ }
576
684
  }
577
685
  if (activeId && activeId !== lastRevealedActive) {
578
686
  lastRevealedActive = activeId;
@@ -832,6 +940,44 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
832
940
  renderSwitcherList();
833
941
  }
834
942
  maybeSwipeHint();
943
+ persistOrder();
944
+ }
945
+
946
+ // persistOrder stores the strip's current arrangement so it survives a
947
+ // reload. It hangs off syncChrome — the one function every list mutation
948
+ // already ends with (reorder, create, adopt, close, reconcile) — rather than
949
+ // off the two reorder call sites, so a future path that rearranges tabs
950
+ // cannot forget to persist. Three guards make that safe:
951
+ //
952
+ // - `started`: nothing is written while the initial population runs. The
953
+ // strip grows one tab at a time there, and each intermediate state is a
954
+ // partial arrangement, NOT a user decision — writing them meant a reload
955
+ // landing mid-population could store a one-tab order and lose the real
956
+ // one. After the bootstrap every change is a genuine change.
957
+ // - the signature compare: syncChrome also runs on every status tick, so
958
+ // only a changed id sequence reaches storage.
959
+ // - a non-empty list: an empty strip carries no arrangement, and a
960
+ // transient empty state (the last tab closed just before its replacement
961
+ // lands, a reconcile against a restarted server) must not erase the
962
+ // stored order.
963
+ //
964
+ // savedOrder is deliberately NOT reassigned here. It is the ARRANGEMENT
965
+ // READ AT LOAD, which is what adoption places tabs by; overwriting it with
966
+ // the live strip discards the position of every session that has not been
967
+ // adopted yet — which is precisely what a strip still filling up is full of.
968
+ function persistOrder(): void {
969
+ if (!started || tabList.length === 0) {
970
+ return;
971
+ }
972
+ const ids = tabList.map((t) => t.id);
973
+ const sig = orderSignature(ids);
974
+ if (sig === persistedSig) {
975
+ return;
976
+ }
977
+ persistedSig = sig;
978
+ // Writing the LIVE strip also prunes: ids whose sessions are gone never
979
+ // reach storage again, so the stored arrangement cannot grow forever.
980
+ writeSavedOrder(ids);
835
981
  }
836
982
 
837
983
  // closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
@@ -944,7 +1090,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
944
1090
  id: info.id,
945
1091
  born: ++tabEpoch,
946
1092
  title: info.title,
947
- clientTitle: info.clientTitle,
1093
+ pinnedTitle: info.pinnedTitle,
1094
+ nameSeq: 0,
948
1095
  display: "",
949
1096
  createdAt: info.createdAt,
950
1097
  store: new LineStore(),
@@ -958,24 +1105,53 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
958
1105
  };
959
1106
  // Set an initial label immediately (relabelAll refines it with de-dup
960
1107
  // once the tab is in tabList and syncChrome runs).
961
- tab.display = baseLabel(tab, preferInputTitle).text;
1108
+ tab.display = baseLabel(tab).text;
962
1109
  label.textContent = tab.display;
963
1110
  aria.setLabel(tab.display);
964
1111
  el.addEventListener("click", (e) => {
965
1112
  if ((e.target as HTMLElement).closest(".wt-tab-close")) {
966
1113
  return; // handled by the close button
967
1114
  }
1115
+ if (editingId === tab.id) {
1116
+ return; // the rename field owns this chip
1117
+ }
968
1118
  switchTo(tab.id);
969
1119
  });
1120
+ // Double-click renames, which is also the TOUCH entry path (a double-tap):
1121
+ // a long-press on a chip starts a reorder drag on iPadOS rather than
1122
+ // opening the context menu, because the chip is draggable — so the menu is
1123
+ // not reachable by touch and this is. The chip's touch-action:manipulation
1124
+ // (CSS) is what stops iOS eating the second tap as double-tap-to-zoom.
1125
+ // The leading click switches to the tab first; Finder selects before
1126
+ // renaming too, so that matches expectation.
1127
+ el.addEventListener("dblclick", (e) => {
1128
+ if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1129
+ return;
1130
+ }
1131
+ e.preventDefault();
1132
+ beginEdit(tab.id, "pointer");
1133
+ });
970
1134
  // Keyboard interaction for the ARIA tabs pattern (WCAG 2.1.1): arrows
971
1135
  // move selection (wrapping), Home/End jump to the boundaries, Delete
972
- // closes the focused tab. Pairs with the roving tabindex the kernel's
973
- // registerTab manages (selected tab is tabIndex 0, others -1).
1136
+ // closes the focused tab, F2 renames (the Explorer convention). Pairs with
1137
+ // the roving tabindex the kernel's registerTab manages (selected tab is
1138
+ // tabIndex 0, others -1).
974
1139
  el.addEventListener("keydown", (e) => {
975
1140
  const current = tabList.indexOf(tab);
976
1141
  if (current < 0) {
977
1142
  return;
978
1143
  }
1144
+ // While this chip hosts the rename field, every key belongs to the field
1145
+ // (belt-and-braces with the field's own stopPropagation).
1146
+ if (editingId === tab.id) {
1147
+ return;
1148
+ }
1149
+
1150
+ if (e.key === "F2") {
1151
+ e.preventDefault();
1152
+ beginEdit(tab.id, "keyboard");
1153
+ return;
1154
+ }
979
1155
 
980
1156
  if (e.key === "Delete") {
981
1157
  e.preventDefault();
@@ -1021,6 +1197,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1021
1197
  }
1022
1198
  });
1023
1199
  el.addEventListener("auxclick", (e) => {
1200
+ if (editingId === tab.id) {
1201
+ return; // middle-click in a text field pastes on X11; it must not close the tab
1202
+ }
1024
1203
  if (e.button === 1) {
1025
1204
  e.preventDefault();
1026
1205
  void close_(tab.id);
@@ -1030,25 +1209,39 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1030
1209
  // the browser's own menu; the strip is hidden on a coarse pointer, so
1031
1210
  // this is desktop-only in practice.
1032
1211
  el.addEventListener("contextmenu", (e) => {
1212
+ if (editingId === tab.id) {
1213
+ // Let the browser's own text menu open: it is where Paste, Select All
1214
+ // and Undo live, and the tab menu would offer Close and Move mid-edit.
1215
+ return;
1216
+ }
1033
1217
  e.preventDefault();
1034
1218
  showTabMenu(e.clientX, e.clientY, tab.id);
1035
1219
  });
1036
1220
  // Drag-and-drop reorder on the desktop strip. The bar's dragover moves
1037
- // this element live; dragend commits the new order into tabList.
1221
+ // this element live; drop/dragend commit the new order into tabList.
1038
1222
  el.draggable = true;
1039
1223
  el.addEventListener("dragstart", (e) => {
1224
+ if (editingId === tab.id) {
1225
+ e.preventDefault(); // renaming: a drag would fight caret selection
1226
+ return;
1227
+ }
1040
1228
  draggingEl = el;
1229
+ // Snapshot the preview from the PRISTINE chip, before the dim class.
1230
+ setDragGhost(e, el);
1041
1231
  el.classList.add("wt-tab-dragging");
1042
1232
  if (e.dataTransfer) {
1043
1233
  e.dataTransfer.effectAllowed = "move";
1044
- // Firefox requires drag data to be set for the drag to start.
1045
- e.dataTransfer.setData("text/plain", tab.id);
1234
+ // Firefox requires drag data to be set for the drag to start; the
1235
+ // private type keeps the payload undroppable everywhere else (see
1236
+ // TAB_DRAG_TYPE).
1237
+ e.dataTransfer.setData(TAB_DRAG_TYPE, tab.id);
1046
1238
  }
1047
1239
  hideTabMenu();
1048
1240
  });
1049
1241
  el.addEventListener("dragend", () => {
1050
1242
  el.classList.remove("wt-tab-dragging");
1051
1243
  draggingEl = null;
1244
+ clearDragGhost(); // belt-and-braces: the rAF normally got there first
1052
1245
  syncOrderFromDom();
1053
1246
  });
1054
1247
  return tab;
@@ -1114,7 +1307,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1114
1307
  } catch {
1115
1308
  /* storage unavailable (private mode / disabled) — non-fatal */
1116
1309
  }
1117
- titleDeriver.reset(); // a partial line typed in the old tab does not carry over
1118
1310
  ctx.render.bind(next.store);
1119
1311
  ctx.notifySwitch({ id: next.id });
1120
1312
  armCatchup();
@@ -1147,11 +1339,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1147
1339
  }
1148
1340
  });
1149
1341
  ctx.announce(`Switched to ${next.display}`);
1150
- if (physicalKeyboardLikely()) {
1342
+ if (physicalKeyboardLikely() && editingId === null) {
1151
1343
  // A physical keyboard is (likely) present, so focus the input on
1152
1344
  // switch — switch and type immediately (the iPad + Magic Keyboard
1153
1345
  // ask). On a keyboard-less touchscreen this is skipped, or every
1154
- // switch would pop the virtual keyboard (#7).
1346
+ // switch would pop the virtual keyboard (#7). Skipped entirely while a
1347
+ // rename field is open: a switch (e.g. the leading click of a
1348
+ // double-click, or a remote-driven ensureActive) would otherwise yank
1349
+ // the caret out of the field the user is typing in.
1155
1350
  focusInput();
1156
1351
  }
1157
1352
  }
@@ -1270,12 +1465,59 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1270
1465
  }
1271
1466
  }
1272
1467
 
1468
+ // --- Drag preview (the ghost) ---
1469
+ // WebKit renders NO automatic drag image for an element whose ancestor
1470
+ // carries a filter or transform (webkit.org/b/22787) — and the strip's own
1471
+ // backdrop-filter is exactly such an ancestor, so on iPadOS the preview
1472
+ // came out as broken white geometry instead of the chip (desktop Chrome,
1473
+ // which snapshots the element regardless, always looked right). An EXPLICIT
1474
+ // drag image fixes it: a clone parked directly under .wt-root, outside the
1475
+ // bar's filtered subtree yet still inside the styling boundary so the
1476
+ // scoped .wt-tab rules paint it. It is laid exactly over the real chip, so
1477
+ // it is indistinguishable from it in the unlikely event it ever paints, and
1478
+ // dropped on the next frame — the browser has already snapshotted it by
1479
+ // then (the snapshot is taken when the dragstart handler returns).
1480
+ let dragGhost: HTMLElement | null = null;
1481
+ function clearDragGhost(): void {
1482
+ dragGhost?.remove();
1483
+ dragGhost = null;
1484
+ }
1485
+ function setDragGhost(e: DragEvent, el: HTMLElement): void {
1486
+ clearDragGhost();
1487
+ const rect = el.getBoundingClientRect();
1488
+ const rootRect = varRoot.getBoundingClientRect();
1489
+ const ghost = el.cloneNode(true) as HTMLElement;
1490
+ ghost.classList.add("wt-tab-ghost");
1491
+ // A duplicated role="tab" must not reach assistive tech for the frame the
1492
+ // clone exists (CSS keeps it non-interactive).
1493
+ ghost.setAttribute("aria-hidden", "true");
1494
+ ghost.style.left = `${String(rect.left - rootRect.left)}px`;
1495
+ ghost.style.top = `${String(rect.top - rootRect.top)}px`;
1496
+ // The chip's width comes from a flex-shrink in the strip; out of that
1497
+ // flex context the clone would snap back to the unshrunk 300px.
1498
+ ghost.style.width = `${String(rect.width)}px`;
1499
+ ghost.style.height = `${String(rect.height)}px`;
1500
+ varRoot.appendChild(ghost);
1501
+ dragGhost = ghost;
1502
+ // Offset from the chip's own box, not e.offsetX/offsetY: those are
1503
+ // relative to the event TARGET, which is the label or the close button
1504
+ // when the drag starts on one of them.
1505
+ e.dataTransfer?.setDragImage(ghost, e.clientX - rect.left, e.clientY - rect.top);
1506
+ requestAnimationFrame(clearDragGhost);
1507
+ }
1508
+
1273
1509
  // moveTab shifts a tab one slot left or right in tabList and re-appends
1274
1510
  // every tab element in list order so the DOM matches.
1275
1511
  // It is the single-pointer / non-drag alternative to the drag-and-drop
1276
1512
  // reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
1277
1513
  // context menu.
1278
1514
  function moveTab(id: string, delta: -1 | 1): void {
1515
+ // Reordering re-appends every chip, and reparenting the focused field blurs
1516
+ // it — so an open edit would be committed as a side effect of moving some
1517
+ // OTHER tab, via a blur the user never performed. Resolve it here instead,
1518
+ // so the outcome is chosen and testable rather than emergent (happy-dom
1519
+ // does not reproduce the reparent-blur, so no test could catch it).
1520
+ resolveEdit("blur", false);
1279
1521
  const from = tabList.findIndex((t) => t.id === id);
1280
1522
  const to = from + delta;
1281
1523
  if (from < 0 || to < 0 || to >= tabList.length) {
@@ -1305,7 +1547,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1305
1547
  if (tombstones.active(info.id)) {
1306
1548
  return; // just closed here; ignore a stale listing until the server reaps it
1307
1549
  }
1308
- tabList.push(addTabChrome(info));
1550
+ const tab = addTabChrome(info);
1551
+ // Place the tab where the user's stored arrangement says it goes, not
1552
+ // where it happened to arrive: the status-stream snapshot and the
1553
+ // bootstrap's GET /api/sessions race each other, so arrival order is not
1554
+ // the user's order (and is not even stable between loads). A session the
1555
+ // arrangement does not know is appended, which is what a genuinely new
1556
+ // tab wants.
1557
+ const at = orderedInsertIndex(
1558
+ tabList.map((t) => t.id),
1559
+ savedOrder,
1560
+ info.id,
1561
+ );
1562
+ tabList.splice(at, 0, tab);
1563
+ // addTabChrome appended the chip to the scroller's end; move it to match
1564
+ // the list when the arrangement put it earlier, so the DOM, the switcher
1565
+ // rows, and the position announcements all read the same order.
1566
+ const after = tabList[at + 1];
1567
+ if (after) {
1568
+ scroller.insertBefore(tab.el, after.el);
1569
+ }
1309
1570
  }
1310
1571
 
1311
1572
  // Activate the first tab when nothing is active. The bootstrap normally
@@ -1332,11 +1593,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1332
1593
  async function create(): Promise<void> {
1333
1594
  let info: SessionInfo;
1334
1595
  try {
1335
- info = await api.create();
1336
- } catch {
1596
+ info = await createSessionHonouringRetry(api, ctx, () => tornDown);
1597
+ } catch (err) {
1337
1598
  // A create can still fail transiently (network, server error); tell
1338
- // the user rather than throwing.
1339
- ctx.toast("Couldn't open a terminal");
1599
+ // the user rather than throwing. A 503 has already been retried on the
1600
+ // server's own schedule by this point, so reaching here means it never
1601
+ // became ready -- say so with the server's own words when it gave any,
1602
+ // rather than the generic message that used to cover both cases.
1603
+ ctx.toast(
1604
+ err instanceof SessionAPIError && err.serverMessage !== undefined
1605
+ ? `Couldn't open a terminal: ${err.serverMessage}`
1606
+ : "Couldn't open a terminal",
1607
+ );
1340
1608
  return;
1341
1609
  }
1342
1610
  // The status SSE may have adopted this session during the POST round-trip
@@ -1387,6 +1655,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1387
1655
  if (!tab) {
1388
1656
  return;
1389
1657
  }
1658
+ // A tab removed mid-edit (a user close, an SSE removal, or the list
1659
+ // reconcile dropping it) abandons the edit with no request: the field
1660
+ // lives inside the chip and goes away with it, so the state must not
1661
+ // outlive it.
1662
+ if (editingId === id) {
1663
+ endEdit();
1664
+ }
1390
1665
  // Tombstone this id briefly so a stale status snapshot/poll that predates
1391
1666
  // the server reaping it cannot re-adopt the just-closed tab.
1392
1667
  tombstones.add(id);
@@ -1448,6 +1723,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1448
1723
  return;
1449
1724
  }
1450
1725
  const closingActive = activeId !== null && ids.includes(activeId);
1726
+ // The bulk-close path drops tabs itself rather than through dropTab, so it
1727
+ // needs the same mid-edit guard: an open editor on a tab being removed must
1728
+ // be abandoned, or editingId outlives its chip and permanently suppresses
1729
+ // focus-on-switch.
1730
+ if (editingId !== null && ids.includes(editingId)) {
1731
+ endEdit();
1732
+ }
1451
1733
  for (const t of victims) {
1452
1734
  const idx = tabList.indexOf(t);
1453
1735
  if (idx >= 0) {
@@ -1504,10 +1786,270 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1504
1786
  return closeMany(tabList.map((t) => t.id));
1505
1787
  }
1506
1788
 
1789
+ // --- Inline rename (R5) ---
1790
+ // The edit surface is the label's OWN box, so the width the user types into
1791
+ // is the width the label will have. An <input>, not contenteditable: real
1792
+ // text-input semantics, IME support, a mobile keyboard, and no rich-HTML
1793
+ // paste surface.
1794
+ //
1795
+ // editingId is also the stand-down flag for every chip handler that would
1796
+ // otherwise fight the field (click-to-switch, the arrow/Delete keys,
1797
+ // focus-on-switch, the drag) — see the guards at each site.
1798
+ let editingId: string | null = null;
1799
+ let editInput: HTMLInputElement | null = null;
1800
+ // Where focus goes when the edit ends. R9: back to the chip when the user
1801
+ // arrived from the keyboard (F2 — they were navigating the strip and should
1802
+ // still be), to the terminal otherwise. Recorded at entry because the exit
1803
+ // paths cannot tell them apart.
1804
+ let editFrom: "keyboard" | "pointer" = "pointer";
1805
+ // Tab ids with a pinned-name request in flight. While an id is here, a status
1806
+ // record carrying a DIFFERENT value is applied for display but does not
1807
+ // supersede the request: SSE delivery order and REST mutation order are not
1808
+ // one total order, so a record sampled BEFORE our PUT can arrive after it,
1809
+ // and treating it as newer authority would suppress the request's own
1810
+ // rollback and its failure toast.
1811
+ const namesInFlight = new Set<string>();
1812
+
1813
+ /** restoreFocusAfterEdit sends focus where the entry path implies. The
1814
+ * terminal branch is gated exactly like switchTo's focus-on-switch: on a
1815
+ * keyboard-less touchscreen, focusing the hidden textarea pops the soft
1816
+ * keyboard, which is not what finishing a rename by double-tap should do. */
1817
+ function restoreFocusAfterEdit(el: HTMLElement | undefined): void {
1818
+ if (editFrom === "keyboard" && el) {
1819
+ el.focus();
1820
+ return;
1821
+ }
1822
+ if (physicalKeyboardLikely()) {
1823
+ focusInput();
1824
+ }
1825
+ }
1826
+
1827
+ /** endEdit tears the field down and restores the chip. It never issues a
1828
+ * request; each caller decides what to commit first. */
1829
+ function endEdit(): void {
1830
+ const id = editingId;
1831
+ editingId = null;
1832
+ const input = editInput;
1833
+ editInput = null;
1834
+ if (id === null) {
1835
+ return;
1836
+ }
1837
+ const t = tabList.find((x) => x.id === id);
1838
+ input?.remove();
1839
+ if (!t) {
1840
+ return; // the tab went away mid-edit; its chrome is already gone
1841
+ }
1842
+ t.label.hidden = false;
1843
+ t.el.classList.remove("wt-tab-editing");
1844
+ t.el.draggable = true;
1845
+ // Restore the tab semantics from the CURRENT selected state: the active
1846
+ // tab can change while an edit is open on a different chip.
1847
+ t.aria.setEditing(false, t.id === activeId);
1848
+ }
1849
+
1850
+ /** commitRename applies a finished edit. An empty value CLEARS the pin, but
1851
+ * only on an explicit confirm — see beginEdit's Enter/blur split. */
1852
+ function commitRename(id: string, raw: string): boolean {
1853
+ const t = tabList.find((x) => x.id === id);
1854
+ if (!t) {
1855
+ return false;
1856
+ }
1857
+ // Sanitize client-side rather than trusting the round-trip: strip control
1858
+ // characters and bound the length exactly as the server does.
1859
+ const name = sanitizePinnedName(raw);
1860
+ const before = t.pinnedTitle ?? "";
1861
+ if (name === before) {
1862
+ return false; // nothing to do; no request, no toast
1863
+ }
1864
+ // Optimistic: paint it now, guarded by a monotonic counter AND the tab's
1865
+ // birth epoch so a slow response cannot roll back a newer rename, a later
1866
+ // clear, a remote update, or — if this id were ever reused by a fresh
1867
+ // session — a different tab entirely.
1868
+ const seq = ++t.nameSeq;
1869
+ const born = t.born;
1870
+ t.pinnedTitle = name;
1871
+ syncChrome();
1872
+ // Announce the OUTCOME, which for a clear is whatever the automatic
1873
+ // sources now yield — and that can be the "New tab" fallback, in which
1874
+ // case claiming an automatic name would be false.
1875
+ if (name !== "") {
1876
+ ctx.announce(`Renamed to ${name}`);
1877
+ } else {
1878
+ const { text, fallback } = baseLabel(t);
1879
+ ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
1880
+ }
1881
+ namesInFlight.add(id);
1882
+ const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
1883
+ void request
1884
+ .catch(() => {
1885
+ const cur = tabList.find((x) => x.id === id);
1886
+ if (cur?.born !== born || cur.nameSeq !== seq) {
1887
+ return; // gone, reused, or superseded: the newer state stands
1888
+ }
1889
+ cur.pinnedTitle = before;
1890
+ syncChrome();
1891
+ ctx.toast("Couldn't save the terminal name");
1892
+ })
1893
+ .finally(() => {
1894
+ namesInFlight.delete(id);
1895
+ });
1896
+ return true;
1897
+ }
1898
+
1899
+ /** beginEdit swaps a chip's label for a text field. Idempotent per tab; a
1900
+ * second tab entering edit commits the first. */
1901
+ function beginEdit(id: string, from: "keyboard" | "pointer"): void {
1902
+ if (editingId === id) {
1903
+ return;
1904
+ }
1905
+ if (editingId !== null) {
1906
+ // Entering edit elsewhere resolves the open one under the blur rules —
1907
+ // this is not an Enter, so it must not be able to CLEAR a pin from a
1908
+ // field the user happened to empty. Focus is about to move to the new
1909
+ // field, so it is not restored here.
1910
+ resolveEdit("blur", false);
1911
+ }
1912
+ editFrom = from;
1913
+ const t = tabList.find((x) => x.id === id);
1914
+ if (!t) {
1915
+ return;
1916
+ }
1917
+ const input = document.createElement("input");
1918
+ input.type = "text";
1919
+ input.className = "wt-tab-rename";
1920
+ input.setAttribute("aria-label", "Rename terminal");
1921
+ // Prefill with the PIN, not the rendered label. t.display is presentation
1922
+ // output: for an unpinned tab it is the automatic title, the "New tab"
1923
+ // fallback, or a de-duplication suffix relabelAll generated (`auth (2)`).
1924
+ // Since a blur commits a non-empty value, prefilling it meant opening the
1925
+ // editor and clicking away silently pinned presentation text as the user's
1926
+ // chosen name. An unpinned tab therefore starts EMPTY, with the current
1927
+ // label as the placeholder so the user still sees what they are replacing.
1928
+ input.value = t.pinnedTitle ?? "";
1929
+ input.placeholder = t.display;
1930
+ // maxLength is a UI affordance only, in UTF-16 code units; the server
1931
+ // counts runes, so sanitizePinnedName (code points) is the real bound.
1932
+ input.maxLength = MAX_PINNED_NAME;
1933
+ // iPadOS is the device this affordance was designed for, and its defaults
1934
+ // are wrong for a short identifier: autocapitalize would upper-case the
1935
+ // first letter of every tab name and autocorrect would rewrite it on
1936
+ // commit. enterkeyhint labels the soft keyboard's action key.
1937
+ input.autocapitalize = "off";
1938
+ input.spellcheck = false;
1939
+ input.setAttribute("autocorrect", "off");
1940
+ input.enterKeyHint = "done";
1941
+ // A tab shrinks toward a 100px floor once the strip is full, and a 100px
1942
+ // edit box is unusable, so the edited chip is given its full width for the
1943
+ // duration — on ENTRY only, never per keystroke, so the strip does not
1944
+ // jitter as the user types.
1945
+ t.el.classList.add("wt-tab-editing");
1946
+ t.label.hidden = true;
1947
+ t.el.insertBefore(input, t.label.nextSibling);
1948
+ // Drop the tab semantics while a textbox lives in the chip, and stop the
1949
+ // chip being draggable: a draggable ancestor interferes with drag-selecting
1950
+ // text, and dragging the tab you are renaming is meaningless.
1951
+ t.aria.setEditing(true, t.id === activeId);
1952
+ t.el.draggable = false;
1953
+ editingId = id;
1954
+ editInput = input;
1955
+ if (typeof t.el.scrollIntoView === "function") {
1956
+ t.el.scrollIntoView({ block: "nearest", inline: "nearest" });
1957
+ }
1958
+ input.focus();
1959
+ input.select();
1960
+ ctx.announce(`Renaming ${t.display}`);
1961
+
1962
+ input.addEventListener("keydown", (e) => {
1963
+ // The chip's own keydown handler treats arrows as tab switching and
1964
+ // Delete as close; the field must own them. stopPropagation is the
1965
+ // narrowest fix and keeps the chip's handler unaware of edit mode.
1966
+ e.stopPropagation();
1967
+ if (e.key === "Enter") {
1968
+ e.preventDefault();
1969
+ resolveEdit("confirm");
1970
+ } else if (e.key === "Escape") {
1971
+ e.preventDefault();
1972
+ resolveEdit("cancel");
1973
+ }
1974
+ });
1975
+ // A click inside the field must not reach the chip's switch-on-click.
1976
+ input.addEventListener("click", (e) => {
1977
+ e.stopPropagation();
1978
+ });
1979
+ input.addEventListener("blur", () => {
1980
+ if (editingId !== id) {
1981
+ return; // already resolved by Enter/Escape/teardown
1982
+ }
1983
+ // A blur is not the user asking for focus to go anywhere in particular,
1984
+ // so it does not move it.
1985
+ resolveEdit("blur", false);
1986
+ });
1987
+ }
1988
+
1989
+ /** resolveEdit closes the open edit and applies its value under the rules for
1990
+ * HOW it was closed. One function rather than a commit path per caller,
1991
+ * because the empty-value rule differs and every caller that got it wrong
1992
+ * did so by hand-inlining a variant:
1993
+ *
1994
+ * - `"confirm"` (Enter) is the only deliberate confirmation, so it is the
1995
+ * only mode that may CLEAR a pin from an emptied field.
1996
+ * - `"blur"` commits a non-empty value (Finder and Explorer both do;
1997
+ * discarding on a stray click is hostile) and reverts an empty one — a
1998
+ * blur is loss of focus, not destructive intent, and on a tablet
1999
+ * dismissing the soft keyboard IS a blur.
2000
+ * - `"cancel"` (Escape) applies nothing.
2001
+ *
2002
+ * `focus` is opt-out for the callers that are not a user finishing an edit
2003
+ * (a reorder, a pre-empting second edit): they should not move focus. */
2004
+ function resolveEdit(mode: "confirm" | "blur" | "cancel", focus = true): void {
2005
+ const id = editingId;
2006
+ if (id === null) {
2007
+ return;
2008
+ }
2009
+ const value = editInput?.value ?? "";
2010
+ const t = tabList.find((x) => x.id === id);
2011
+ const label = t?.display ?? "";
2012
+ endEdit();
2013
+ if (mode === "cancel") {
2014
+ ctx.announce(`Rename cancelled, keeping ${label}`);
2015
+ } else if (mode === "confirm" || value.trim() !== "") {
2016
+ // A confirm that changes nothing must still close the narrative: without
2017
+ // this a screen reader hears "Renaming X" and then silence, which is
2018
+ // indistinguishable from the edit still being open.
2019
+ if (!commitRename(id, value) && mode === "confirm") {
2020
+ ctx.announce(`Rename finished, keeping ${label}`);
2021
+ }
2022
+ }
2023
+ if (focus) {
2024
+ restoreFocusAfterEdit(t?.el);
2025
+ }
2026
+ }
2027
+
1507
2028
  // hideTabMenu / showTabMenu drive the right-click menu. showTabMenu rebuilds
1508
2029
  // the items for the target tab (disabled states reflect its position) and
1509
2030
  // clamps into the visible viewport, flipping above the pointer near the
1510
2031
  // bottom edge (mirrors context-menu.ts).
2032
+ //
2033
+ // menuSwallow covers the contextmenu-then-touchend race: iPadOS Safari
2034
+ // raises a context menu from a LONG-PRESS, and the same gesture emits a
2035
+ // click on release which onDocClickMenu would read as a click-away. The
2036
+ // terminal menu has guarded this since it shipped; this one did not, so a
2037
+ // long-pressed tab menu closed itself on release — on the one platform
2038
+ // where the desktop strip is the chrome you touch. menuTouch records
2039
+ // whether the press that raised the menu was a finger, since only touch
2040
+ // emits that trailing click.
2041
+ const menuSwallow = createClickSwallow();
2042
+ let menuTouch = false;
2043
+ // One listener on the bar rather than one per chip: chips are created and
2044
+ // destroyed as sessions come and go, and the pointer type belongs to the
2045
+ // gesture, not to a chip.
2046
+ bar.addEventListener(
2047
+ "pointerdown",
2048
+ (e) => {
2049
+ menuTouch = e.pointerType === "touch";
2050
+ },
2051
+ { passive: true },
2052
+ );
1511
2053
  function hideTabMenu(): void {
1512
2054
  tabMenu.classList.remove("visible");
1513
2055
  tabMenu.replaceChildren();
@@ -1527,13 +2069,34 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1527
2069
  }
1528
2070
  tabMenu.appendChild(b);
1529
2071
  }
2072
+ function tabMenuSeparator(): void {
2073
+ const hr = document.createElement("div");
2074
+ hr.className = "wt-tab-menu-sep";
2075
+ hr.setAttribute("role", "separator");
2076
+ tabMenu.appendChild(hr);
2077
+ }
1530
2078
  function showTabMenu(x: number, y: number, id: string): void {
1531
2079
  hideTabMenu();
1532
2080
  const idx = tabList.findIndex((t) => t.id === id);
1533
- if (idx < 0) {
2081
+ const target = tabList[idx];
2082
+ if (idx < 0 || !target) {
1534
2083
  return;
1535
2084
  }
1536
2085
  const n = tabList.length;
2086
+ // The naming pair goes FIRST, farthest from the accidental-close zone.
2087
+ tabMenuItem("Rename\u2026", false, () => {
2088
+ beginEdit(id, "pointer");
2089
+ });
2090
+ // Always present, disabled when there is no pin — matching the menu's
2091
+ // existing rule for Move left / Close to the right. Not for consistency's
2092
+ // own sake: an item that appeared only sometimes would shift every close
2093
+ // item up or down by a row depending on whether the tab happens to be
2094
+ // renamed, so right-clicking two tabs and clicking the same point could
2095
+ // reset a name on one and close tabs on the other.
2096
+ tabMenuItem("Use automatic name", !hasPinnedName(target), () => {
2097
+ commitRename(id, ""); // the same operation an emptied, confirmed edit performs
2098
+ });
2099
+ tabMenuSeparator();
1537
2100
  tabMenuItem("Move left", idx <= 0, () => {
1538
2101
  moveTab(id, -1);
1539
2102
  });
@@ -1560,6 +2123,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1560
2123
  // the visual viewport; flip above the pointer near the bottom edge).
1561
2124
  tabMenu.classList.add("visible");
1562
2125
  placeMenuAt(tabMenu, x, y);
2126
+ if (menuTouch) {
2127
+ menuSwallow.arm();
2128
+ }
1563
2129
  }
1564
2130
 
1565
2131
  // One-time "swipe to switch" hint on first multi-tab state, mobile only.
@@ -1589,22 +2155,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1589
2155
  ctx.toast("Swipe to switch terminals");
1590
2156
  }
1591
2157
 
1592
- // applyStatus updates one tab's dot (status + reveal) + title from a status
1593
- // record (shared by the SSE monitor and the polling fallback). reports is
1594
- // the server's sticky reportsActivity flag; it gates the dot's visibility.
1595
- function applyStatus(
1596
- id: string,
1597
- status: string,
1598
- title: string | undefined,
1599
- clientTitle: string | undefined,
1600
- reports: boolean,
1601
- ): void {
1602
- const t = tabList.find((tab) => tab.id === id);
2158
+ // applyStatus updates one tab's dot (status + reveal) + titles from a status
2159
+ // record (shared by the SSE monitor and the polling fallback). It takes the
2160
+ // record whole rather than as positional fields: both callers already hold
2161
+ // one, and its title fields are interchangeable strings that as positional
2162
+ // parameters would be trivially swappable. reports is the server's sticky reportsActivity flag, passed
2163
+ // separately because it is normalised (reportsOf) before it gets here.
2164
+ function applyStatus(rec: SessionInfo, reports: boolean): void {
2165
+ const t = tabList.find((tab) => tab.id === rec.id);
1603
2166
  if (!t) {
1604
2167
  return;
1605
2168
  }
1606
2169
  t.reports = reports;
1607
- paintStatusDot(t.dot, status, reports);
2170
+ paintStatusDot(t.dot, rec.status, reports);
1608
2171
  // Latest-wins background-tab notification for the switch button's dot: a
1609
2172
  // background tab (not the active one) reaching "input" (needs you) or
1610
2173
  // "done" (turn finished) raises the cue in that colour; each qualifying
@@ -1612,9 +2175,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1612
2175
  // list opens. The active surface keeps its own needs-input cue (see
1613
2176
  // syncMobile); this is the moved + upgraded, glanceable version on the
1614
2177
  // dedicated button.
1615
- if (id !== activeId && (status === "input" || status === "done")) {
1616
- switchNotify = status;
1617
- switchNotifyId = id;
2178
+ if (rec.id !== activeId && (rec.status === "input" || rec.status === "done")) {
2179
+ switchNotify = rec.status;
2180
+ switchNotifyId = rec.id;
1618
2181
  paintSwitchDot();
1619
2182
  }
1620
2183
  // Record the raw server title; the displayed label (fallback + de-dup)
@@ -1624,15 +2187,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1624
2187
  // and overwriting a good label with it dropped an idle tab back to "New
1625
2188
  // tab". Hold the last known title until a genuine (non-blank) one
1626
2189
  // arrives; the derived-from-input fallback is likewise sticky.
1627
- if (title !== undefined && title.trim() !== "") {
1628
- t.title = title;
1629
- }
1630
- // The persisted client title is authoritative from the server (set via
1631
- // PUT .../title); apply it as-is, including "" for a fresh session, so the
1632
- // preferInputTitle label recovers it on reload. No blank-guard: unlike the
1633
- // OSC title it does not flicker (it changes only on an explicit push).
1634
- if (clientTitle !== undefined) {
1635
- t.clientTitle = clientTitle;
2190
+ // The typeof check is not redundant with the type: rec comes from
2191
+ // unvalidated server JSON, and a missing title would make .trim() throw
2192
+ // here and abort the caller's whole reconcile loop.
2193
+ if (typeof rec.title === "string" && rec.title.trim() !== "") {
2194
+ t.title = rec.title;
2195
+ }
2196
+ // The pinned name is likewise authoritative and un-guarded, and "" is the
2197
+ // meaningful value: it is how a clear made in ANOTHER browser reaches this
2198
+ // one. A blank-guard here would make a remote un-rename invisible.
2199
+ //
2200
+ // The nameSeq bump is gated on the value actually DIFFERING from what we
2201
+ // believe. The wire carries pinnedTitle on every status event, so bumping
2202
+ // unconditionally would mark an in-flight local rename as superseded by
2203
+ // the server's echo of that same rename — and its failure handler would
2204
+ // then decline to roll back or explain itself. A differing value really is
2205
+ // newer authority (a remote rename or clear), and supersedes.
2206
+ if (rec.pinnedTitle !== undefined && rec.pinnedTitle !== (t.pinnedTitle ?? "")) {
2207
+ t.pinnedTitle = rec.pinnedTitle;
2208
+ // Only a value arriving with no request of ours in flight is newer
2209
+ // AUTHORITY. During a pending request the record may predate our own PUT
2210
+ // (SSE delivery and REST mutation are not one total order), and bumping
2211
+ // would suppress that request's rollback and its failure toast.
2212
+ if (!namesInFlight.has(rec.id)) {
2213
+ t.nameSeq++;
2214
+ }
1636
2215
  }
1637
2216
  }
1638
2217
 
@@ -1677,13 +2256,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1677
2256
  const seen = new Set(list.map((s) => s.id));
1678
2257
  for (const info of list) {
1679
2258
  adoptSession(info); // add sessions created elsewhere (no local tab)
1680
- applyStatus(
1681
- info.id,
1682
- info.status,
1683
- info.title,
1684
- info.clientTitle,
1685
- reportsOf(info.reportsActivity),
1686
- );
2259
+ applyStatus(info, reportsOf(info.reportsActivity));
1687
2260
  }
1688
2261
  // A tab the server no longer lists was reaped/closed elsewhere (or
1689
2262
  // died with a restarted manager): drop it locally (no DELETE — it
@@ -1708,15 +2281,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1708
2281
  return;
1709
2282
  }
1710
2283
  // Adopt a session created in another browser so all clients converge.
1711
- // The status record IS the session's wire shape (SessionStatus
1712
- // extends SessionInfo), so it flows through whole — which also
1713
- // carries the persisted clientTitle: the SSE snapshot usually beats
1714
- // the initial GET /api/sessions (whose adoptSession then dedups and
1715
- // never re-applies its fields), so dropping it here left every
1716
- // SSE-adopted tab without its persisted title — in preferInputTitle
1717
- // mode the label then fell back to "New tab" on reload.
2284
+ // The status record IS the session's wire shape (SessionStatus extends
2285
+ // SessionInfo), so it flows through whole.
1718
2286
  adoptSession(s);
1719
- applyStatus(s.id, s.status, s.title, s.clientTitle, reportsOf(s.reportsActivity));
2287
+ applyStatus(s, reportsOf(s.reportsActivity));
1720
2288
  ensureActive();
1721
2289
  syncChrome();
1722
2290
  });
@@ -1731,10 +2299,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1731
2299
  }
1732
2300
 
1733
2301
  // --- Event wiring ---
1734
- // Derive each tab's title from the lines the user submits into it.
1735
- const offInput = ctx.registerInputObserver((bytes) => {
1736
- titleDeriver.observe(bytes);
1737
- });
2302
+ // No input observer. The ENGINE derives a session's name from the input
2303
+ // stream when its host asked for that (terminal.WithInputTitle), so no preset
2304
+ // does per-keystroke title work in the browser, and the name is identical for
2305
+ // every client attached to the session — including one that attaches later.
1738
2306
  // Observe (never consume) keydowns to detect a physical keyboard: a
1739
2307
  // hardware-only key latches sawHardwareKey, which upgrades focus-on-switch
1740
2308
  // for a keyboard folio with no trackpad (no fine pointer to key off).
@@ -1744,7 +2312,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1744
2312
  }
1745
2313
  return false;
1746
2314
  });
1747
- // Live reorder while dragging a tab over the strip (dragend commits it).
2315
+ // Live reorder while dragging a tab over the strip (drop/dragend commit it).
1748
2316
  bar.addEventListener("dragover", (e) => {
1749
2317
  if (!draggingEl) {
1750
2318
  return;
@@ -1755,6 +2323,32 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1755
2323
  }
1756
2324
  scroller.insertBefore(draggingEl, dragTargetBefore(e.clientX));
1757
2325
  });
2326
+ // Commit on drop — and, load-bearing, CANCEL the drop's default action. An
2327
+ // uncancelled drop leaves the browser free to act on the drag payload
2328
+ // itself, and WebKit's action is to LOAD it as a URL: with no drop handler
2329
+ // at all, dropping a tab on iPadOS navigated the page to /<session-id>
2330
+ // instead of reordering. Chrome and Firefox ignore an unhandled reorder
2331
+ // drop, which is why the strip looked fine on the desktop.
2332
+ bar.addEventListener("drop", (e) => {
2333
+ if (!draggingEl) {
2334
+ return;
2335
+ }
2336
+ e.preventDefault();
2337
+ syncOrderFromDom();
2338
+ });
2339
+ // A tab released anywhere OTHER than the strip — over the terminal, over
2340
+ // any other chrome — must be inert as well, not a navigation, so claim the
2341
+ // whole document as a drop target for the life of a tab drag and swallow
2342
+ // the drop there too. dragend still commits whatever order the strip was
2343
+ // previewing. Gated on draggingEl so an unrelated drag (a file dropped on
2344
+ // the page) is left entirely to the browser.
2345
+ const onDocTabDrop = (e: DragEvent): void => {
2346
+ if (draggingEl) {
2347
+ e.preventDefault();
2348
+ }
2349
+ };
2350
+ document.addEventListener("dragover", onDocTabDrop);
2351
+ document.addEventListener("drop", onDocTabDrop);
1758
2352
  // Active-row close (x): closes the current tab (mirrors a listed row's x).
1759
2353
  // stopPropagation so it is not read as a tap/swipe on the row surface.
1760
2354
  swClose.addEventListener("click", (e) => {
@@ -1766,6 +2360,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1766
2360
  // Dismiss the tab context menu on an outside click, on Escape, and on
1767
2361
  // scroll (it is anchored to a viewport point, so a scroll would detach it).
1768
2362
  const onDocClickMenu = (): void => {
2363
+ // The trailing click of the long-press that just opened the menu is that
2364
+ // gesture's own release, not a click-away.
2365
+ if (menuSwallow.swallowing()) {
2366
+ return;
2367
+ }
1769
2368
  hideTabMenu();
1770
2369
  };
1771
2370
  document.addEventListener("click", onDocClickMenu);
@@ -2214,14 +2813,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2214
2813
  // them by hand).
2215
2814
  if (!sessions.some((s) => s.status !== STATUS_EXITED)) {
2216
2815
  try {
2217
- sessions = [...sessions, await api.create()];
2218
- } catch {
2816
+ sessions = [...sessions, await createSessionHonouringRetry(api, ctx, () => tornDown)];
2817
+ } catch (err) {
2219
2818
  // Match the runtime create() path: toast and leave the chrome up so
2220
2819
  // "+" can retry. Any exited sessions stay adopted (frozen screen +
2221
2820
  // "Session ended" is still better than a blank page). A throw here
2222
2821
  // would also be survivable (the kernel treats a rejected resolver
2223
- // as null), but the toast is the better UX.
2224
- ctx.toast("Couldn't open a terminal");
2822
+ // as null), but the toast is the better UX. A 503 was already
2823
+ // retried on the server's schedule, so this is the give-up path;
2824
+ // carry the server's explanation when it gave one.
2825
+ ctx.toast(
2826
+ err instanceof SessionAPIError && err.serverMessage !== undefined
2827
+ ? `Couldn't open a terminal: ${err.serverMessage}`
2828
+ : "Couldn't open a terminal",
2829
+ );
2225
2830
  }
2226
2831
  }
2227
2832
  // Adopt (dedup) rather than blindly push. The status SSE pushes a
@@ -2292,16 +2897,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2292
2897
  tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
2293
2898
  },
2294
2899
  teardown() {
2900
+ tornDown = true;
2295
2901
  resolveImpl = null;
2296
2902
  offStatus?.();
2297
2903
  offStreamOpen?.();
2298
- offInput();
2299
2904
  offHwKey();
2300
2905
  offArmed?.();
2301
2906
  offMenuKey();
2302
2907
  document.removeEventListener("click", onDocClickMenu);
2303
2908
  document.removeEventListener("contextmenu", onDocContextMenu);
2304
2909
  document.removeEventListener("pointerup", onDocTapDismiss, true);
2910
+ document.removeEventListener("dragover", onDocTabDrop);
2911
+ document.removeEventListener("drop", onDocTabDrop);
2305
2912
  window.removeEventListener("scroll", onScrollMenu, true);
2306
2913
  if (pollTimer !== null) {
2307
2914
  clearInterval(pollTimer);
@@ -2320,6 +2927,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2320
2927
  }
2321
2928
  endReelNow();
2322
2929
  gestureAbort?.abort(); // drop any in-flight gesture's window listeners
2930
+ endEdit(); // abandon an open rename without issuing a request
2931
+ clearDragGhost();
2323
2932
  clearCatchup();
2324
2933
  hideTabMenu();
2325
2934
  for (const t of tabList) {