@cplieger/web-terminal-ui 5.0.2 → 5.0.3
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 +2 -2
- package/src/features/tabs/index.ts +97 -4
- package/src/features/tabs/model.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.3",
|
|
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": "GPL-3.0-or-later",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@types/node": "25.9.5",
|
|
53
53
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
54
54
|
"@vitest/coverage-v8": "4.1.10",
|
|
55
|
-
"eslint": "10.
|
|
55
|
+
"eslint": "10.8.0",
|
|
56
56
|
"fast-check": "4.9.0",
|
|
57
57
|
"happy-dom": "20.11.1",
|
|
58
58
|
"html-validate": "11.5.6",
|
|
@@ -14,9 +14,11 @@ import type { ActivityMonitorApi } from "../activity-monitor.js";
|
|
|
14
14
|
import type { MobileToolbarApi } from "../mobile-toolbar.js";
|
|
15
15
|
import { fromHTML } from "../dom.js";
|
|
16
16
|
import { createClickSwallow, placeMenuAt } from "../menu-position.js";
|
|
17
|
-
import type { SessionInfo, Tab } from "./model.js";
|
|
17
|
+
import type { CueStatus, SessionInfo, Tab } from "./model.js";
|
|
18
18
|
import {
|
|
19
19
|
ACTIVE_TAB_KEY,
|
|
20
|
+
CUE_SEEN_KEY,
|
|
21
|
+
MAX_PERSISTED_CUE_SEEN,
|
|
20
22
|
MAX_PINNED_NAME,
|
|
21
23
|
STATUS_EXITED,
|
|
22
24
|
SWIPE_HINT_KEY,
|
|
@@ -26,9 +28,12 @@ import {
|
|
|
26
28
|
createSessionAPI,
|
|
27
29
|
createTombstones,
|
|
28
30
|
hasPinnedName,
|
|
31
|
+
isCueStatus,
|
|
29
32
|
orderedInsertIndex,
|
|
33
|
+
parseCueSeen,
|
|
30
34
|
parseTabOrder,
|
|
31
35
|
sanitizePinnedName,
|
|
36
|
+
serializeCueSeen,
|
|
32
37
|
serializeTabOrder,
|
|
33
38
|
} from "./model.js";
|
|
34
39
|
import {
|
|
@@ -186,6 +191,29 @@ function writeSavedOrder(ids: readonly string[]): void {
|
|
|
186
191
|
}
|
|
187
192
|
}
|
|
188
193
|
|
|
194
|
+
/** readCueSeen loads the acknowledged background-tab cues, or an empty map when
|
|
195
|
+
* there is none to trust. Storage itself can throw (Safari private mode, a
|
|
196
|
+
* disabled third-party context, an embedder's iframe) and an unreadable map is
|
|
197
|
+
* never fatal: the dot simply lights again, exactly as it behaved before
|
|
198
|
+
* acknowledgements were remembered. */
|
|
199
|
+
function readCueSeen(): Map<string, CueStatus> {
|
|
200
|
+
try {
|
|
201
|
+
return parseCueSeen(localStorage.getItem(CUE_SEEN_KEY));
|
|
202
|
+
} catch {
|
|
203
|
+
return new Map<string, CueStatus>();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** writeCueSeen persists acknowledgements, best-effort: a full quota must not
|
|
208
|
+
* break the dismissal the user just performed on screen. */
|
|
209
|
+
function writeCueSeen(seen: ReadonlyMap<string, CueStatus>): void {
|
|
210
|
+
try {
|
|
211
|
+
localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
|
|
212
|
+
} catch {
|
|
213
|
+
/* storage unavailable or full — the dismissal still holds for this page */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
189
217
|
async function createSessionHonouringRetry(
|
|
190
218
|
api: { create: () => Promise<SessionInfo> },
|
|
191
219
|
ctx: TerminalContext,
|
|
@@ -468,6 +496,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
468
496
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
469
497
|
// swipe or any switch) acknowledges it without opening the list.
|
|
470
498
|
let switchNotifyId: string | null = null;
|
|
499
|
+
// Cues this viewer has already SEEN, per session (see CUE_SEEN_KEY).
|
|
500
|
+
// Loaded here so a reload starts from what the user already dismissed:
|
|
501
|
+
// input/done stay LATCHED server-side, and the status stream re-pushes the
|
|
502
|
+
// latch in the snapshot it sends on every open, so without this a
|
|
503
|
+
// dismissed dot came back on the next load — and on every SSE reconnect,
|
|
504
|
+
// which on a phone is just returning to a backgrounded page.
|
|
505
|
+
const cueSeen = readCueSeen();
|
|
506
|
+
// markCueSeen records that this viewer has seen `id` holding `status`:
|
|
507
|
+
// either because that tab is the one on screen, or because the tray listing
|
|
508
|
+
// every tab's dot was opened. A non-cue status is not an acknowledgeable
|
|
509
|
+
// event, so it is ignored rather than stored.
|
|
510
|
+
function markCueSeen(id: string, status: string): void {
|
|
511
|
+
if (!isCueStatus(status) || cueSeen.get(id) === status) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
cueSeen.set(id, status);
|
|
515
|
+
// Evict the oldest entries so the live map obeys the same cap the parser
|
|
516
|
+
// does. dropTab prunes a session closed while this page was open, but one
|
|
517
|
+
// that vanished while the page was CLOSED leaves an entry nothing else
|
|
518
|
+
// collects; unbounded, that would eventually push the map past the cap and
|
|
519
|
+
// make the parser discard whatever it read last — dropping fresh
|
|
520
|
+
// acknowledgements to keep dead ones.
|
|
521
|
+
while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
|
|
522
|
+
const oldest = cueSeen.keys().next().value;
|
|
523
|
+
if (oldest === undefined) {
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
cueSeen.delete(oldest);
|
|
527
|
+
}
|
|
528
|
+
writeCueSeen(cueSeen);
|
|
529
|
+
}
|
|
530
|
+
// forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
|
|
531
|
+
// is a fresh cue: called when its status moves off the acknowledged value
|
|
532
|
+
// (a new working phase, an exit) and when the session goes away.
|
|
533
|
+
function forgetCueSeen(id: string): void {
|
|
534
|
+
if (cueSeen.delete(id)) {
|
|
535
|
+
writeCueSeen(cueSeen);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
471
538
|
function paintSwitchDot(): void {
|
|
472
539
|
// Reuse the per-tab status-dot colours (single source, css/05-tabs.css
|
|
473
540
|
// .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
|
|
@@ -1100,6 +1167,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1100
1167
|
}
|
|
1101
1168
|
// Opening the list acknowledges any pending background-tab notification:
|
|
1102
1169
|
// the user is now looking at the tabs, so clear the switch button's dot.
|
|
1170
|
+
// The tray lists EVERY tab with its own status dot, so every latched tab
|
|
1171
|
+
// is acknowledged here, not just the cue's latest subject — several tabs
|
|
1172
|
+
// can be latched at once while the dot only ever showed the newest, and an
|
|
1173
|
+
// unacknowledged sibling would re-raise it on the next load.
|
|
1174
|
+
for (const t of tabList) {
|
|
1175
|
+
markCueSeen(t.id, t.dot.dataset["status"] ?? "");
|
|
1176
|
+
}
|
|
1103
1177
|
clearSwitchNotify();
|
|
1104
1178
|
renderSwitcherList();
|
|
1105
1179
|
setExpandedState(true);
|
|
@@ -1371,8 +1445,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1371
1445
|
activeId = next.id;
|
|
1372
1446
|
// Arriving on the tab that raised the switch-button cue resolves it
|
|
1373
1447
|
// (a swipe through the tabs must dismiss the dot, not only opening the
|
|
1374
|
-
// list).
|
|
1448
|
+
// list). Its current latch is acknowledged whether or not it was the
|
|
1449
|
+
// cue's subject: the terminal is now on screen, so a reload must not
|
|
1450
|
+
// notify about a state the user just looked at.
|
|
1375
1451
|
acknowledgeSwitchNotify(next.id);
|
|
1452
|
+
markCueSeen(next.id, next.dot.dataset["status"] ?? "");
|
|
1376
1453
|
try {
|
|
1377
1454
|
localStorage.setItem(ACTIVE_TAB_KEY, next.id);
|
|
1378
1455
|
} catch {
|
|
@@ -1749,8 +1826,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1749
1826
|
// the server reaping it cannot re-adopt the just-closed tab.
|
|
1750
1827
|
tombstones.add(id);
|
|
1751
1828
|
// A pending switch-button cue whose subject just closed is moot: clear
|
|
1752
|
-
// it rather than leaving a dot no tab visit can ever resolve.
|
|
1829
|
+
// it rather than leaving a dot no tab visit can ever resolve. Its stored
|
|
1830
|
+
// acknowledgement goes too — the session is gone, so the entry would only
|
|
1831
|
+
// sit in storage forever.
|
|
1753
1832
|
acknowledgeSwitchNotify(id);
|
|
1833
|
+
forgetCueSeen(id);
|
|
1754
1834
|
tab.aria.remove();
|
|
1755
1835
|
// Remove immediately (no exit animation): a lingering element made the
|
|
1756
1836
|
// "+" teleport after a delay, and made a last-tab replacement appear in
|
|
@@ -2263,7 +2343,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2263
2343
|
// list opens. The active surface keeps its own needs-input cue (see
|
|
2264
2344
|
// syncMobile); this is the moved + upgraded, glanceable version on the
|
|
2265
2345
|
// dedicated button.
|
|
2266
|
-
|
|
2346
|
+
//
|
|
2347
|
+
// Both statuses are LATCHED server-side, so this runs on re-delivered
|
|
2348
|
+
// state as well as on genuine transitions: every SSE (re)open pushes a
|
|
2349
|
+
// snapshot and the poll fallback re-lists every few seconds. What makes a
|
|
2350
|
+
// dismissal stick across those is cueSeen — a latch this viewer already
|
|
2351
|
+
// acknowledged raises nothing, while a status that moved on drops the
|
|
2352
|
+
// acknowledgement so the next latch is a fresh cue.
|
|
2353
|
+
if (!isCueStatus(rec.status)) {
|
|
2354
|
+
forgetCueSeen(rec.id);
|
|
2355
|
+
} else if (rec.id === activeId) {
|
|
2356
|
+
// The user is looking at this terminal as it latches, so there is
|
|
2357
|
+
// nothing to notify — and nothing to re-raise once they move away.
|
|
2358
|
+
markCueSeen(rec.id, rec.status);
|
|
2359
|
+
} else if (cueSeen.get(rec.id) !== rec.status) {
|
|
2267
2360
|
switchNotify = rec.status;
|
|
2268
2361
|
switchNotifyId = rec.id;
|
|
2269
2362
|
paintSwitchDot();
|
|
@@ -87,6 +87,85 @@ export function serializeTabOrder(ids: readonly string[]): string {
|
|
|
87
87
|
return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** The two session statuses that raise the mobile switcher's aggregate attention
|
|
91
|
+
* cue: a background terminal blocked on the user ("input") or one whose turn
|
|
92
|
+
* finished ("done"). Declared once here so the raise test and the
|
|
93
|
+
* acknowledgement store cannot disagree about which statuses are cue-worthy. */
|
|
94
|
+
export type CueStatus = "input" | "done";
|
|
95
|
+
|
|
96
|
+
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
97
|
+
export function isCueStatus(status: string): status is CueStatus {
|
|
98
|
+
return status === "input" || status === "done";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** localStorage key for the cues this viewer has already SEEN: session id -> the
|
|
102
|
+
* latched status that was acknowledged.
|
|
103
|
+
*
|
|
104
|
+
* It has to be remembered, because dismissing the cue does not change the
|
|
105
|
+
* session: `input` and `done` are LATCHED server-side (the engine clears them
|
|
106
|
+
* only on the session's next working phase) and the status stream re-delivers
|
|
107
|
+
* the latch in the snapshot it pushes on every open. So a dismissed dot came
|
|
108
|
+
* back on the next page load — and, since the snapshot is re-pushed on every
|
|
109
|
+
* SSE reconnect, on a phone simply returning to a backgrounded page.
|
|
110
|
+
*
|
|
111
|
+
* Client-side for the same reason as ACTIVE_TAB_KEY and TAB_ORDER_KEY: "I have
|
|
112
|
+
* seen this" is a property of the VIEWER, not of the session. A phone
|
|
113
|
+
* acknowledging a finished turn must not blank the dot on the desktop watching
|
|
114
|
+
* the same server, so this needs no engine API and the session REST surface
|
|
115
|
+
* stays the fixed four routes.
|
|
116
|
+
*
|
|
117
|
+
* Keyed per session rather than as one latest-wins slot: several background tabs
|
|
118
|
+
* can hold a latched status at once while the cue only ever shows the newest, so
|
|
119
|
+
* a single-slot acknowledgement would let every other one re-raise the dot on
|
|
120
|
+
* the next load. */
|
|
121
|
+
export const CUE_SEEN_KEY = "wt-cue-seen";
|
|
122
|
+
|
|
123
|
+
/** Bound on the acknowledgement map, same reasoning as MAX_PERSISTED_TAB_ORDER:
|
|
124
|
+
* every key is a live session server-side, so a real one is nowhere near this,
|
|
125
|
+
* and a corrupted or hostile stored value cannot make the restore path do
|
|
126
|
+
* unbounded work. */
|
|
127
|
+
export const MAX_PERSISTED_CUE_SEEN = 200;
|
|
128
|
+
|
|
129
|
+
/** parseCueSeen reads stored acknowledgements into a clean map. Anything it
|
|
130
|
+
* cannot trust is dropped (or, for a broken document, all of it): a lost
|
|
131
|
+
* acknowledgement only re-lights a dot the user can dismiss again, so degrading
|
|
132
|
+
* to "nothing acknowledged" is always safe. Pure, so it is testable without a
|
|
133
|
+
* storage backend; the caller owns the localStorage read and its try/catch. */
|
|
134
|
+
export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
|
|
135
|
+
const out = new Map<string, CueStatus>();
|
|
136
|
+
if (raw === null || raw === "") {
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
let data: unknown;
|
|
140
|
+
try {
|
|
141
|
+
data = JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
// Arrays and null are typeof "object" too, and neither is a cue map.
|
|
146
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
for (const [id, status] of Object.entries(data)) {
|
|
150
|
+
if (id === "" || typeof status !== "string" || !isCueStatus(status)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
out.set(id, status);
|
|
154
|
+
if (out.size >= MAX_PERSISTED_CUE_SEEN) {
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** serializeCueSeen encodes acknowledgements for storage. The caller keeps the
|
|
162
|
+
* live map within the cap (see markCueSeen), so this does not truncate: a silent
|
|
163
|
+
* truncation here would drop whichever entries the parser happened to read last,
|
|
164
|
+
* which is the opposite of what an eviction should discard. */
|
|
165
|
+
export function serializeCueSeen(seen: ReadonlyMap<string, CueStatus>): string {
|
|
166
|
+
return JSON.stringify(Object.fromEntries(seen));
|
|
167
|
+
}
|
|
168
|
+
|
|
90
169
|
/** orderedInsertIndex returns where `id` belongs in `current` (a list of tab ids
|
|
91
170
|
* in display order) to honour the stored arrangement `saved`.
|
|
92
171
|
*
|