@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 +3 -3
- package/src/features/tabs/index.ts +205 -133
- package/src/features/tabs/model.ts +64 -72
- package/src/kernel/attention.ts +53 -13
- package/src/kernel/notify.ts +35 -7
- package/src/kernel/shell.ts +1 -1
- package/src/kernel/types.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "8.6.0-dev.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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;
|
|
464
|
-
*
|
|
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
|
|
680
|
-
// the raising tab is visited or closed
|
|
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
|
-
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
//
|
|
694
|
-
// every tab's dot was opened.
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
function
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
1569
|
+
// unread sibling would stay unread on every device.
|
|
1541
1570
|
for (const t of tabList) {
|
|
1542
|
-
markCueSeen(t
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
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.
|
|
3322
|
-
//
|
|
3323
|
-
//
|
|
3324
|
-
|
|
3325
|
-
|
|
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(
|
|
3332
|
-
} else if (
|
|
3333
|
-
|
|
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
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
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
|
|
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 ""
|
|
230
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
268
|
-
|
|
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 (!
|
|
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
|
-
/**
|
|
286
|
-
*
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
package/src/kernel/attention.ts
CHANGED
|
@@ -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
|
|
44
|
-
*
|
|
45
|
-
* re-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/kernel/notify.ts
CHANGED
|
@@ -38,24 +38,41 @@ export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): bo
|
|
|
38
38
|
return !(sessionIsActive && pageVisible);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** Bound on
|
|
42
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/kernel/shell.ts
CHANGED
|
@@ -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(
|
package/src/kernel/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|