@cplieger/web-terminal-ui 8.6.0-dev.1 → 8.6.0-dev.10
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/README.md +1 -1
- package/css/00-tokens.css +13 -18
- package/css/01-scope.css +2 -2
- package/css/02-terminal.css +19 -40
- package/css/10-primitives.css +1 -1
- package/css/30-tabs.css +24 -92
- package/css/31-switcher.css +3 -3
- package/css/MANIFEST +3 -3
- package/css/MANIFEST.touch +1 -1
- package/css/page.css +25 -66
- package/package.json +10 -10
- package/scaffold/index.html +1 -1
- package/src/features/tabs/index.ts +224 -167
- package/src/features/tabs/model.ts +95 -103
- package/src/features/tabs/strip.ts +5 -10
- package/src/kernel/attention.ts +53 -13
- package/src/kernel/notify.ts +35 -7
- package/src/kernel/pane.ts +32 -15
- package/src/kernel/shell.ts +1 -1
- package/src/kernel/types.ts +10 -1
- package/src/predict.ts +1 -1
- package/src/presets/agent-tabbed.ts +6 -8
- package/src/presets/tabbed.ts +10 -18
- package/src/presets.ts +8 -7
- package/src/features/tabs/device-snap.ts +0 -56
|
@@ -16,7 +16,6 @@ import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
|
16
16
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
17
17
|
import { fromHTML, holdFocusOnPress } from "../dom.js";
|
|
18
18
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
19
|
-
import { snapToDevicePixels } from "./device-snap.js";
|
|
20
19
|
import { centreChipLabels } from "./ink-centre.js";
|
|
21
20
|
import { SWITCH_ANIMATIONS, SWITCH_CLASSES } from "./switch-anim.js";
|
|
22
21
|
import { createAddressBar, type AddressBar, type RouteOrigin } from "./address-bar.js";
|
|
@@ -29,8 +28,6 @@ import {
|
|
|
29
28
|
} from "./route.js";
|
|
30
29
|
import type { CueStatus, PaneLayout, SessionInfo, StatusRecord, Tab } from "./model.js";
|
|
31
30
|
import {
|
|
32
|
-
CUE_SEEN_KEY,
|
|
33
|
-
MAX_PERSISTED_CUE_SEEN,
|
|
34
31
|
MAX_PINNED_NAME,
|
|
35
32
|
PROGRESS_ABSENT,
|
|
36
33
|
SWIPE_HINT_KEY,
|
|
@@ -44,15 +41,14 @@ import {
|
|
|
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
|
-
serializeCueSeen,
|
|
56
52
|
statusPhrase,
|
|
57
53
|
summarizeCues,
|
|
58
54
|
tabAccessibleName,
|
|
@@ -177,12 +173,11 @@ export interface TabsOptions {
|
|
|
177
173
|
|
|
178
174
|
/** The layout read, the session list and session creation can be TEMPORARILY
|
|
179
175
|
* refused: a host answers 503 with `Retry-After` and a body message while it
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* is re-told periodically. */
|
|
176
|
+
* starts up or restores its sessions, a window that can run twenty minutes.
|
|
177
|
+
* Only 503 retries, on the server's own schedule and repeating its own
|
|
178
|
+
* explanation. The bound is ELAPSED TIME, not an attempt count, which at
|
|
179
|
+
* `Retry-After: 5` would give up in a minute; every iteration sleeps at least
|
|
180
|
+
* the hint, so this is never a hot loop, and the user is re-told periodically. */
|
|
186
181
|
const CREATE_RETRY_MAX_TOTAL_MS = 1200000;
|
|
187
182
|
const CREATE_RETRY_FALLBACK_MS = 5000;
|
|
188
183
|
const CREATE_RETRY_REANNOUNCE_MS = 60000;
|
|
@@ -197,29 +192,6 @@ const CATCHUP_SETTLE_MS = 250;
|
|
|
197
192
|
* multiple frames; ordinary streaming must never arm the cue. */
|
|
198
193
|
const CATCHUP_MIN_BACKLOG = 400;
|
|
199
194
|
|
|
200
|
-
/** The acknowledged background-tab cues, or an empty map when storage throws or
|
|
201
|
-
* holds nothing trustworthy: the dot simply lights again. */
|
|
202
|
-
function readCueSeen(win: Pick<Window, "localStorage">): Map<string, CueStatus> {
|
|
203
|
-
try {
|
|
204
|
-
return parseCueSeen(win.localStorage.getItem(CUE_SEEN_KEY));
|
|
205
|
-
} catch {
|
|
206
|
-
return new Map<string, CueStatus>();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** writeCueSeen persists acknowledgements, best-effort: a full quota must not
|
|
211
|
-
* break the dismissal the user just performed on screen. */
|
|
212
|
-
function writeCueSeen(
|
|
213
|
-
win: Pick<Window, "localStorage">,
|
|
214
|
-
seen: ReadonlyMap<string, CueStatus>,
|
|
215
|
-
): void {
|
|
216
|
-
try {
|
|
217
|
-
win.localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
|
|
218
|
-
} catch {
|
|
219
|
-
/* storage unavailable or full — the dismissal still holds for this page */
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
195
|
/** A wait the feature's teardown ends early: the timer goes with the feature. */
|
|
224
196
|
function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void> {
|
|
225
197
|
return new Promise((resolve) => {
|
|
@@ -235,6 +207,10 @@ function retryWait(win: Window, ms: number, signal: AbortSignal): Promise<void>
|
|
|
235
207
|
});
|
|
236
208
|
}
|
|
237
209
|
|
|
210
|
+
/** Where a status record came from: the stream, or a list requested when the
|
|
211
|
+
* stream epoch read `listedAt`. */
|
|
212
|
+
type RecordSource = { readonly stream: true } | { readonly listedAt: number };
|
|
213
|
+
|
|
238
214
|
/** How long a run of refusals has waited and when it last told the user. */
|
|
239
215
|
interface RefusalWait {
|
|
240
216
|
readonly startedAt: number;
|
|
@@ -354,14 +330,13 @@ class SettleCreateFailed extends Error {
|
|
|
354
330
|
}
|
|
355
331
|
|
|
356
332
|
/** Read the layout, then list, then (when nothing live is listed) create one
|
|
357
|
-
* session, starting the turn again after any 503, so a
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* reason. */
|
|
333
|
+
* session, starting the turn again after any 503, so a create refused
|
|
334
|
+
* mid-restore never adds a tab beside the restored ones. A non-503 layout read
|
|
335
|
+
* proves the host was not restoring when it was served, so a list issued after
|
|
336
|
+
* it sees the whole restored set. Past `CREATE_RETRY_MAX_TOTAL_MS` a refused
|
|
337
|
+
* layout read or list returns the last good list, or rethrows the 503; a create
|
|
338
|
+
* that fails for good, a spent 503 included, throws `SettleCreateFailed`
|
|
339
|
+
* carrying the list, an abort its reason. */
|
|
365
340
|
async function settleSessionSet(
|
|
366
341
|
api: SessionAPIClient,
|
|
367
342
|
ctx: TerminalContext,
|
|
@@ -461,8 +436,8 @@ type SplitOpening =
|
|
|
461
436
|
* `/api/sessions`, `?session=<id>` on the WebSocket, ideally the status SSE).
|
|
462
437
|
* It registers as the terminal's `paneLayoutOwner`, so it decides which session
|
|
463
438
|
* the first connect attaches to; two owners fail at `kernel-init`. The strip's
|
|
464
|
-
* order and the pane layout are SERVER state every viewer shares;
|
|
465
|
-
*
|
|
439
|
+
* order and the pane layout are SERVER state every viewer shares; which cues
|
|
440
|
+
* each session's readers have seen is server state too. Teardown removes the chrome and leaves
|
|
466
441
|
* the sessions running; closing a tab is what kills a process. */
|
|
467
442
|
export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
468
443
|
const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
|
|
@@ -677,59 +652,92 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
677
652
|
const swSwitchDot = pick(swSwitch, ".wt-switcher-switch-dot");
|
|
678
653
|
const swNew = makeNewButton("wt-switcher-new wt-btn wt-switcher-new-btn");
|
|
679
654
|
swBar.append(swKb, ...(swSplit === null ? [] : [swSplit]), swSwitch, swNew);
|
|
680
|
-
// Latest-wins cue for the switch button's dot, cleared when the list opens
|
|
681
|
-
// the raising tab is visited or closed
|
|
655
|
+
// Latest-wins cue for the switch button's dot, cleared when the list opens,
|
|
656
|
+
// the raising tab is visited or closed, or its occurrence (switchNotifySeq)
|
|
657
|
+
// is read on any window or device; "" is no pending cue.
|
|
682
658
|
let switchNotify: "" | CueStatus = "";
|
|
683
659
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
684
660
|
// swipe or any switch) acknowledges it without opening the list.
|
|
685
661
|
let switchNotifyId: string | null = null;
|
|
686
|
-
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
//
|
|
695
|
-
// every tab's dot was opened.
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
662
|
+
let switchNotifySeq = 0;
|
|
663
|
+
// Counts status-stream records; applyStatus compares a list's reading of it
|
|
664
|
+
// with each tab's streamAt to skip a list older than the stream.
|
|
665
|
+
let streamEpoch = 0;
|
|
666
|
+
let warnedSeen = false;
|
|
667
|
+
// The out-of-page surfaces render the SAME unread-cue set the switch dot
|
|
668
|
+
// shows, fed only through paintAttention below.
|
|
669
|
+
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
670
|
+
// markCueSeen acknowledges t's current occurrence for every reader: the tab
|
|
671
|
+
// is on screen, or the tray listing every tab's dot was opened. Every read
|
|
672
|
+
// re-asserts the badge, which may still show a write the platform dropped,
|
|
673
|
+
// and closes the session's banner. The surfaces clear before the round trip,
|
|
674
|
+
// so a cue landing on the watched tab never paints a count. The automatic
|
|
675
|
+
// path (retry false) skips an occurrence whose ack already failed, so a
|
|
676
|
+
// failing server sees one PUT per occurrence rather than one per record.
|
|
677
|
+
function markCueSeen(t: Tab, how: { readonly retry: boolean }): void {
|
|
678
|
+
attention.resync();
|
|
679
|
+
ctx.shell.notifications.retract(t.id);
|
|
680
|
+
t.bannerSeq = 0;
|
|
681
|
+
if (!isCueStatus(statusOf(t)) || t.statusSeq <= Math.max(t.seenSeq, t.ackSeq)) {
|
|
699
682
|
return;
|
|
700
683
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
// does. dropTab prunes a session closed while this page was open, but one
|
|
704
|
-
// that vanished while the page was CLOSED leaves an entry nothing else
|
|
705
|
-
// collects; unbounded, that would eventually push the map past the cap and
|
|
706
|
-
// make the parser discard whatever it read last — dropping fresh
|
|
707
|
-
// acknowledgements to keep dead ones.
|
|
708
|
-
while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
|
|
709
|
-
const oldest = cueSeen.keys().next().value;
|
|
710
|
-
if (oldest === undefined) {
|
|
711
|
-
break;
|
|
712
|
-
}
|
|
713
|
-
cueSeen.delete(oldest);
|
|
684
|
+
if (!how.retry && t.failedSeq === t.statusSeq) {
|
|
685
|
+
return;
|
|
714
686
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
687
|
+
const seq = t.statusSeq;
|
|
688
|
+
t.ackSeq = seq;
|
|
689
|
+
closeReadSurfaces(t);
|
|
719
690
|
paintAttention();
|
|
691
|
+
api.markSeen(t.id, seq).then(
|
|
692
|
+
() => {
|
|
693
|
+
if (tornDown() || !tabList.includes(t)) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
// seq came from the server, so a 204 means its floor is at least seq.
|
|
697
|
+
t.seenSeq = Math.max(t.seenSeq, seq);
|
|
698
|
+
if (t.ackSeq <= t.seenSeq) {
|
|
699
|
+
t.ackSeq = 0;
|
|
700
|
+
}
|
|
701
|
+
if (t.failedSeq <= seq) {
|
|
702
|
+
t.failedSeq = 0;
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
(err: unknown) => {
|
|
706
|
+
if (tornDown() || !tabList.includes(t)) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (err instanceof SessionAPIError && err.status === 404) {
|
|
710
|
+
return; // the session is gone; its removal drops the tab
|
|
711
|
+
}
|
|
712
|
+
// A later ack owns ackSeq, and seenSeq is never written here: a
|
|
713
|
+
// broadcast may already carry this ack though its response was lost.
|
|
714
|
+
if (t.ackSeq === seq) {
|
|
715
|
+
t.ackSeq = 0;
|
|
716
|
+
}
|
|
717
|
+
t.failedSeq = Math.max(t.failedSeq, seq);
|
|
718
|
+
if (!warnedSeen) {
|
|
719
|
+
warnedSeen = true;
|
|
720
|
+
console.warn("web-terminal-ui: marking a session read failed", err);
|
|
721
|
+
}
|
|
722
|
+
paintAttention();
|
|
723
|
+
},
|
|
724
|
+
);
|
|
720
725
|
}
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
function
|
|
726
|
-
|
|
727
|
-
|
|
726
|
+
// A surface this page raised for an occurrence closes once the page's read
|
|
727
|
+
// floor reaches that occurrence, keyed on the number rather than the fold: a
|
|
728
|
+
// done held by background activity folds to "" yet may own a banner, and a
|
|
729
|
+
// late ack of an older occurrence must not close one raised for a newer.
|
|
730
|
+
function closeReadSurfaces(t: Tab): void {
|
|
731
|
+
const floor = Math.max(t.seenSeq, t.ackSeq);
|
|
732
|
+
if (t.bannerSeq !== 0 && floor >= t.bannerSeq) {
|
|
733
|
+
ctx.shell.notifications.retract(t.id); // only this page can close its own banners
|
|
734
|
+
t.bannerSeq = 0;
|
|
735
|
+
}
|
|
736
|
+
if (switchNotifyId === t.id && floor >= switchNotifySeq) {
|
|
737
|
+
clearSwitchNotify();
|
|
728
738
|
}
|
|
739
|
+
attention.resync();
|
|
729
740
|
}
|
|
730
|
-
// The out-of-page surfaces render the SAME unseen-cue set the switch dot
|
|
731
|
-
// shows, fed only through paintAttention below.
|
|
732
|
-
const attention = ctx.shell.attention({ icons: opts.attentionIcons === true });
|
|
733
741
|
// visibilityState, not document.hasFocus(), which is false for a
|
|
734
742
|
// visible-but-unfocused window where the terminal IS on screen.
|
|
735
743
|
function pageVisible(): boolean {
|
|
@@ -739,29 +747,46 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
739
747
|
// The DEFERRED half of the shown-tab acknowledgement: a cue that latched on
|
|
740
748
|
// a hidden page raised the out-of-page surfaces and is acknowledged when the
|
|
741
749
|
// user can see the terminal. The SHOWN tabs only: blanking a background
|
|
742
|
-
// tab's cue the viewer never saw loses the thing they came back for.
|
|
750
|
+
// tab's cue the viewer never saw loses the thing they came back for. Also
|
|
751
|
+
// the retry point for a failed ack.
|
|
743
752
|
function onPageVisible(): void {
|
|
744
753
|
if (!pageVisible()) {
|
|
745
754
|
return;
|
|
746
755
|
}
|
|
747
756
|
for (const t of tabList) {
|
|
748
757
|
if (isShown(t.id)) {
|
|
749
|
-
markCueSeen(t
|
|
758
|
+
markCueSeen(t, { retry: true });
|
|
750
759
|
}
|
|
751
760
|
}
|
|
752
761
|
}
|
|
753
762
|
doc.addEventListener("visibilitychange", onPageVisible);
|
|
763
|
+
// A desktop window can regain focus while it stays visible, which fires no
|
|
764
|
+
// visibilitychange.
|
|
765
|
+
win.addEventListener("focus", onPageVisible);
|
|
754
766
|
ctx.defer(() => {
|
|
755
767
|
doc.removeEventListener("visibilitychange", onPageVisible);
|
|
768
|
+
win.removeEventListener("focus", onPageVisible);
|
|
756
769
|
});
|
|
757
770
|
|
|
771
|
+
/** The cue fold's view of a tab (isUnread), shared by the count and the raise. */
|
|
772
|
+
function fold(t: Tab): {
|
|
773
|
+
status: string;
|
|
774
|
+
statusSeq: number;
|
|
775
|
+
seenSeq: number;
|
|
776
|
+
ackSeq: number;
|
|
777
|
+
} {
|
|
778
|
+
return {
|
|
779
|
+
status: cueStatusOf(t),
|
|
780
|
+
statusSeq: t.statusSeq,
|
|
781
|
+
seenSeq: t.seenSeq,
|
|
782
|
+
ackSeq: t.ackSeq,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
758
786
|
// A FOLD over the tab list rather than incremental bookkeeping: no state of
|
|
759
787
|
// its own can go stale, and the shell no-ops when nothing changed.
|
|
760
788
|
function paintAttention(): void {
|
|
761
|
-
const { count, worst } = summarizeCues(
|
|
762
|
-
tabList.map((t) => ({ id: t.id, status: cueStatusOf(t) })),
|
|
763
|
-
cueSeen,
|
|
764
|
-
);
|
|
789
|
+
const { count, worst } = summarizeCues(tabList.map(fold));
|
|
765
790
|
attention.report({ count, icon: worst === "" ? null : cueIconName(worst) });
|
|
766
791
|
}
|
|
767
792
|
function paintSwitchDot(): void {
|
|
@@ -779,11 +804,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
779
804
|
function clearSwitchNotify(): void {
|
|
780
805
|
switchNotify = "";
|
|
781
806
|
switchNotifyId = null;
|
|
807
|
+
switchNotifySeq = 0;
|
|
782
808
|
paintSwitchDot();
|
|
783
809
|
}
|
|
784
810
|
// The pending cue is resolved when its session is reached (a switch, a swipe
|
|
785
811
|
// arriving on it) and moot when the session ceases to exist (a close, a
|
|
786
|
-
// reap)
|
|
812
|
+
// reap).
|
|
787
813
|
function acknowledgeSwitchNotify(id: string): void {
|
|
788
814
|
if (switchNotifyId === id) {
|
|
789
815
|
clearSwitchNotify();
|
|
@@ -833,12 +859,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
833
859
|
// engine produced for THIS font at THIS size, rather than the em constant
|
|
834
860
|
// in 00-tokens.css that can only be right at one size (see ink-centre.ts).
|
|
835
861
|
ctx.defer(centreChipLabels(varRoot, { strip: bar, switcher }));
|
|
836
|
-
for (const b of splitButtons) {
|
|
837
|
-
const icon = b.querySelector("svg");
|
|
838
|
-
if (icon) {
|
|
839
|
-
ctx.defer(snapToDevicePixels(icon, ctx.shell.root));
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
862
|
|
|
843
863
|
// Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
|
|
844
864
|
// scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
|
|
@@ -986,9 +1006,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
986
1006
|
pageTitle.release();
|
|
987
1007
|
});
|
|
988
1008
|
}
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
// PRESS-time frame, so a move a hold lifted across still ends the drag.
|
|
1009
|
+
// A live drag's viewport is judged against the PRESS-time frame, so a move a
|
|
1010
|
+
// hold lifted across still ends the drag.
|
|
992
1011
|
let draggingEl: HTMLElement | null = null;
|
|
993
1012
|
// The chips currently carrying an inline displacement from the commit slide.
|
|
994
1013
|
// A set plus one settle function, so whatever a slide wrote can always be handed
|
|
@@ -1048,9 +1067,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1048
1067
|
const prefersReduce = (): boolean =>
|
|
1049
1068
|
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1050
1069
|
|
|
1051
|
-
// Recomputes every display label with de-duplication.
|
|
1052
|
-
// is applied at render time and stored nowhere, so de-duplication and the
|
|
1053
|
-
// rename field see real names and clearing it needs no cleanup.
|
|
1070
|
+
// Recomputes every display label with de-duplication.
|
|
1054
1071
|
function relabelAll(): void {
|
|
1055
1072
|
// The "(k)" suffix is numbered by the session's AGE, never by its slot:
|
|
1056
1073
|
// numbered by encounter order, the labels stayed put while the sessions
|
|
@@ -1120,11 +1137,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1120
1137
|
return foldedCueStatus(statusOf(t), t.activity);
|
|
1121
1138
|
}
|
|
1122
1139
|
|
|
1123
|
-
/** shownProgress is a tab's percentage as it may currently be DISPLAYED
|
|
1124
|
-
*
|
|
1125
|
-
* one the progress channel owns. Those are the only two ways a percentage
|
|
1126
|
-
* stops showing — the program's own OSC 9;4;0 (an explicit -1) and a status
|
|
1127
|
-
* from another channel; see renderedProgress for why there is no third. */
|
|
1140
|
+
/** shownProgress is a tab's percentage as it may currently be DISPLAYED;
|
|
1141
|
+
* renderedProgress owns when that is. */
|
|
1128
1142
|
function shownProgress(t: Tab): number {
|
|
1129
1143
|
return renderedProgress(statusOf(t), t.progress);
|
|
1130
1144
|
}
|
|
@@ -1544,9 +1558,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1544
1558
|
// The tray lists EVERY tab with its own status dot, so every latched tab
|
|
1545
1559
|
// is acknowledged here, not just the cue's latest subject — several tabs
|
|
1546
1560
|
// can be latched at once while the dot only ever showed the newest, and an
|
|
1547
|
-
//
|
|
1561
|
+
// unread sibling would stay unread on every device.
|
|
1548
1562
|
for (const t of tabList) {
|
|
1549
|
-
markCueSeen(t
|
|
1563
|
+
markCueSeen(t, { retry: true });
|
|
1550
1564
|
}
|
|
1551
1565
|
clearSwitchNotify();
|
|
1552
1566
|
renderSwitcherList();
|
|
@@ -1625,6 +1639,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1625
1639
|
aria,
|
|
1626
1640
|
view: null,
|
|
1627
1641
|
reports: reportsOf(info.reportsActivity),
|
|
1642
|
+
// The boot list adopts without applyStatus, so these carry its numbers.
|
|
1643
|
+
statusSeq: normalizeSeq(info.statusSeq) ?? 0,
|
|
1644
|
+
seenSeq: normalizeSeq(info.seenSeq) ?? 0,
|
|
1645
|
+
ackSeq: 0,
|
|
1646
|
+
failedSeq: 0,
|
|
1647
|
+
bannerSeq: 0,
|
|
1648
|
+
streamAt: 0,
|
|
1628
1649
|
};
|
|
1629
1650
|
paintProgress(progressEl, renderedProgress(info.status, tab.progress));
|
|
1630
1651
|
paintActivityMark(activityEl, tab.activity, tab.activityCount);
|
|
@@ -1842,7 +1863,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1842
1863
|
// The terminal is now on screen, so a reload must not notify about a state
|
|
1843
1864
|
// the user just looked at.
|
|
1844
1865
|
acknowledgeSwitchNotify(next.id);
|
|
1845
|
-
markCueSeen(next
|
|
1866
|
+
markCueSeen(next, { retry: true });
|
|
1846
1867
|
const moveSelection = how.keepSelection !== true || selectedId() === null;
|
|
1847
1868
|
// The view goes in WITH the bind, which makes the swap atomic: the pane's
|
|
1848
1869
|
// follow flag is one per renderer, so a bind without it gated the first
|
|
@@ -2732,11 +2753,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2732
2753
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
2733
2754
|
tombstones.add(id);
|
|
2734
2755
|
// A pending switch-button cue whose subject just closed is moot: clear
|
|
2735
|
-
// it rather than leaving a dot no tab visit can ever resolve.
|
|
2736
|
-
// acknowledgement goes too — the session is gone, so the entry would only
|
|
2737
|
-
// sit in storage forever.
|
|
2756
|
+
// it rather than leaving a dot no tab visit can ever resolve.
|
|
2738
2757
|
acknowledgeSwitchNotify(id);
|
|
2739
|
-
forgetCueSeen(id);
|
|
2740
2758
|
ctx.shell.notifications.forget(id);
|
|
2741
2759
|
tab.aria.remove();
|
|
2742
2760
|
// Remove immediately (no exit animation): a lingering element made the
|
|
@@ -2840,7 +2858,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2840
2858
|
// re-adopt a just-closed tab.
|
|
2841
2859
|
tombstones.add(t.id);
|
|
2842
2860
|
acknowledgeSwitchNotify(t.id);
|
|
2843
|
-
forgetCueSeen(t.id);
|
|
2844
2861
|
ctx.shell.notifications.forget(t.id);
|
|
2845
2862
|
t.aria.remove();
|
|
2846
2863
|
abortReorderFor(t.el); // a live drag holds this node
|
|
@@ -3276,13 +3293,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3276
3293
|
|
|
3277
3294
|
// Takes the record whole: its title fields are interchangeable strings that
|
|
3278
3295
|
// as positional parameters would be swappable. `reports` travels separately
|
|
3279
|
-
// because it is normalised (reportsOf) first.
|
|
3280
|
-
|
|
3296
|
+
// because it is normalised (reportsOf) first. `source` says whether the
|
|
3297
|
+
// record came off the stream or from a list requested at stream epoch
|
|
3298
|
+
// `listedAt`.
|
|
3299
|
+
function applyStatus(rec: StatusRecord, reports: boolean, source: RecordSource): void {
|
|
3281
3300
|
const t = tabList.find((tab) => tab.id === rec.id);
|
|
3282
3301
|
if (!t) {
|
|
3283
3302
|
return;
|
|
3284
3303
|
}
|
|
3285
|
-
|
|
3304
|
+
if ("stream" in source) {
|
|
3305
|
+
t.streamAt = streamEpoch;
|
|
3306
|
+
}
|
|
3307
|
+
// The stream-owned tuple is skipped from a list older than the stream's
|
|
3308
|
+
// last record for the tab, or carrying an older occurrence: anything such a
|
|
3309
|
+
// list could add arrives on the stream anyway, and applying it can hide a
|
|
3310
|
+
// cue no later frame restores.
|
|
3311
|
+
const seq = normalizeSeq(rec.statusSeq);
|
|
3312
|
+
const stale =
|
|
3313
|
+
("listedAt" in source && t.streamAt > source.listedAt) ||
|
|
3314
|
+
(seq !== null && seq < t.statusSeq);
|
|
3315
|
+
if (!stale) {
|
|
3316
|
+
applyStreamTuple(t, rec, reports, seq);
|
|
3317
|
+
}
|
|
3286
3318
|
// The server's shared position. Only an explicitly PRESENT value updates
|
|
3287
3319
|
// the tab, for the same reason as progressValue below: an engine that
|
|
3288
3320
|
// keeps no order sends no field, and reading that absence as "position 0"
|
|
@@ -3300,50 +3332,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3300
3332
|
scheduleAddress("replace");
|
|
3301
3333
|
}
|
|
3302
3334
|
}
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
t.progress = normalizeProgress(rec.progressValue);
|
|
3309
|
-
}
|
|
3310
|
-
paintProgress(t.progressEl, shownProgress(t));
|
|
3311
|
-
// The host's secondary activity, read with the same PRESENT-only guard and
|
|
3312
|
-
// for the same reason: the polling fallback lists SessionInfo, and a
|
|
3313
|
-
// pre-release server carries neither field at all, so reading absence as
|
|
3314
|
-
// "no background task" would blank a live mark on every tick. An empty
|
|
3315
|
-
// string IS the withdrawal, and it arrives explicitly.
|
|
3316
|
-
if (rec.activity !== undefined) {
|
|
3317
|
-
t.activity = normalizeActivity(rec.activity);
|
|
3318
|
-
}
|
|
3319
|
-
if (rec.activityCount !== undefined) {
|
|
3320
|
-
t.activityCount = normalizeActivityCount(rec.activityCount);
|
|
3335
|
+
// The seen floor only rises, so a lower value is an older record.
|
|
3336
|
+
const seen = normalizeSeq(rec.seenSeq);
|
|
3337
|
+
if (seen !== null && seen > t.seenSeq) {
|
|
3338
|
+
t.seenSeq = seen;
|
|
3339
|
+
closeReadSurfaces(t);
|
|
3321
3340
|
}
|
|
3322
|
-
|
|
3323
|
-
//
|
|
3324
|
-
// permission request for the next user gesture.
|
|
3341
|
+
// A program that reports activity may also post an OSC 9 notification:
|
|
3342
|
+
// arm the permission request for the next user gesture.
|
|
3325
3343
|
if (reports) {
|
|
3326
3344
|
ctx.shell.notifications.arm();
|
|
3327
3345
|
}
|
|
3328
|
-
// The switch button's latest-wins dot.
|
|
3329
|
-
//
|
|
3330
|
-
//
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
const cue = cueStatusOf(t);
|
|
3334
|
-
if (isShown(rec.id) && pageVisible() && isCueStatus(raw)) {
|
|
3346
|
+
// The switch button's latest-wins dot. The acknowledgement reads the RAW
|
|
3347
|
+
// status; the raise reads the FOLDED one, so a done held by background
|
|
3348
|
+
// activity raises nothing.
|
|
3349
|
+
const folded = fold(t);
|
|
3350
|
+
if (isShown(rec.id) && pageVisible() && isCueStatus(statusOf(t))) {
|
|
3335
3351
|
// BOTH halves: keyed on the shown tabs alone, this swallowed the cue of
|
|
3336
3352
|
// the one session the user left running on a hidden page, the case the
|
|
3337
3353
|
// out-of-page surfaces exist for.
|
|
3338
|
-
markCueSeen(
|
|
3339
|
-
} else if (
|
|
3340
|
-
|
|
3341
|
-
// the moment the background task ended.
|
|
3342
|
-
} else if (!isCueStatus(cue)) {
|
|
3343
|
-
forgetCueSeen(rec.id);
|
|
3344
|
-
} else if (isUnseenCue(cue, rec.id, cueSeen)) {
|
|
3345
|
-
switchNotify = cue;
|
|
3354
|
+
markCueSeen(t, { retry: false });
|
|
3355
|
+
} else if (isUnread(folded)) {
|
|
3356
|
+
switchNotify = folded.status;
|
|
3346
3357
|
switchNotifyId = rec.id;
|
|
3358
|
+
switchNotifySeq = t.statusSeq;
|
|
3347
3359
|
paintSwitchDot();
|
|
3348
3360
|
}
|
|
3349
3361
|
// A BLANK title (a status sweep, or the process clearing its OSC 0/2
|
|
@@ -3362,6 +3374,40 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3362
3374
|
}
|
|
3363
3375
|
}
|
|
3364
3376
|
|
|
3377
|
+
// The fields only the stream decides: the status and its dot, its
|
|
3378
|
+
// occurrence number, the reveal gate, the percentage and the activity pair.
|
|
3379
|
+
function applyStreamTuple(
|
|
3380
|
+
t: Tab,
|
|
3381
|
+
rec: StatusRecord,
|
|
3382
|
+
reports: boolean,
|
|
3383
|
+
seq: number | null,
|
|
3384
|
+
): void {
|
|
3385
|
+
t.reports = reports;
|
|
3386
|
+
if (seq !== null) {
|
|
3387
|
+
t.statusSeq = seq;
|
|
3388
|
+
}
|
|
3389
|
+
paintStatusDot(t.dot, rec.status, reports);
|
|
3390
|
+
// Only a PRESENT percentage updates the tab: the polling fallback lists
|
|
3391
|
+
// SessionInfo, which carries none, and reading absence as cleared would
|
|
3392
|
+
// blank a live bar every tick. A clear arrives as an explicit -1.
|
|
3393
|
+
if (rec.progressValue !== undefined) {
|
|
3394
|
+
t.progress = normalizeProgress(rec.progressValue);
|
|
3395
|
+
}
|
|
3396
|
+
paintProgress(t.progressEl, shownProgress(t));
|
|
3397
|
+
// The host's secondary activity, read with the same PRESENT-only guard and
|
|
3398
|
+
// for the same reason: the polling fallback lists SessionInfo, and a
|
|
3399
|
+
// pre-release server carries neither field at all, so reading absence as
|
|
3400
|
+
// "no background task" would blank a live mark on every tick. An empty
|
|
3401
|
+
// string IS the withdrawal, and it arrives explicitly.
|
|
3402
|
+
if (rec.activity !== undefined) {
|
|
3403
|
+
t.activity = normalizeActivity(rec.activity);
|
|
3404
|
+
}
|
|
3405
|
+
if (rec.activityCount !== undefined) {
|
|
3406
|
+
t.activityCount = normalizeActivityCount(rec.activityCount);
|
|
3407
|
+
}
|
|
3408
|
+
paintActivityMark(t.activityEl, t.activity, t.activityCount);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3365
3411
|
// One list reconcile for both status sources: adopt every session the
|
|
3366
3412
|
// server lists, then drop tabs it no longer lists. The SSE path runs it on
|
|
3367
3413
|
// every (re)open because a RESTARTED manager's snapshot carries no
|
|
@@ -3420,6 +3466,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3420
3466
|
// requested: a tab adopted while the GET was in flight is invisible to
|
|
3421
3467
|
// it, and dropping it here cascaded into a duplicate replacement session.
|
|
3422
3468
|
const epochAtList = tabEpoch;
|
|
3469
|
+
const listedAt = streamEpoch;
|
|
3423
3470
|
let list: SessionInfo[];
|
|
3424
3471
|
try {
|
|
3425
3472
|
list = await api.list();
|
|
@@ -3442,7 +3489,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3442
3489
|
}
|
|
3443
3490
|
for (const info of list) {
|
|
3444
3491
|
adoptSession(info); // add sessions created elsewhere (no local tab)
|
|
3445
|
-
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3492
|
+
applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
|
|
3446
3493
|
}
|
|
3447
3494
|
// Already gone server-side, so no DELETE; tabs born after the snapshot
|
|
3448
3495
|
// wait for the next reconcile.
|
|
@@ -3479,13 +3526,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3479
3526
|
applyingRecord = false;
|
|
3480
3527
|
}
|
|
3481
3528
|
}
|
|
3529
|
+
const listedAt = streamEpoch;
|
|
3482
3530
|
const settled = await settleOrToast();
|
|
3483
3531
|
if (tornDown()) {
|
|
3484
3532
|
return;
|
|
3485
3533
|
}
|
|
3486
3534
|
for (const info of settled.sessions) {
|
|
3487
3535
|
adoptSession(info);
|
|
3488
|
-
applyStatus(info, reportsOf(info.reportsActivity));
|
|
3536
|
+
applyStatus(info, reportsOf(info.reportsActivity), { listedAt });
|
|
3489
3537
|
}
|
|
3490
3538
|
// Read now, so an address the user navigated to during the wait wins.
|
|
3491
3539
|
const route =
|
|
@@ -3721,14 +3769,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3721
3769
|
void dropTab(s.id, { remote: false }); // already gone server-side; no DELETE
|
|
3722
3770
|
return;
|
|
3723
3771
|
}
|
|
3772
|
+
streamEpoch++;
|
|
3724
3773
|
adoptSession(s);
|
|
3725
|
-
applyStatus(s, reportsOf(s.reportsActivity));
|
|
3774
|
+
applyStatus(s, reportsOf(s.reportsActivity), { stream: true });
|
|
3726
3775
|
ensureActive();
|
|
3727
3776
|
syncChrome();
|
|
3728
3777
|
// A notification is an EVENT delivered once, so it is handled here and
|
|
3729
3778
|
// not in applyStatus, which also runs on re-delivered STATE. After
|
|
3730
3779
|
// syncChrome, so its title is the label the user would see.
|
|
3731
|
-
ctx.shell.notifications.deliver(s, {
|
|
3780
|
+
const posted = ctx.shell.notifications.deliver(s, {
|
|
3732
3781
|
sessionIsActive: isShown(s.id),
|
|
3733
3782
|
label: tabList.find((t) => t.id === s.id)?.display ?? s.title,
|
|
3734
3783
|
// switchTo no-ops on an id that is gone, which is the case that
|
|
@@ -3737,6 +3786,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3737
3786
|
switchTo(s.id);
|
|
3738
3787
|
},
|
|
3739
3788
|
});
|
|
3789
|
+
const shownFor = posted ? tabList.find((t) => t.id === s.id) : undefined;
|
|
3790
|
+
if (shownFor) {
|
|
3791
|
+
// The event's own number: a skipped tuple leaves the tab's newer.
|
|
3792
|
+
shownFor.bannerSeq = normalizeSeq(s.statusSeq) ?? shownFor.statusSeq;
|
|
3793
|
+
}
|
|
3740
3794
|
});
|
|
3741
3795
|
ctx.defer(offStatus);
|
|
3742
3796
|
// The first open joins the boot's reconcile. A reopen queues a fresh one
|
|
@@ -3745,11 +3799,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3745
3799
|
let streamOpens = 0;
|
|
3746
3800
|
const offStreamOpen = monitor.onStreamOpen?.(() => {
|
|
3747
3801
|
streamOpens++;
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3802
|
+
const settled =
|
|
3803
|
+
streamOpens === 1
|
|
3804
|
+
? reconcileOnce()
|
|
3805
|
+
: withPlacementHold(() => inReconcileSlot(reconcileNow));
|
|
3806
|
+
void settled.then(() => {
|
|
3807
|
+
attention.resync();
|
|
3808
|
+
onPageVisible(); // re-acks the shown tabs, now reconciled
|
|
3809
|
+
});
|
|
3753
3810
|
});
|
|
3754
3811
|
if (offStreamOpen) {
|
|
3755
3812
|
ctx.defer(offStreamOpen);
|