@cplieger/web-terminal-ui 8.6.0-dev.4 → 8.6.0-dev.6

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.6.0-dev.4",
3
+ "version": "8.6.0-dev.6",
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",
@@ -44,7 +44,7 @@
44
44
  "verify": "bash scripts/verify.sh"
45
45
  },
46
46
  "peerDependencies": {
47
- "@cplieger/web-terminal-engine": "^6.0.0 || ^6.2.0-dev || ^6.4.0-dev"
47
+ "@cplieger/web-terminal-engine": "^6.4.0-dev.4"
48
48
  },
49
49
  "overrides": {
50
50
  "qs": "^6.15.2"
@@ -64,7 +64,7 @@
64
64
  "html-validate": "11.16.2",
65
65
  "knip": "6.41.0",
66
66
  "playwright": "1.64.0",
67
- "prettier": "3.9.9",
67
+ "prettier": "3.9.10",
68
68
  "stylelint": "17.16.0",
69
69
  "stylelint-config-standard": "40.0.0",
70
70
  "typescript": "npm:@typescript/typescript6@6.0.2",
@@ -28,8 +28,6 @@ import {
28
28
  } from "./route.js";
29
29
  import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
30
30
  import {
31
- CUE_SEEN_KEY,
32
- MAX_PERSISTED_CUE_SEEN,
33
31
  MAX_PINNED_NAME,
34
32
  PROGRESS_ABSENT,
35
33
  SWIPE_HINT_KEY,
@@ -43,15 +41,14 @@ import {
43
41
  hasPinnedName,
44
42
  isCueStatus,
45
43
  isEndedStatus,
46
- isUnseenCue,
44
+ isUnread,
47
45
  normalizeActivity,
48
46
  normalizeActivityCount,
49
47
  normalizeProgress,
48
+ normalizeSeq,
50
49
  orderedInsertIndex,
51
- parseCueSeen,
52
50
  renderedProgress,
53
51
  sanitizePinnedName,
54
- serializeCueSeen,
55
52
  statusPhrase,
56
53
  summarizeCues,
57
54
  tabAccessibleName,
@@ -196,29 +193,6 @@ const CATCHUP_SETTLE_MS = 250;
196
193
  * multiple frames; ordinary streaming must never arm the cue. */
197
194
  const CATCHUP_MIN_BACKLOG = 400;
198
195
 
199
- /** The acknowledged background-tab cues, or an empty map when storage throws or
200
- * holds nothing trustworthy: the dot simply lights again. */
201
- function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
202
- try {
203
- return parseCueSeen(win.localStorage.getItem(CUE_SEEN_KEY));
204
- } catch {
205
- return new Map<string, CueStatus>();
206
- }
207
- }
208
-
209
- /** writeCueSeen persists acknowledgements, best-effort: a full quota must not
210
- * break the dismissal the user just performed on screen. */
211
- function writeCueSeen(
212
- win: Pick<Window, "localStorage">,
213
- seen: ReadonlyMap<string, CueStatus>,
214
- ): void {
215
- try {
216
- win.localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
217
- } catch {
218
- /* storage unavailable or full — the dismissal still holds for this page */
219
- }
220
- }
221
-
222
196
  /** A wait the feature's teardown ends early: the timer goes with the feature. */
223
197
  function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void> {
224
198
  return new Promise((resolve) => {
@@ -234,6 +208,10 @@ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void>
234
208
  });
235
209
  }
236
210
 
211
+ /** Where a status record came from: the stream, or a list requested when the
212
+ * stream epoch read `listedAt`. */
213
+ type RecordSource = { readonly stream: true } | { readonly listedAt: number };
214
+
237
215
  /** How long a run of refusals has waited and when it last told the user. */
238
216
  interface RefusalWait {
239
217
  readonly startedAt: number;
@@ -460,8 +438,8 @@ type SplitOpening =
460
438
  * `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
461
439
  * It registers as the terminal's `paneLayoutOwner`, so it decides which session
462
440
  * the first connect attaches to; two owners fail at `kernel-init`. The strip's
463
- * order and the pane layout are SERVER state every viewer shares; the dismissed
464
- * cues are per-viewer in `localStorage`. Teardown removes the chrome and leaves
441
+ * order and the pane layout are SERVER state every viewer shares; which cues
442
+ * each session's readers have seen is server state too. Teardown removes the chrome and leaves
465
443
  * the sessions running; closing a tab is what kills a process. */
466
444
  export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
467
445
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
@@ -676,59 +654,92 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
676
654
  const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
677
655
  const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
678
656
  swBar.append(swKb, ...(swSplit === null ? [] : [swSplit]), swSwitch, swNew);
679
- // Latest-wins cue for the switch button's dot, cleared when the list opens or
680
- // the raising tab is visited or closed; "" is no pending cue.
657
+ // Latest-wins cue for the switch button's dot, cleared when the list opens,
658
+ // the raising tab is visited or closed, or its occurrence (switchNotifySeq)
659
+ // is read on any window or device; "" is no pending cue.
681
660
  let switchNotify: "" | CueStatus = "";
682
661
  // The session that raised the pending cue, so arriving on that tab (a
683
662
  // swipe or any switch) acknowledges it without opening the list.
684
663
  let switchNotifyId: string | null = null;
685
- // Cues this viewer has already SEEN, per session (see CUE_SEEN_KEY).
686
- // Loaded here so a reload starts from what the user already dismissed:
687
- // input/done stay LATCHED server-side, and the status stream re-pushes the
688
- // latch in the snapshot it sends on every open, so without this a
689
- // dismissed dot came back on the next load — and on every SSE reconnect,
690
- // which on a phone is just returning to a backgrounded page.
691
- const cueSeen = readCueSeen(win);
692
- // markCueSeen records that this viewer has seen `id` holding `status`:
693
- // either because that tab is the one on screen, or because the tray listing
694
- // every tab's dot was opened. A non-cue status is not an acknowledgeable
695
- // event, so it is ignored rather than stored.
696
- function markCueSeen(id: string, status: string): void {
697
- if (!isCueStatus(status) || cueSeen.get(id) === status) {
664
+ let switchNotifySeq = 0;
665
+ // Counts status-stream records; applyStatus compares a list's reading of it
666
+ // with each tab's streamAt to skip a list older than the stream.
667
+ let streamEpoch = 0;
668
+ let warnedSeen = false;
669
+ // The out-of-page surfaces render the SAME unread-cue set the switch dot
670
+ // shows, fed only through paintAttention below.
671
+ const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
672
+ // markCueSeen acknowledges t's current occurrence for every reader: the tab
673
+ // is on screen, or the tray listing every tab's dot was opened. Every read
674
+ // re-asserts the badge, which may still show a write the platform dropped,
675
+ // and closes the session's banner. The surfaces clear before the round trip,
676
+ // so a cue landing on the watched tab never paints a count. The automatic
677
+ // path (retry false) skips an occurrence whose ack already failed, so a
678
+ // failing server sees one PUT per occurrence rather than one per record.
679
+ function markCueSeen(t: Tab, how: { readonly retry: boolean }): void {
680
+ attention.resync();
681
+ ctx.shell.notifications.retract(t.id);
682
+ t.bannerSeq = 0;
683
+ if (!isCueStatus(statusOf(t)) || t.statusSeq <= Math.max(t.seenSeq, t.ackSeq)) {
698
684
  return;
699
685
  }
700
- cueSeen.set(id, status);
701
- // Evict the oldest entries so the live map obeys the same cap the parser
702
- // does. dropTab prunes a session closed while this page was open, but one
703
- // that vanished while the page was CLOSED leaves an entry nothing else
704
- // collects; unbounded, that would eventually push the map past the cap and
705
- // make the parser discard whatever it read last — dropping fresh
706
- // acknowledgements to keep dead ones.
707
- while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
708
- const oldest = cueSeen.keys().next().value;
709
- if (oldest === undefined) {
710
- break;
711
- }
712
- cueSeen.delete(oldest);
686
+ if (!how.retry && t.failedSeq === t.statusSeq) {
687
+ return;
713
688
  }
714
- writeCueSeen(win, cueSeen);
715
- // An acknowledgement can change the unseen count without touching the tab
716
- // list, so syncChrome's sweep is not enough on its own: opening the tray
717
- // and arriving on a tab both land here and neither adds or removes a chip.
689
+ const seq = t.statusSeq;
690
+ t.ackSeq = seq;
691
+ closeReadSurfaces(t);
718
692
  paintAttention();
693
+ api.markSeen(t.id, seq).then(
694
+ () => {
695
+ if (tornDown() || !tabList.includes(t)) {
696
+ return;
697
+ }
698
+ // seq came from the server, so a 204 means its floor is at least seq.
699
+ t.seenSeq = Math.max(t.seenSeq, seq);
700
+ if (t.ackSeq <= t.seenSeq) {
701
+ t.ackSeq = 0;
702
+ }
703
+ if (t.failedSeq <= seq) {
704
+ t.failedSeq = 0;
705
+ }
706
+ },
707
+ (err: unknown) => {
708
+ if (tornDown() || !tabList.includes(t)) {
709
+ return;
710
+ }
711
+ if (err instanceof SessionAPIError && err.status === 404) {
712
+ return; // the session is gone; its removal drops the tab
713
+ }
714
+ // A later ack owns ackSeq, and seenSeq is never written here: a
715
+ // broadcast may already carry this ack though its response was lost.
716
+ if (t.ackSeq === seq) {
717
+ t.ackSeq = 0;
718
+ }
719
+ t.failedSeq = Math.max(t.failedSeq, seq);
720
+ if (!warnedSeen) {
721
+ warnedSeen = true;
722
+ console.warn("web-terminal-ui: marking a session read failed", err);
723
+ }
724
+ paintAttention();
725
+ },
726
+ );
719
727
  }
720
- // forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
721
- // is a fresh cue: called when its status moves off the acknowledged value
722
- // (a new working phase, an exit) and when the session goes away. Every
723
- // caller ends in syncChrome, which repaints the out-of-page surfaces.
724
- function forgetCueSeen(id: string): void {
725
- if (cueSeen.delete(id)) {
726
- writeCueSeen(win, cueSeen);
728
+ // A surface this page raised for an occurrence closes once the page's read
729
+ // floor reaches that occurrence, keyed on the number rather than the fold: a
730
+ // done held by background activity folds to "" yet may own a banner, and a
731
+ // late ack of an older occurrence must not close one raised for a newer.
732
+ function closeReadSurfaces(t: Tab): void {
733
+ const floor = Math.max(t.seenSeq, t.ackSeq);
734
+ if (t.bannerSeq !== 0 && floor >= t.bannerSeq) {
735
+ ctx.shell.notifications.retract(t.id); // only this page can close its own banners
736
+ t.bannerSeq = 0;
737
+ }
738
+ if (switchNotifyId === t.id && floor >= switchNotifySeq) {
739
+ clearSwitchNotify();
727
740
  }
741
+ attention.resync();
728
742
  }
729
- // The out-of-page surfaces render the SAME unseen-cue set the switch dot
730
- // shows, fed only through paintAttention below.
731
- const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
732
743
  // visibilityState, not document.hasFocus(), which is false for a
733
744
  // visible-but-unfocused window where the terminal IS on screen.
734
745
  function pageVisible(): boolean {
@@ -738,29 +749,46 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
738
749
  // The DEFERRED half of the shown-tab acknowledgement: a cue that latched on
739
750
  // a hidden page raised the out-of-page surfaces and is acknowledged when the
740
751
  // user can see the terminal. The SHOWN tabs only: blanking a background
741
- // tab's cue the viewer never saw loses the thing they came back for.
752
+ // tab's cue the viewer never saw loses the thing they came back for. Also
753
+ // the retry point for a failed ack.
742
754
  function onPageVisible(): void {
743
755
  if (!pageVisible()) {
744
756
  return;
745
757
  }
746
758
  for (const t of tabList) {
747
759
  if (isShown(t.id)) {
748
- markCueSeen(t.id, statusOf(t));
760
+ markCueSeen(t, { retry: true });
749
761
  }
750
762
  }
751
763
  }
752
764
  doc.addEventListener("visibilitychange", onPageVisible);
765
+ // A desktop window can regain focus while it stays visible, which fires no
766
+ // visibilitychange.
767
+ win.addEventListener("focus", onPageVisible);
753
768
  ctx.defer(() => {
754
769
  doc.removeEventListener("visibilitychange", onPageVisible);
770
+ win.removeEventListener("focus", onPageVisible);
755
771
  });
756
772
 
773
+ /** The cue fold's view of a tab (isUnread), shared by the count and the raise. */
774
+ function fold(t: Tab): {
775
+ status: string;
776
+ statusSeq: number;
777
+ seenSeq: number;
778
+ ackSeq: number;
779
+ } {
780
+ return {
781
+ status: cueStatusOf(t),
782
+ statusSeq: t.statusSeq,
783
+ seenSeq: t.seenSeq,
784
+ ackSeq: t.ackSeq,
785
+ };
786
+ }
787
+
757
788
  // A FOLD over the tab list rather than incremental bookkeeping: no state of
758
789
  // its own can go stale, and the shell no-ops when nothing changed.
759
790
  function paintAttention(): void {
760
- const { count, worst } = summarizeCues(
761
- tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
762
- cueSeen,
763
- );
791
+ const { count, worst } = summarizeCues(tabList.map(fold));
764
792
  attention.report({ count, icon: worst === "" ? null : cueIconName(worst) });
765
793
  }
766
794
  function paintSwitchDot(): void {
@@ -778,11 +806,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
778
806
  function clearSwitchNotify(): void {
779
807
  switchNotify = "";
780
808
  switchNotifyId = null;
809
+ switchNotifySeq = 0;
781
810
  paintSwitchDot();
782
811
  }
783
812
  // The pending cue is resolved when its session is reached (a switch, a swipe
784
813
  // arriving on it) and moot when the session ceases to exist (a close, a
785
- // reap); opening the list alone does not clear it.
814
+ // reap).
786
815
  function acknowledgeSwitchNotify(id: string): void {
787
816
  if (switchNotifyId === id) {
788
817
  clearSwitchNotify();
@@ -1537,9 +1566,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1537
1566
  // The tray lists EVERY tab with its own status dot, so every latched tab
1538
1567
  // is acknowledged here, not just the cue's latest subject — several tabs
1539
1568
  // can be latched at once while the dot only ever showed the newest, and an
1540
- // unacknowledged sibling would re-raise it on the next load.
1569
+ // unread sibling would stay unread on every device.
1541
1570
  for (const t of tabList) {
1542
- markCueSeen(t.id, t.dot.dataset["status"] ?? "");
1571
+ markCueSeen(t, { retry: true });
1543
1572
  }
1544
1573
  clearSwitchNotify();
1545
1574
  renderSwitcherList();
@@ -1618,6 +1647,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1618
1647
  aria,
1619
1648
  view: null,
1620
1649
  reports: reportsOf(info.reportsActivity),
1650
+ // The boot list adopts without applyStatus, so these carry its numbers.
1651
+ statusSeq: normalizeSeq(info.statusSeq) ?? 0,
1652
+ seenSeq: normalizeSeq(info.seenSeq) ?? 0,
1653
+ ackSeq: 0,
1654
+ failedSeq: 0,
1655
+ bannerSeq: 0,
1656
+ streamAt: 0,
1621
1657
  };
1622
1658
  paintProgress(progressEl, renderedProgress(info.status, tab.progress));
1623
1659
  paintActivityMark(activityEl, tab.activity, tab.activityCount);
@@ -1835,7 +1871,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1835
1871
  // The terminal is now on screen, so a reload must not notify about a state
1836
1872
  // the user just looked at.
1837
1873
  acknowledgeSwitchNotify(next.id);
1838
- markCueSeen(next.id, next.dot.dataset["status"] ?? "");
1874
+ markCueSeen(next, { retry: true });
1839
1875
  const moveSelection = how.keepSelection !== true || selectedId() === null;
1840
1876
  // The view goes in WITH the bind, which makes the swap atomic: the pane's
1841
1877
  // follow flag is one per renderer, so a bind without it gated the first
@@ -2725,11 +2761,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2725
2761
  // the server reaping it cannot re-adopt the just-closed tab.
2726
2762
  tombstones.add(id);
2727
2763
  // A pending switch-button cue whose subject just closed is moot: clear
2728
- // it rather than leaving a dot no tab visit can ever resolve. Its stored
2729
- // acknowledgement goes too — the session is gone, so the entry would only
2730
- // sit in storage forever.
2764
+ // it rather than leaving a dot no tab visit can ever resolve.
2731
2765
  acknowledgeSwitchNotify(id);
2732
- forgetCueSeen(id);
2733
2766
  ctx.shell.notifications.forget(id);
2734
2767
  tab.aria.remove();
2735
2768
  // Remove immediately (no exit animation): a lingering element made the
@@ -2833,7 +2866,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2833
2866
  // re-adopt a just-closed tab.
2834
2867
  tombstones.add(t.id);
2835
2868
  acknowledgeSwitchNotify(t.id);
2836
- forgetCueSeen(t.id);
2837
2869
  ctx.shell.notifications.forget(t.id);
2838
2870
  t.aria.remove();
2839
2871
  abortReorderFor(t.el); // a live drag holds this node
@@ -3269,13 +3301,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3269
3301
 
3270
3302
  // Takes the record whole: its title fields are interchangeable strings that
3271
3303
  // as positional parameters would be swappable. `reports` travels separately
3272
- // because it is normalised (reportsOf) first.
3273
- function applyStatus(rec: StatusRecord, reports: boolean): void {
3304
+ // because it is normalised (reportsOf) first. `source` says whether the
3305
+ // record came off the stream or from a list requested at stream epoch
3306
+ // `listedAt`.
3307
+ function applyStatus(rec: StatusRecord, reports: boolean, source: RecordSource): void {
3274
3308
  const t = tabList.find((tab) => tab.id === rec.id);
3275
3309
  if (!t) {
3276
3310
  return;
3277
3311
  }
3278
- t.reports = reports;
3312
+ if ("stream" in source) {
3313
+ t.streamAt = streamEpoch;
3314
+ }
3315
+ // The stream-owned tuple is skipped from a list older than the stream's
3316
+ // last record for the tab, or carrying an older occurrence: anything such a
3317
+ // list could add arrives on the stream anyway, and applying it can hide a
3318
+ // cue no later frame restores.
3319
+ const seq = normalizeSeq(rec.statusSeq);
3320
+ const stale =
3321
+ ("listedAt" in source && t.streamAt > source.listedAt) ||
3322
+ (seq !== null && seq < t.statusSeq);
3323
+ if (!stale) {
3324
+ applyStreamTuple(t, rec, reports, seq);
3325
+ }
3279
3326
  // The server's shared position. Only an explicitly PRESENT value updates
3280
3327
  // the tab, for the same reason as progressValue below: an engine that
3281
3328
  // keeps no order sends no field, and reading that absence as "position 0"
@@ -3293,50 +3340,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3293
3340
  scheduleAddress("replace");
3294
3341
  }
3295
3342
  }
3296
- paintStatusDot(t.dot, rec.status, reports);
3297
- // Only a PRESENT percentage updates the tab: the polling fallback lists
3298
- // SessionInfo, which carries none, and reading absence as cleared would
3299
- // blank a live bar every tick. A clear arrives as an explicit -1.
3300
- if (rec.progressValue !== undefined) {
3301
- t.progress = normalizeProgress(rec.progressValue);
3302
- }
3303
- paintProgress(t.progressEl, shownProgress(t));
3304
- // The host's secondary activity, read with the same PRESENT-only guard and
3305
- // for the same reason: the polling fallback lists SessionInfo, and a
3306
- // pre-release server carries neither field at all, so reading absence as
3307
- // "no background task" would blank a live mark on every tick. An empty
3308
- // string IS the withdrawal, and it arrives explicitly.
3309
- if (rec.activity !== undefined) {
3310
- t.activity = normalizeActivity(rec.activity);
3343
+ // The seen floor only rises, so a lower value is an older record.
3344
+ const seen = normalizeSeq(rec.seenSeq);
3345
+ if (seen !== null && seen > t.seenSeq) {
3346
+ t.seenSeq = seen;
3347
+ closeReadSurfaces(t);
3311
3348
  }
3312
- if (rec.activityCount !== undefined) {
3313
- t.activityCount = normalizeActivityCount(rec.activityCount);
3314
- }
3315
- paintActivityMark(t.activityEl, t.activity, t.activityCount);
3316
3349
  // A program that speaks OSC 9 may also post a notification: arm the
3317
3350
  // permission request for the next user gesture.
3318
3351
  if (reports) {
3319
3352
  ctx.shell.notifications.arm();
3320
3353
  }
3321
- // The switch button's latest-wins dot. Cue statuses are LATCHED server-side
3322
- // and re-delivered on every snapshot, so cueSeen is what makes a dismissal
3323
- // stick. The acknowledgement reads the RAW status (a non-cue must reach the
3324
- // forget below); the raise and the forget read the FOLDED one.
3325
- const raw = statusOf(t);
3326
- const cue = cueStatusOf(t);
3327
- if (isShown(rec.id) && pageVisible() && isCueStatus(raw)) {
3354
+ // The switch button's latest-wins dot. The acknowledgement reads the RAW
3355
+ // status; the raise reads the FOLDED one, so a done held by background
3356
+ // activity raises nothing.
3357
+ const folded = fold(t);
3358
+ if (isShown(rec.id) && pageVisible() && isCueStatus(statusOf(t))) {
3328
3359
  // BOTH halves: keyed on the shown tabs alone, this swallowed the cue of
3329
3360
  // the one session the user left running on a hidden page, the case the
3330
3361
  // out-of-page surfaces exist for.
3331
- markCueSeen(rec.id, raw);
3332
- } else if (cue === "") {
3333
- // NO INFORMATION: forgetting here would re-raise the cue from scratch
3334
- // the moment the background task ended.
3335
- } else if (!isCueStatus(cue)) {
3336
- forgetCueSeen(rec.id);
3337
- } else if (isUnseenCue(cue, rec.id, cueSeen)) {
3338
- switchNotify = cue;
3362
+ markCueSeen(t, { retry: false });
3363
+ } else if (isUnread(folded)) {
3364
+ switchNotify = folded.status;
3339
3365
  switchNotifyId = rec.id;
3366
+ switchNotifySeq = t.statusSeq;
3340
3367
  paintSwitchDot();
3341
3368
  }
3342
3369
  // A BLANK title (a status sweep, or the process clearing its OSC 0/2
@@ -3355,6 +3382,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3355
3382
  }
3356
3383
  }
3357
3384
 
3385
+ // The fields only the stream decides: the status and its dot, its
3386
+ // occurrence number, the reveal gate, the percentage and the activity pair.
3387
+ function applyStreamTuple(
3388
+ t: Tab,
3389
+ rec: StatusRecord,
3390
+ reports: boolean,
3391
+ seq: number | null,
3392
+ ): void {
3393
+ t.reports = reports;
3394
+ if (seq !== null) {
3395
+ t.statusSeq = seq;
3396
+ }
3397
+ paintStatusDot(t.dot, rec.status, reports);
3398
+ // Only a PRESENT percentage updates the tab: the polling fallback lists
3399
+ // SessionInfo, which carries none, and reading absence as cleared would
3400
+ // blank a live bar every tick. A clear arrives as an explicit -1.
3401
+ if (rec.progressValue !== undefined) {
3402
+ t.progress = normalizeProgress(rec.progressValue);
3403
+ }
3404
+ paintProgress(t.progressEl, shownProgress(t));
3405
+ // The host's secondary activity, read with the same PRESENT-only guard and
3406
+ // for the same reason: the polling fallback lists SessionInfo, and a
3407
+ // pre-release server carries neither field at all, so reading absence as
3408
+ // "no background task" would blank a live mark on every tick. An empty
3409
+ // string IS the withdrawal, and it arrives explicitly.
3410
+ if (rec.activity !== undefined) {
3411
+ t.activity = normalizeActivity(rec.activity);
3412
+ }
3413
+ if (rec.activityCount !== undefined) {
3414
+ t.activityCount = normalizeActivityCount(rec.activityCount);
3415
+ }
3416
+ paintActivityMark(t.activityEl, t.activity, t.activityCount);
3417
+ }
3418
+
3358
3419
  // One list reconcile for both status sources: adopt every session the
3359
3420
  // server lists, then drop tabs it no longer lists. The SSE path runs it on
3360
3421
  // every (re)open because a RESTARTED manager's snapshot carries no
@@ -3413,6 +3474,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3413
3474
  // requested: a tab adopted while the GET was in flight is invisible to
3414
3475
  // it, and dropping it here cascaded into a duplicate replacement session.
3415
3476
  const epochAtList = tabEpoch;
3477
+ const listedAt = streamEpoch;
3416
3478
  let list: SessionInfo[];
3417
3479
  try {
3418
3480
  list = await api.list();
@@ -3435,7 +3497,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3435
3497
  }
3436
3498
  for (const info of list) {
3437
3499
  adoptSession(info); // add sessions created elsewhere (no local tab)
3438
- applyStatus(info, reportsOf(info.reportsActivity));
3500
+ applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
3439
3501
  }
3440
3502
  // Already gone server-side, so no DELETE; tabs born after the snapshot
3441
3503
  // wait for the next reconcile.
@@ -3472,13 +3534,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3472
3534
  applyingRecord = false;
3473
3535
  }
3474
3536
  }
3537
+ const listedAt = streamEpoch;
3475
3538
  const settled = await settleOrToast();
3476
3539
  if (tornDown()) {
3477
3540
  return;
3478
3541
  }
3479
3542
  for (const info of settled.sessions) {
3480
3543
  adoptSession(info);
3481
- applyStatus(info, reportsOf(info.reportsActivity));
3544
+ applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
3482
3545
  }
3483
3546
  // Read now, so an address the user navigated to during the wait wins.
3484
3547
  const route =
@@ -3714,14 +3777,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3714
3777
  void dropTab(s.id, { remote: false }); // already gone server-side; no DELETE
3715
3778
  return;
3716
3779
  }
3780
+ streamEpoch++;
3717
3781
  adoptSession(s);
3718
- applyStatus(s, reportsOf(s.reportsActivity));
3782
+ applyStatus(s, reportsOf(s.reportsActivity), { stream: true });
3719
3783
  ensureActive();
3720
3784
  syncChrome();
3721
3785
  // A notification is an EVENT delivered once, so it is handled here and
3722
3786
  // not in applyStatus, which also runs on re-delivered STATE. After
3723
3787
  // syncChrome, so its title is the label the user would see.
3724
- ctx.shell.notifications.deliver(s, {
3788
+ const posted = ctx.shell.notifications.deliver(s, {
3725
3789
  sessionIsActive: isShown(s.id),
3726
3790
  label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
3727
3791
  // switchTo no-ops on an id that is gone, which is the case that
@@ -3730,6 +3794,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3730
3794
  switchTo(s.id);
3731
3795
  },
3732
3796
  });
3797
+ const shownFor = posted ? tabList.find((t) => t.id === s.id) : undefined;
3798
+ if (shownFor) {
3799
+ // The event's own number: a skipped tuple leaves the tab's newer.
3800
+ shownFor.bannerSeq = normalizeSeq(s.statusSeq) ?? shownFor.statusSeq;
3801
+ }
3733
3802
  });
3734
3803
  ctx.defer(offStatus);
3735
3804
  // The first open joins the boot's reconcile. A reopen queues a fresh one
@@ -3738,11 +3807,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3738
3807
  let streamOpens = 0;
3739
3808
  const offStreamOpen = monitor.onStreamOpen?.(() => {
3740
3809
  streamOpens++;
3741
- if (streamOpens === 1) {
3742
- void reconcileOnce();
3743
- } else {
3744
- void withPlacementHold(() => inReconcileSlot(reconcileNow));
3745
- }
3810
+ const settled =
3811
+ streamOpens === 1
3812
+ ? reconcileOnce()
3813
+ : withPlacementHold(() => inReconcileSlot(reconcileNow));
3814
+ void settled.then(() => {
3815
+ attention.resync();
3816
+ onPageVisible(); // re-acks the shown tabs, now reconciled
3817
+ });
3746
3818
  });
3747
3819
  if (offStreamOpen) {
3748
3820
  ctx.defer(offStreamOpen);
@@ -183,7 +183,7 @@ export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
183
183
  * a notification. `working` and `warning` are ongoing and informational, so a
184
184
  * dot for them would nag with nothing to act on; `failed` is a parked result the
185
185
  * program does not revisit, so it belongs. Declared once so the raise test, the
186
- * acknowledgement store and every attention sink cannot disagree. */
186
+ * acknowledgement and every attention sink cannot disagree. */
187
187
  export type CueStatus = "input" | "done" | "crashed" | "failed";
188
188
 
189
189
  /** Severity order, most severe first, AND the complete set isCueStatus tests
@@ -226,10 +226,8 @@ export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
226
226
  const SETTLED_CUES: ReadonlySet<string> = new Set<CueStatus>(["done"]);
227
227
 
228
228
  /** The status the CUE surfaces see: the session's own, unless a settled turn's
229
- * background task is still running, in which case "". "" and NOT "idle": "" means
230
- * NO INFORMATION, so the seen map is left alone, where "idle" is a real non-cue
231
- * state that would forget the acknowledgement and re-raise the cue when the task
232
- * ends. The status DOT keeps rendering the real state. */
229
+ * background task is still running, in which case "" (no cue). The status DOT
230
+ * keeps rendering the real state. */
233
231
  export function foldedCueStatus(status: string, activity: string): string {
234
232
  if (!SETTLED_CUES.has(status) || normalizeActivity(activity) === "") {
235
233
  return status;
@@ -237,25 +235,29 @@ export function foldedCueStatus(status: string, activity: string): string {
237
235
  return "";
238
236
  }
239
237
 
240
- /** Whether a session's CURRENT status is a cue this viewer has not acknowledged;
241
- * the one predicate behind the raise and the attention count. A latch arriving
242
- * on a shown tab is acknowledged at arrival, so no caller needs a special case. */
243
- export function isUnseenCue(
244
- status: string,
245
- id: string,
246
- seen: ReadonlyMap<string, CueStatus>,
247
- ): status is CueStatus {
248
- return isCueStatus(status) && seen.get(id) !== status;
249
- }
250
-
251
238
  /** What the cue fold reads per tab; structural, so a caller passes its own tab
252
- * objects. */
239
+ * objects. `status` is the FOLDED status (foldedCueStatus). */
253
240
  interface CueCandidate {
254
- readonly id: string;
255
241
  readonly status: string;
242
+ /** the server's number for that status's occurrence */
243
+ readonly statusSeq: number;
244
+ /** the server-confirmed seen floor */
245
+ readonly seenSeq: number;
246
+ /** this page's optimistic read floor, 0 when none */
247
+ readonly ackSeq: number;
256
248
  }
257
249
 
258
- /** The unseen cues over the tab list: a COUNT for the title and the badge, one
250
+ /** Whether a session's CURRENT status is a cue no reader has acknowledged; the
251
+ * one predicate behind the raise and the attention count. The floor is the
252
+ * higher of what the server confirmed and what this page has already sent, so
253
+ * a read paints at once and a lagging record cannot relight it. */
254
+ export function isUnread<T extends CueCandidate>(
255
+ tab: T,
256
+ ): tab is T & { readonly status: CueStatus } {
257
+ return isCueStatus(tab.status) && tab.statusSeq > Math.max(tab.seenSeq, tab.ackSeq);
258
+ }
259
+
260
+ /** The unread cues over the tab list: a COUNT for the title and the badge, one
259
261
  * WORST for the icon. A count is set-valued and needs no rule for choosing among
260
262
  * sessions; severity is a total order, so the icon's choice is not arbitrary.
261
263
  * Neither names a session, the standing constraint on a page-wide surface. */
@@ -264,16 +266,12 @@ interface CueSummary {
264
266
  readonly worst: CueStatus | "";
265
267
  }
266
268
 
267
- /** Fold the tab list and this viewer's acknowledgements into the one summary
268
- * every attention surface renders. */
269
- export function summarizeCues(
270
- tabs: readonly CueCandidate[],
271
- seen: ReadonlyMap<string, CueStatus>,
272
- ): CueSummary {
269
+ /** Fold the tab list into the one summary every attention surface renders. */
270
+ export function summarizeCues(tabs: readonly CueCandidate[]): CueSummary {
273
271
  let count = 0;
274
272
  let worst: CueStatus | "" = "";
275
273
  for (const tab of tabs) {
276
- if (!isUnseenCue(tab.status, tab.id, seen)) {
274
+ if (!isUnread(tab)) {
277
275
  continue;
278
276
  }
279
277
  count += 1;
@@ -282,52 +280,10 @@ export function summarizeCues(
282
280
  return { count, worst };
283
281
  }
284
282
 
285
- /** localStorage key for the cues this viewer has SEEN: session id -> the
286
- * acknowledged status. Remembered because `input` and `done` are latched on the
287
- * server and re-delivered in every snapshot, so a dismissed dot came back on
288
- * every reload; client-side because "seen" is the VIEWER's, not the session's;
289
- * per session because several background tabs can hold a latch at once. */
290
- export const CUE_SEEN_KEY = "wt-cue-seen";
291
-
292
- /** Bound on the acknowledgement map, so a corrupted or hostile stored value
293
- * cannot make the restore path do unbounded work. */
294
- export const MAX_PERSISTED_CUE_SEEN = 200;
295
-
296
- /** Read stored acknowledgements into a clean map, dropping anything untrusted: a
297
- * lost acknowledgement only re-lights a dot the user can dismiss again. */
298
- export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
299
- const out = new Map<string, CueStatus>();
300
- if (raw === null) {
301
- return out;
302
- }
303
- let data: unknown;
304
- try {
305
- data = JSON.parse(raw);
306
- } catch {
307
- return out;
308
- }
309
- // Arrays and null are typeof "object" too, and neither is a cue map.
310
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
311
- return out;
312
- }
313
- for (const [id, status] of Object.entries(data)) {
314
- if (id === "" || typeof status !== "string" || !isCueStatus(status)) {
315
- continue;
316
- }
317
- out.set(id, status);
318
- if (out.size >= MAX_PERSISTED_CUE_SEEN) {
319
- break;
320
- }
321
- }
322
- return out;
323
- }
324
-
325
- /** serializeCueSeen encodes acknowledgements for storage. The caller keeps the
326
- * live map within the cap (see markCueSeen), so this does not truncate: a silent
327
- * truncation here would drop whichever entries the parser happened to read last,
328
- * which is the opposite of what an eviction should discard. */
329
- export function serializeCueSeen(seen: ReadonlyMap<string, CueStatus>): string {
330
- return JSON.stringify(Object.fromEntries(seen));
283
+ /** A status-occurrence number off the wire: a non-negative safe integer, or null
284
+ * for anything else, which leaves the tab's value unchanged. */
285
+ export function normalizeSeq(raw: unknown): number | null {
286
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
331
287
  }
332
288
 
333
289
  /** TabOrderKey is what the strip's order is decided by: the server's shared
@@ -457,6 +413,25 @@ export interface Tab {
457
413
  * tab menu's "use the automatic name" action. Undefined or empty means the tab
458
414
  * has no user-set name. */
459
415
  pinnedTitle?: string | undefined;
416
+ /** The server's number for the status this tab holds (0: none yet). */
417
+ statusSeq: number;
418
+ /** The server-confirmed seen floor. Only rises: a record carrying a lower one
419
+ * is older, never a retraction. */
420
+ seenSeq: number;
421
+ /** This page's optimistic read floor: the occurrence its latest ack sent, 0
422
+ * when none. A 204 clears it once seenSeq covers it, and a non-404 failure
423
+ * only while it still holds the failed number; a 404 keeps it until the
424
+ * vanished tab is removed. */
425
+ ackSeq: number;
426
+ /** The occurrence whose ack last failed with a non-404, 0 when none; the
427
+ * automatic ack skips it so a failing server sees one PUT per occurrence. */
428
+ failedSeq: number;
429
+ /** The occurrence a banner this page posted for the session belongs to, 0
430
+ * when none. */
431
+ bannerSeq: number;
432
+ /** The stream epoch at which a status-stream record last updated this tab, 0
433
+ * when none: a list requested before it is older. */
434
+ streamAt: number;
460
435
  /** Monotonic per-tab rename counter. A rename or clear increments it and
461
436
  * captures the value; the response is applied only if it is still current when
462
437
  * it lands, so a slow failure cannot roll back a newer rename, a later clear,
@@ -518,6 +493,12 @@ interface SessionAPI {
518
493
  /** Replace the shared pane layout. Throws on non-2xx as `setOrder` does: a 409
519
494
  * means a side names a session that is not live. */
520
495
  setLayout(layout: PaneLayout): Promise<void>;
496
+ /** Acknowledge `seq` for every reader. The server keeps the higher floor and
497
+ * caps it at its own statusSeq, so a resolve confirms the floor reached `seq`
498
+ * only for a `seq` this page received. Throws a SessionAPIError on non-2xx; a
499
+ * 404 means the session is gone. Sent with `keepalive`, so a read just before
500
+ * the page goes away still lands. */
501
+ markSeen(id: string, seq: number): Promise<void>;
521
502
  }
522
503
 
523
504
  /** The server's pane-layout record: which session each pane shows, the handle's
@@ -754,6 +735,17 @@ export function createSessionAPI(apiBase: string, realm: SessionAPIRealm): Sessi
754
735
  throw await sessionError("set layout", r);
755
736
  }
756
737
  },
738
+ async markSeen(id: string, seq: number): Promise<void> {
739
+ const r = await request(`${apiBase}/${encodeURIComponent(id)}/seen`, {
740
+ method: "PUT",
741
+ headers: { "Content-Type": "application/json" },
742
+ body: JSON.stringify({ seq }),
743
+ keepalive: true,
744
+ });
745
+ if (!r.ok) {
746
+ throw await sessionError("mark seen", r);
747
+ }
748
+ },
757
749
  };
758
750
  }
759
751
 
@@ -40,17 +40,18 @@ export function iconVariantHref(href: string, variant: string): string | null {
40
40
  return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
41
41
  }
42
42
 
43
- /** Render reports onto `sinks`, change-gated on the last rendered value: the
44
- * title doubles as the browser-tab label and the bookmark name, and
45
- * re-assigning an icon href makes some browsers re-fetch it. `pagehide` restores
46
- * the surfaces (a browser remembers ONE icon per URL for the bookmark and the
47
- * history row, so a tab closed on a lit cue would leave a status variant standing
48
- * in for the app), `pageshow` repaints the last report for a page that came back
49
- * from the back-forward cache, and `freeze` changes nothing because a frozen tab
50
- * is still in the strip rendering its icon. */
43
+ /** Render reports onto `sinks`. The title and icon are change-gated: the title is
44
+ * also the tab label and bookmark name, and re-assigning an icon href makes some
45
+ * browsers re-fetch it. The badge is OS state a platform may drop a write to
46
+ * without rejecting, so it is also re-asserted on `resync`, a return to visible,
47
+ * window focus and `pageshow`. `pagehide` restores the title and icon (a browser
48
+ * remembers ONE icon per URL) but leaves the badge, which means "unread" for the
49
+ * whole app rather than for this page. */
51
50
  export function createAttention(sinks: AttentionSinks, win: Window): AttentionSurface {
52
- let reported: AttentionState = NO_ATTENTION;
51
+ let reported: AttentionState | null = null;
53
52
  let rendered: AttentionState | null = null;
53
+ let badged: number | null = null;
54
+ let pendingResync: number | null = null;
54
55
 
55
56
  function render(next: AttentionState): void {
56
57
  const countChanged = next.count !== rendered?.count;
@@ -58,24 +59,58 @@ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSu
58
59
  rendered = next;
59
60
  if (countChanged) {
60
61
  sinks.setTitleMark(titleMarkFor(next.count));
61
- sinks.setBadge?.(next.count);
62
62
  }
63
63
  if (iconChanged) {
64
64
  sinks.setIcon?.(next.icon);
65
65
  }
66
66
  }
67
+ function cancelResync(): void {
68
+ if (pendingResync !== null) {
69
+ win.clearTimeout(pendingResync);
70
+ pendingResync = null;
71
+ }
72
+ }
73
+ function writeBadge(count: number): void {
74
+ cancelResync();
75
+ badged = count;
76
+ sinks.setBadge?.(count);
77
+ }
78
+ const listeners = new AbortController();
79
+ const { signal } = listeners;
80
+ // Deferred a task so this surface's listener and the caller's report on one
81
+ // event write once. Before the first report, a write would wipe the badge
82
+ // another page or a previous run set; after dispose, it would raise a cleared one.
83
+ function resync(): void {
84
+ if (reported === null || signal.aborted || pendingResync !== null) {
85
+ return;
86
+ }
87
+ pendingResync = win.setTimeout(() => {
88
+ pendingResync = null;
89
+ if (reported !== null && !signal.aborted) {
90
+ writeBadge(reported.count);
91
+ }
92
+ }, 0);
93
+ }
67
94
 
68
95
  function onPageGone(): void {
69
96
  render(NO_ATTENTION);
70
97
  }
71
98
  function onPageBack(): void {
72
- render(reported);
99
+ if (reported !== null) {
100
+ render(reported);
101
+ }
102
+ resync();
103
+ }
104
+ function onVisibility(): void {
105
+ if (win.document.visibilityState !== "hidden") {
106
+ resync();
107
+ }
73
108
  }
74
- const listeners = new AbortController();
75
- const { signal } = listeners;
76
109
  try {
77
110
  win.addEventListener("pagehide", onPageGone, { signal });
78
111
  win.addEventListener("pageshow", onPageBack, { signal });
112
+ win.addEventListener("focus", resync, { signal });
113
+ win.document.addEventListener("visibilitychange", onVisibility, { signal });
79
114
  } catch (err) {
80
115
  listeners.abort();
81
116
  throw err;
@@ -85,10 +120,15 @@ export function createAttention(sinks: AttentionSinks, win: Window): AttentionSu
85
120
  report(state) {
86
121
  reported = state;
87
122
  render(state);
123
+ if (state.count !== badged) {
124
+ writeBadge(state.count);
125
+ }
88
126
  },
127
+ resync,
89
128
  dispose() {
90
129
  listeners.abort();
91
130
  render(NO_ATTENTION);
131
+ writeBadge(0);
92
132
  },
93
133
  };
94
134
  }
@@ -38,24 +38,41 @@ export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): bo
38
38
  return !(sessionIsActive && pageVisible);
39
39
  }
40
40
 
41
- /** Bound on the per-session dedupe map: each key is a live session server-side,
42
- * so a real page is nowhere near this and a hostile stream cannot grow it. */
41
+ /** Bound on each per-session map: each key is a live session server-side, so a
42
+ * real page is nowhere near this and a hostile stream cannot grow it. */
43
43
  const MAX_TRACKED_SESSIONS = 200;
44
44
 
45
45
  export function createNotifier(env: NotifierEnv): Notifier {
46
46
  // Highest sequence delivered per session. The engine never replays a
47
47
  // notification; this makes a doubled frame unable to notify twice for one event.
48
48
  const lastSeq = new Map<string, number>();
49
+ // The banner on screen per session. A page notification is invisible to
50
+ // ServiceWorkerRegistration.getNotifications(), so only this reference can
51
+ // close it once its session is read.
52
+ const shown = new Map<string, NotificationLike>();
49
53
  let armed = false;
50
54
  let requested = false;
51
55
 
52
- function evict(): void {
53
- while (lastSeq.size > MAX_TRACKED_SESSIONS) {
54
- const oldest = lastSeq.keys().next().value;
56
+ // `drop` must remove the key, or this never ends.
57
+ function evict(map: Map<string, unknown>, drop: (id: string) => void): void {
58
+ while (map.size > MAX_TRACKED_SESSIONS) {
59
+ const oldest = map.keys().next().value;
55
60
  if (oldest === undefined) {
56
61
  return;
57
62
  }
58
- lastSeq.delete(oldest);
63
+ drop(oldest);
64
+ }
65
+ }
66
+ function retract(id: string): void {
67
+ const posted = shown.get(id);
68
+ if (posted === undefined) {
69
+ return;
70
+ }
71
+ shown.delete(id);
72
+ try {
73
+ posted.close();
74
+ } catch {
75
+ /* a banner the platform already took down */
59
76
  }
60
77
  }
61
78
 
@@ -71,7 +88,7 @@ export function createNotifier(env: NotifierEnv): Notifier {
71
88
  return false;
72
89
  }
73
90
  lastSeq.set(ev.id, seq);
74
- evict();
91
+ evict(lastSeq, (id) => lastSeq.delete(id));
75
92
  }
76
93
  if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
77
94
  return false;
@@ -90,7 +107,16 @@ export function createNotifier(env: NotifierEnv): Notifier {
90
107
  posted.onclick = (): void => {
91
108
  view.activate();
92
109
  posted.close();
110
+ if (shown.get(ev.id) === posted) {
111
+ shown.delete(ev.id);
112
+ }
93
113
  };
114
+ // Re-inserted so map order is banner age, and the banner evicted is
115
+ // closed: a dropped reference would leave it (and Android's launcher
116
+ // dot) up with nothing able to retract it.
117
+ shown.delete(ev.id);
118
+ shown.set(ev.id, posted);
119
+ evict(shown, retract);
94
120
  return true;
95
121
  } catch {
96
122
  // Safari throws on `new Notification` outside an installed PWA.
@@ -117,8 +143,10 @@ export function createNotifier(env: NotifierEnv): Notifier {
117
143
  /* a browser that refuses to be asked is a tab-only browser */
118
144
  }
119
145
  },
146
+ retract,
120
147
  forget(id): void {
121
148
  lastSeq.delete(id);
149
+ retract(id);
122
150
  },
123
151
  };
124
152
  }
@@ -438,7 +438,7 @@ function createShellInto(
438
438
  function attention(options: AttentionOptions): AttentionReporter {
439
439
  // A setup still running past destroy() gets a reporter that writes nothing.
440
440
  if (destroyed) {
441
- return { report: () => undefined };
441
+ return { report: () => undefined, resync: () => undefined };
442
442
  }
443
443
  const surface = createAttention(
444
444
  browserAttentionSinks(
@@ -240,6 +240,12 @@ export interface AttentionReporter {
240
240
  /** Render `state`. Idempotent: a state equal to the last one reported touches
241
241
  * nothing, so a status sweep may call it every tick. */
242
242
  report(state: AttentionState): void;
243
+ /** Write the latest reported count to the app badge again on the next task,
244
+ * changed or not; a `report` that writes the badge before then satisfies it.
245
+ * The page cannot read the badge back and a platform may drop a write
246
+ * silently, so a caller resyncs wherever the two may have diverged, ahead of
247
+ * the report that follows. A no-op before the first report. */
248
+ resync(): void;
243
249
  }
244
250
 
245
251
  /** The notification a session status event carries (OSC 9 `<message>`). Both
@@ -276,7 +282,10 @@ export interface Notifier {
276
282
  arm(): void;
277
283
  /** Note a user gesture: the ONLY moment a permission prompt may be raised. */
278
284
  gesture(): void;
279
- /** Drop a closed session's dedupe state. */
285
+ /** Close the notification delivered for `id`, if one is still up: the user has
286
+ * seen that session, so the banner no longer reports anything unread. */
287
+ retract(id: string): void;
288
+ /** Drop a closed session's dedupe state and retract its notification. */
280
289
  forget(id: string): void;
281
290
  }
282
291