@cplieger/web-terminal-ui 8.6.0-dev.5 → 8.6.0-dev.7
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 +7 -7
- package/src/features/tabs/index.ts +194 -179
- package/src/features/tabs/model.ts +64 -67
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.7",
|
|
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"
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
57
57
|
"@types/node": "26.6.5",
|
|
58
58
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
59
|
-
"@vitest/browser": "5.0.
|
|
60
|
-
"@vitest/browser-playwright": "5.0.
|
|
61
|
-
"@vitest/coverage-v8": "5.0.
|
|
59
|
+
"@vitest/browser": "5.0.5",
|
|
60
|
+
"@vitest/browser-playwright": "5.0.5",
|
|
61
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
62
62
|
"eslint": "10.12.0",
|
|
63
63
|
"fast-check": "4.10.2",
|
|
64
64
|
"html-validate": "11.16.2",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"stylelint": "17.16.0",
|
|
69
69
|
"stylelint-config-standard": "40.0.0",
|
|
70
70
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
71
|
-
"typescript-eslint": "8.71.
|
|
72
|
-
"vitest": "5.0.
|
|
71
|
+
"typescript-eslint": "8.71.2",
|
|
72
|
+
"vitest": "5.0.5"
|
|
73
73
|
},
|
|
74
74
|
"publishConfig": {
|
|
75
75
|
"access": "public"
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
} from "./route.js";
|
|
29
29
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
30
30
|
import {
|
|
31
|
-
MAX_PERSISTED_CUE_SEEN,
|
|
32
31
|
MAX_PINNED_NAME,
|
|
33
32
|
PROGRESS_ABSENT,
|
|
34
33
|
SWIPE_HINT_KEY,
|
|
@@ -38,18 +37,16 @@ import {
|
|
|
38
37
|
createSessionAPI,
|
|
39
38
|
createTombstones,
|
|
40
39
|
cueIconName,
|
|
41
|
-
cueSeenID,
|
|
42
|
-
cueSeenKey,
|
|
43
40
|
foldedCueStatus,
|
|
44
41
|
hasPinnedName,
|
|
45
42
|
isCueStatus,
|
|
46
43
|
isEndedStatus,
|
|
47
|
-
|
|
44
|
+
isUnread,
|
|
48
45
|
normalizeActivity,
|
|
49
46
|
normalizeActivityCount,
|
|
50
47
|
normalizeProgress,
|
|
48
|
+
normalizeSeq,
|
|
51
49
|
orderedInsertIndex,
|
|
52
|
-
parseCueSeen,
|
|
53
50
|
renderedProgress,
|
|
54
51
|
sanitizePinnedName,
|
|
55
52
|
statusPhrase,
|
|
@@ -196,50 +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
|
-
function* storedCueSeen(win: Pick<Window, "localStorage">): Generator<readonly [string, string]> {
|
|
200
|
-
try {
|
|
201
|
-
const store = win.localStorage;
|
|
202
|
-
for (let i = 0; i < store.length; i++) {
|
|
203
|
-
const key = store.key(i);
|
|
204
|
-
const id = key === null ? null : cueSeenID(key);
|
|
205
|
-
if (key !== null && id !== null) {
|
|
206
|
-
yield [id, store.getItem(key) ?? ""];
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
/* storage unavailable */
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** The acknowledged background-tab cues, or an empty map when storage throws or
|
|
215
|
-
* holds nothing trustworthy: the dot simply lights again. */
|
|
216
|
-
function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
|
|
217
|
-
return parseCueSeen(storedCueSeen(win));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Best-effort: a full quota must not break the dismissal the user just
|
|
221
|
-
* performed on screen. */
|
|
222
|
-
function writeCueSeen(win: Pick<Window, "localStorage">, id: string, status: CueStatus): void {
|
|
223
|
-
try {
|
|
224
|
-
win.localStorage.setItem(cueSeenKey(id), status);
|
|
225
|
-
} catch {
|
|
226
|
-
/* storage unavailable or full: the dismissal still holds for this page */
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Remove `id`'s stored acknowledgement only while it still holds `status`:
|
|
231
|
-
* another window may since have acknowledged the session's next cue. */
|
|
232
|
-
function dropCueSeen(win: Pick<Window, "localStorage">, id: string, status: CueStatus): void {
|
|
233
|
-
try {
|
|
234
|
-
const key = cueSeenKey(id);
|
|
235
|
-
if (win.localStorage.getItem(key) === status) {
|
|
236
|
-
win.localStorage.removeItem(key);
|
|
237
|
-
}
|
|
238
|
-
} catch {
|
|
239
|
-
/* storage unavailable: nothing stored to remove */
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
196
|
/** A wait the feature's teardown ends early: the timer goes with the feature. */
|
|
244
197
|
function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void> {
|
|
245
198
|
return new Promise((resolve) => {
|
|
@@ -255,6 +208,10 @@ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void>
|
|
|
255
208
|
});
|
|
256
209
|
}
|
|
257
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
|
+
|
|
258
215
|
/** How long a run of refusals has waited and when it last told the user. */
|
|
259
216
|
interface RefusalWait {
|
|
260
217
|
readonly startedAt: number;
|
|
@@ -481,8 +438,8 @@ type SplitOpening =
|
|
|
481
438
|
* `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
|
|
482
439
|
* It registers as the terminal's `paneLayoutOwner`, so it decides which session
|
|
483
440
|
* the first connect attaches to; two owners fail at `kernel-init`. The strip's
|
|
484
|
-
* order and the pane layout are SERVER state every viewer shares;
|
|
485
|
-
*
|
|
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
|
|
486
443
|
* the sessions running; closing a tab is what kills a process. */
|
|
487
444
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
488
445
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
@@ -697,62 +654,91 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
697
654
|
const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
|
|
698
655
|
const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
|
|
699
656
|
swBar.append(swKb, ...(swSplit === null ? [] : [swSplit]), swSwitch, swNew);
|
|
700
|
-
// Latest-wins cue for the switch button's dot, cleared when the list opens
|
|
701
|
-
// 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.
|
|
702
660
|
let switchNotify: "" | CueStatus = "";
|
|
703
661
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
704
662
|
// swipe or any switch) acknowledges it without opening the list.
|
|
705
663
|
let switchNotifyId: string | null = null;
|
|
706
|
-
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
//
|
|
712
|
-
const cueSeen = readCueSeen(win);
|
|
713
|
-
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
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
|
|
714
670
|
// shows, fed only through paintAttention below.
|
|
715
671
|
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
716
|
-
// markCueSeen
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
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 {
|
|
722
680
|
attention.resync();
|
|
723
|
-
ctx.shell.notifications.retract(id);
|
|
724
|
-
|
|
681
|
+
ctx.shell.notifications.retract(t.id);
|
|
682
|
+
t.bannerSeq = 0;
|
|
683
|
+
if (!isCueStatus(statusOf(t)) || t.statusSeq <= Math.max(t.seenSeq, t.ackSeq)) {
|
|
725
684
|
return;
|
|
726
685
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
// Evict the oldest so storage obeys the parser's cap: dropTab prunes a
|
|
730
|
-
// session closed while this page was open, but one that vanished while the
|
|
731
|
-
// page was CLOSED leaves an entry nothing else collects.
|
|
732
|
-
while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
|
|
733
|
-
const [oldest, held] = cueSeen.entries().next().value ?? [];
|
|
734
|
-
if (oldest === undefined || held === undefined) {
|
|
735
|
-
break;
|
|
736
|
-
}
|
|
737
|
-
cueSeen.delete(oldest);
|
|
738
|
-
dropCueSeen(win, oldest, held);
|
|
686
|
+
if (!how.retry && t.failedSeq === t.statusSeq) {
|
|
687
|
+
return;
|
|
739
688
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
689
|
+
const seq = t.statusSeq;
|
|
690
|
+
t.ackSeq = seq;
|
|
691
|
+
closeReadSurfaces(t);
|
|
743
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
|
+
);
|
|
744
727
|
}
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
function
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
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();
|
|
753
740
|
}
|
|
754
|
-
|
|
755
|
-
dropCueSeen(win, id, known);
|
|
741
|
+
attention.resync();
|
|
756
742
|
}
|
|
757
743
|
// visibilityState, not document.hasFocus(), which is false for a
|
|
758
744
|
// visible-but-unfocused window where the terminal IS on screen.
|
|
@@ -763,59 +749,46 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
763
749
|
// The DEFERRED half of the shown-tab acknowledgement: a cue that latched on
|
|
764
750
|
// a hidden page raised the out-of-page surfaces and is acknowledged when the
|
|
765
751
|
// user can see the terminal. The SHOWN tabs only: blanking a background
|
|
766
|
-
// 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.
|
|
767
754
|
function onPageVisible(): void {
|
|
768
755
|
if (!pageVisible()) {
|
|
769
756
|
return;
|
|
770
757
|
}
|
|
771
758
|
for (const t of tabList) {
|
|
772
759
|
if (isShown(t.id)) {
|
|
773
|
-
markCueSeen(t
|
|
760
|
+
markCueSeen(t, { retry: true });
|
|
774
761
|
}
|
|
775
762
|
}
|
|
776
763
|
}
|
|
777
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);
|
|
778
768
|
ctx.defer(() => {
|
|
779
769
|
doc.removeEventListener("visibilitychange", onPageVisible);
|
|
770
|
+
win.removeEventListener("focus", onPageVisible);
|
|
780
771
|
});
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if (cueSeen.get(id) !== status) {
|
|
796
|
-
cueSeen.set(id, status);
|
|
797
|
-
// Read elsewhere, but only this page can close the banners it posted.
|
|
798
|
-
ctx.shell.notifications.retract(id);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
if (switchNotifyId !== null && cueSeen.get(switchNotifyId) === switchNotify) {
|
|
802
|
-
clearSwitchNotify();
|
|
803
|
-
}
|
|
804
|
-
attention.resync();
|
|
805
|
-
paintAttention();
|
|
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
|
+
};
|
|
806
786
|
}
|
|
807
|
-
win.addEventListener("storage", onCueSeenStored);
|
|
808
|
-
ctx.defer(() => {
|
|
809
|
-
win.removeEventListener("storage", onCueSeenStored);
|
|
810
|
-
});
|
|
811
787
|
|
|
812
788
|
// A FOLD over the tab list rather than incremental bookkeeping: no state of
|
|
813
789
|
// its own can go stale, and the shell no-ops when nothing changed.
|
|
814
790
|
function paintAttention(): void {
|
|
815
|
-
const { count, worst } = summarizeCues(
|
|
816
|
-
tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
|
|
817
|
-
cueSeen,
|
|
818
|
-
);
|
|
791
|
+
const { count, worst } = summarizeCues(tabList.map(fold));
|
|
819
792
|
attention.report({ count, icon: worst === "" ? null : cueIconName(worst) });
|
|
820
793
|
}
|
|
821
794
|
function paintSwitchDot(): void {
|
|
@@ -833,11 +806,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
833
806
|
function clearSwitchNotify(): void {
|
|
834
807
|
switchNotify = "";
|
|
835
808
|
switchNotifyId = null;
|
|
809
|
+
switchNotifySeq = 0;
|
|
836
810
|
paintSwitchDot();
|
|
837
811
|
}
|
|
838
812
|
// The pending cue is resolved when its session is reached (a switch, a swipe
|
|
839
813
|
// arriving on it) and moot when the session ceases to exist (a close, a
|
|
840
|
-
// reap)
|
|
814
|
+
// reap).
|
|
841
815
|
function acknowledgeSwitchNotify(id: string): void {
|
|
842
816
|
if (switchNotifyId === id) {
|
|
843
817
|
clearSwitchNotify();
|
|
@@ -1592,9 +1566,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1592
1566
|
// The tray lists EVERY tab with its own status dot, so every latched tab
|
|
1593
1567
|
// is acknowledged here, not just the cue's latest subject — several tabs
|
|
1594
1568
|
// can be latched at once while the dot only ever showed the newest, and an
|
|
1595
|
-
//
|
|
1569
|
+
// unread sibling would stay unread on every device.
|
|
1596
1570
|
for (const t of tabList) {
|
|
1597
|
-
markCueSeen(t
|
|
1571
|
+
markCueSeen(t, { retry: true });
|
|
1598
1572
|
}
|
|
1599
1573
|
clearSwitchNotify();
|
|
1600
1574
|
renderSwitcherList();
|
|
@@ -1673,6 +1647,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1673
1647
|
aria,
|
|
1674
1648
|
view: null,
|
|
1675
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,
|
|
1676
1657
|
};
|
|
1677
1658
|
paintProgress(progressEl, renderedProgress(info.status, tab.progress));
|
|
1678
1659
|
paintActivityMark(activityEl, tab.activity, tab.activityCount);
|
|
@@ -1890,7 +1871,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1890
1871
|
// The terminal is now on screen, so a reload must not notify about a state
|
|
1891
1872
|
// the user just looked at.
|
|
1892
1873
|
acknowledgeSwitchNotify(next.id);
|
|
1893
|
-
markCueSeen(next
|
|
1874
|
+
markCueSeen(next, { retry: true });
|
|
1894
1875
|
const moveSelection = how.keepSelection !== true || selectedId() === null;
|
|
1895
1876
|
// The view goes in WITH the bind, which makes the swap atomic: the pane's
|
|
1896
1877
|
// follow flag is one per renderer, so a bind without it gated the first
|
|
@@ -2780,11 +2761,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2780
2761
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
2781
2762
|
tombstones.add(id);
|
|
2782
2763
|
// A pending switch-button cue whose subject just closed is moot: clear
|
|
2783
|
-
// it rather than leaving a dot no tab visit can ever resolve.
|
|
2784
|
-
// acknowledgement goes too — the session is gone, so the entry would only
|
|
2785
|
-
// sit in storage forever.
|
|
2764
|
+
// it rather than leaving a dot no tab visit can ever resolve.
|
|
2786
2765
|
acknowledgeSwitchNotify(id);
|
|
2787
|
-
forgetCueSeen(id);
|
|
2788
2766
|
ctx.shell.notifications.forget(id);
|
|
2789
2767
|
tab.aria.remove();
|
|
2790
2768
|
// Remove immediately (no exit animation): a lingering element made the
|
|
@@ -2888,7 +2866,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2888
2866
|
// re-adopt a just-closed tab.
|
|
2889
2867
|
tombstones.add(t.id);
|
|
2890
2868
|
acknowledgeSwitchNotify(t.id);
|
|
2891
|
-
forgetCueSeen(t.id);
|
|
2892
2869
|
ctx.shell.notifications.forget(t.id);
|
|
2893
2870
|
t.aria.remove();
|
|
2894
2871
|
abortReorderFor(t.el); // a live drag holds this node
|
|
@@ -3324,13 +3301,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3324
3301
|
|
|
3325
3302
|
// Takes the record whole: its title fields are interchangeable strings that
|
|
3326
3303
|
// as positional parameters would be swappable. `reports` travels separately
|
|
3327
|
-
// because it is normalised (reportsOf) first.
|
|
3328
|
-
|
|
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 {
|
|
3329
3308
|
const t = tabList.find((tab) => tab.id === rec.id);
|
|
3330
3309
|
if (!t) {
|
|
3331
3310
|
return;
|
|
3332
3311
|
}
|
|
3333
|
-
|
|
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
|
+
}
|
|
3334
3326
|
// The server's shared position. Only an explicitly PRESENT value updates
|
|
3335
3327
|
// the tab, for the same reason as progressValue below: an engine that
|
|
3336
3328
|
// keeps no order sends no field, and reading that absence as "position 0"
|
|
@@ -3348,50 +3340,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3348
3340
|
scheduleAddress("replace");
|
|
3349
3341
|
}
|
|
3350
3342
|
}
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
t.progress = normalizeProgress(rec.progressValue);
|
|
3357
|
-
}
|
|
3358
|
-
paintProgress(t.progressEl, shownProgress(t));
|
|
3359
|
-
// The host's secondary activity, read with the same PRESENT-only guard and
|
|
3360
|
-
// for the same reason: the polling fallback lists SessionInfo, and a
|
|
3361
|
-
// pre-release server carries neither field at all, so reading absence as
|
|
3362
|
-
// "no background task" would blank a live mark on every tick. An empty
|
|
3363
|
-
// string IS the withdrawal, and it arrives explicitly.
|
|
3364
|
-
if (rec.activity !== undefined) {
|
|
3365
|
-
t.activity = normalizeActivity(rec.activity);
|
|
3366
|
-
}
|
|
3367
|
-
if (rec.activityCount !== undefined) {
|
|
3368
|
-
t.activityCount = normalizeActivityCount(rec.activityCount);
|
|
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);
|
|
3369
3348
|
}
|
|
3370
|
-
paintActivityMark(t.activityEl, t.activity, t.activityCount);
|
|
3371
3349
|
// A program that speaks OSC 9 may also post a notification: arm the
|
|
3372
3350
|
// permission request for the next user gesture.
|
|
3373
3351
|
if (reports) {
|
|
3374
3352
|
ctx.shell.notifications.arm();
|
|
3375
3353
|
}
|
|
3376
|
-
// The switch button's latest-wins dot.
|
|
3377
|
-
//
|
|
3378
|
-
//
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
const cue = cueStatusOf(t);
|
|
3382
|
-
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))) {
|
|
3383
3359
|
// BOTH halves: keyed on the shown tabs alone, this swallowed the cue of
|
|
3384
3360
|
// the one session the user left running on a hidden page, the case the
|
|
3385
3361
|
// out-of-page surfaces exist for.
|
|
3386
|
-
markCueSeen(
|
|
3387
|
-
} else if (
|
|
3388
|
-
|
|
3389
|
-
// the moment the background task ended.
|
|
3390
|
-
} else if (!isCueStatus(cue)) {
|
|
3391
|
-
forgetCueSeen(rec.id);
|
|
3392
|
-
} else if (isUnseenCue(cue, rec.id, cueSeen)) {
|
|
3393
|
-
switchNotify = cue;
|
|
3362
|
+
markCueSeen(t, { retry: false });
|
|
3363
|
+
} else if (isUnread(folded)) {
|
|
3364
|
+
switchNotify = folded.status;
|
|
3394
3365
|
switchNotifyId = rec.id;
|
|
3366
|
+
switchNotifySeq = t.statusSeq;
|
|
3395
3367
|
paintSwitchDot();
|
|
3396
3368
|
}
|
|
3397
3369
|
// A BLANK title (a status sweep, or the process clearing its OSC 0/2
|
|
@@ -3410,6 +3382,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3410
3382
|
}
|
|
3411
3383
|
}
|
|
3412
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
|
+
|
|
3413
3419
|
// One list reconcile for both status sources: adopt every session the
|
|
3414
3420
|
// server lists, then drop tabs it no longer lists. The SSE path runs it on
|
|
3415
3421
|
// every (re)open because a RESTARTED manager's snapshot carries no
|
|
@@ -3468,6 +3474,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3468
3474
|
// requested: a tab adopted while the GET was in flight is invisible to
|
|
3469
3475
|
// it, and dropping it here cascaded into a duplicate replacement session.
|
|
3470
3476
|
const epochAtList = tabEpoch;
|
|
3477
|
+
const listedAt = streamEpoch;
|
|
3471
3478
|
let list: SessionInfo[];
|
|
3472
3479
|
try {
|
|
3473
3480
|
list = await api.list();
|
|
@@ -3490,7 +3497,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3490
3497
|
}
|
|
3491
3498
|
for (const info of list) {
|
|
3492
3499
|
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
3493
|
-
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3500
|
+
applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
|
|
3494
3501
|
}
|
|
3495
3502
|
// Already gone server-side, so no DELETE; tabs born after the snapshot
|
|
3496
3503
|
// wait for the next reconcile.
|
|
@@ -3527,13 +3534,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3527
3534
|
applyingRecord = false;
|
|
3528
3535
|
}
|
|
3529
3536
|
}
|
|
3537
|
+
const listedAt = streamEpoch;
|
|
3530
3538
|
const settled = await settleOrToast();
|
|
3531
3539
|
if (tornDown()) {
|
|
3532
3540
|
return;
|
|
3533
3541
|
}
|
|
3534
3542
|
for (const info of settled.sessions) {
|
|
3535
3543
|
adoptSession(info);
|
|
3536
|
-
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3544
|
+
applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
|
|
3537
3545
|
}
|
|
3538
3546
|
// Read now, so an address the user navigated to during the wait wins.
|
|
3539
3547
|
const route =
|
|
@@ -3769,14 +3777,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3769
3777
|
void dropTab(s.id, { remote: false }); // already gone server-side; no DELETE
|
|
3770
3778
|
return;
|
|
3771
3779
|
}
|
|
3780
|
+
streamEpoch++;
|
|
3772
3781
|
adoptSession(s);
|
|
3773
|
-
applyStatus(s, reportsOf(s.reportsActivity));
|
|
3782
|
+
applyStatus(s, reportsOf(s.reportsActivity), { stream: true });
|
|
3774
3783
|
ensureActive();
|
|
3775
3784
|
syncChrome();
|
|
3776
3785
|
// A notification is an EVENT delivered once, so it is handled here and
|
|
3777
3786
|
// not in applyStatus, which also runs on re-delivered STATE. After
|
|
3778
3787
|
// syncChrome, so its title is the label the user would see.
|
|
3779
|
-
ctx.shell.notifications.deliver(s, {
|
|
3788
|
+
const posted = ctx.shell.notifications.deliver(s, {
|
|
3780
3789
|
sessionIsActive: isShown(s.id),
|
|
3781
3790
|
label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
|
|
3782
3791
|
// switchTo no-ops on an id that is gone, which is the case that
|
|
@@ -3785,6 +3794,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3785
3794
|
switchTo(s.id);
|
|
3786
3795
|
},
|
|
3787
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
|
+
}
|
|
3788
3802
|
});
|
|
3789
3803
|
ctx.defer(offStatus);
|
|
3790
3804
|
// The first open joins the boot's reconcile. A reopen queues a fresh one
|
|
@@ -3799,6 +3813,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3799
3813
|
: withPlacementHold(() => inReconcileSlot(reconcileNow));
|
|
3800
3814
|
void settled.then(() => {
|
|
3801
3815
|
attention.resync();
|
|
3816
|
+
onPageVisible(); // re-acks the shown tabs, now reconciled
|
|
3802
3817
|
});
|
|
3803
3818
|
});
|
|
3804
3819
|
if (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,47 +280,10 @@ export function summarizeCues(
|
|
|
282
280
|
return { count, worst };
|
|
283
281
|
}
|
|
284
282
|
|
|
285
|
-
/**
|
|
286
|
-
*
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
* "seen" is the VIEWER's, not the session's. One key per session because every
|
|
290
|
-
* window of the app writes here: two windows acknowledging different sessions
|
|
291
|
-
* write different keys, where one shared map lets the later write drop the
|
|
292
|
-
* other's acknowledgement. */
|
|
293
|
-
const CUE_SEEN_PREFIX = "wt-cue-seen/";
|
|
294
|
-
|
|
295
|
-
/** The storage key holding `id`'s acknowledgement. */
|
|
296
|
-
export function cueSeenKey(id: string): string {
|
|
297
|
-
return CUE_SEEN_PREFIX + id;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** The session id a storage key acknowledges, or null for any other key. */
|
|
301
|
-
export function cueSeenID(key: string): string | null {
|
|
302
|
-
return key.startsWith(CUE_SEEN_PREFIX) && key.length > CUE_SEEN_PREFIX.length
|
|
303
|
-
? key.slice(CUE_SEEN_PREFIX.length)
|
|
304
|
-
: null;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** Bound on the acknowledgements read back, so a corrupted or hostile store
|
|
308
|
-
* cannot make the restore path do unbounded work. */
|
|
309
|
-
export const MAX_PERSISTED_CUE_SEEN = 200;
|
|
310
|
-
|
|
311
|
-
/** Read stored [session id, raw status] entries into a clean map, dropping
|
|
312
|
-
* anything untrusted (a lost acknowledgement only re-lights a dot the user can
|
|
313
|
-
* dismiss again), and stop reading at the cap. */
|
|
314
|
-
export function parseCueSeen(entries: Iterable<readonly [string, string]>): Map<string, CueStatus> {
|
|
315
|
-
const out = new Map<string, CueStatus>();
|
|
316
|
-
for (const [id, status] of entries) {
|
|
317
|
-
if (out.size >= MAX_PERSISTED_CUE_SEEN) {
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
if (id === "" || !isCueStatus(status)) {
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
out.set(id, status);
|
|
324
|
-
}
|
|
325
|
-
return out;
|
|
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;
|
|
326
287
|
}
|
|
327
288
|
|
|
328
289
|
/** TabOrderKey is what the strip's order is decided by: the server's shared
|
|
@@ -452,6 +413,25 @@ export interface Tab {
|
|
|
452
413
|
* tab menu's "use the automatic name" action. Undefined or empty means the tab
|
|
453
414
|
* has no user-set name. */
|
|
454
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;
|
|
455
435
|
/** Monotonic per-tab rename counter. A rename or clear increments it and
|
|
456
436
|
* captures the value; the response is applied only if it is still current when
|
|
457
437
|
* it lands, so a slow failure cannot roll back a newer rename, a later clear,
|
|
@@ -513,6 +493,12 @@ interface SessionAPI {
|
|
|
513
493
|
/** Replace the shared pane layout. Throws on non-2xx as `setOrder` does: a 409
|
|
514
494
|
* means a side names a session that is not live. */
|
|
515
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>;
|
|
516
502
|
}
|
|
517
503
|
|
|
518
504
|
/** The server's pane-layout record: which session each pane shows, the handle's
|
|
@@ -749,6 +735,17 @@ export function createSessionAPI(apiBase: string, realm: SessionAPIRealm): Sessi
|
|
|
749
735
|
throw await sessionError("set layout", r);
|
|
750
736
|
}
|
|
751
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
|
+
},
|
|
752
749
|
};
|
|
753
750
|
}
|
|
754
751
|
|