@cplieger/web-terminal-ui 8.6.0-dev.1 → 8.6.0-dev.11
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
|
@@ -19,7 +19,9 @@ export type StatusRecord = SessionInfo & {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// The status vocabulary is module-private so a status's MEANING has one home:
|
|
22
|
-
// every consumer asks a predicate below instead of comparing strings.
|
|
22
|
+
// every consumer asks a predicate below instead of comparing strings. The
|
|
23
|
+
// protocol states named below are the built-in sources; a classified OSC 9
|
|
24
|
+
// notification may set any status.
|
|
23
25
|
//
|
|
24
26
|
// An exited session is viewable history that can never produce output again, so
|
|
25
27
|
// session selection prefers live sessions everywhere.
|
|
@@ -30,11 +32,14 @@ const STATUS_EXITED = "exited";
|
|
|
30
32
|
* restart never paints as a failure. Terminal, like "exited". */
|
|
31
33
|
const STATUS_CRASHED = "crashed";
|
|
32
34
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
+
/** An error the program reported: OSC 7501 state `error`, or OSC 9;4 progress
|
|
36
|
+
* state 2 (iTerm2 semantics). A STATE that persists until the program reports
|
|
37
|
+
* another, or the process dies. */
|
|
35
38
|
const STATUS_FAILED = "failed";
|
|
36
39
|
|
|
37
|
-
/** OSC 9;4 progress state 4, the warning state (iTerm2 semantics).
|
|
40
|
+
/** OSC 9;4 progress state 4, the warning state (iTerm2 semantics). OSC 9;4
|
|
41
|
+
* cannot set it once the session has sent an OSC 7501 report, until a
|
|
42
|
+
* terminal reset (RIS): OSC 7501 outranks OSC 9;4 until a RIS forgets it. */
|
|
38
43
|
const STATUS_WARNING = "warning";
|
|
39
44
|
|
|
40
45
|
/** Whether the session's process is GONE, whichever way it went: reloading onto
|
|
@@ -51,8 +56,9 @@ export function statusRevealsDot(status: string): boolean {
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/** The human wording for a status, for BOTH the dot's tooltip and the tab's
|
|
54
|
-
* accessible name, so they cannot disagree.
|
|
55
|
-
*
|
|
59
|
+
* accessible name, so they cannot disagree. A status outside the built-in
|
|
60
|
+
* vocabulary (a host classifier's, or a newer server's) falls back to the raw
|
|
61
|
+
* value rather than being hidden. */
|
|
56
62
|
export function statusPhrase(status: string): string {
|
|
57
63
|
switch (status) {
|
|
58
64
|
case "working":
|
|
@@ -94,22 +100,16 @@ export function normalizeProgress(value: unknown): number {
|
|
|
94
100
|
return Math.min(100, Math.round(value));
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
/** The percentage a status may SHOW. Two things clear it: the program's own
|
|
106
|
-
* OSC 9;4;0 (arriving as PROGRESS_ABSENT) and a status the channel does not own
|
|
107
|
-
* (kiro-cli parks state 4 at its context-usage percentage when idle, which once
|
|
108
|
-
* painted a done dot beside a 72% bar). Deliberately no third clear: 100% is a
|
|
109
|
-
* STATE that persists, and a timeout would assert a change the program never
|
|
110
|
-
* reported. */
|
|
103
|
+
/** The percentage a status may SHOW. Under any live status it is the server's
|
|
104
|
+
* value, because the status and the percentage can come from different
|
|
105
|
+
* channels: OSC 7501 or a notification can set the status while OSC 9;4
|
|
106
|
+
* supplies the percentage. Two things clear it: the server's PROGRESS_ABSENT
|
|
107
|
+
* (the program cleared or never reported a percentage, e.g. OSC 9;4;0) and an
|
|
108
|
+
* ended process, whose percentage nothing will ever clear. No timeout clears
|
|
109
|
+
* it: 100% is a STATE that persists, and a timeout would assert a change the
|
|
110
|
+
* program never reported. */
|
|
111
111
|
export function renderedProgress(status: string, progress: number): number {
|
|
112
|
-
return
|
|
112
|
+
return isEndedStatus(status) ? PROGRESS_ABSENT : progress;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// The secondary-activity vocabulary: a host-reported background activity that
|
|
@@ -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
|
|
@@ -221,15 +221,13 @@ export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
|
221
221
|
|
|
222
222
|
/** The cue statuses meaning the turn is OVER WITHOUT INCIDENT, the only ones a
|
|
223
223
|
* live background task may blank. `done` is the whole set: every other cue must
|
|
224
|
-
* reach the viewer AT ONCE, and `failed`
|
|
225
|
-
* turn
|
|
224
|
+
* reach the viewer AT ONCE, and `failed` is an error the program reported,
|
|
225
|
+
* not a clean end of the turn. */
|
|
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;
|
|
248
|
+
}
|
|
249
|
+
|
|
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);
|
|
256
258
|
}
|
|
257
259
|
|
|
258
|
-
/** The
|
|
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
|
|
@@ -424,10 +380,9 @@ export interface Tab {
|
|
|
424
380
|
/** The chip's determinate progress bar (the 2px line on its bottom edge).
|
|
425
381
|
* Present on every chip, hidden until a percentage exists. */
|
|
426
382
|
progressEl: HTMLElement;
|
|
427
|
-
/** The last
|
|
383
|
+
/** The last percentage the server reported for this session, or
|
|
428
384
|
* PROGRESS_ABSENT. Held raw: whether it is currently SHOWN is a render-time
|
|
429
|
-
* question (renderedProgress)
|
|
430
|
-
* any title field. */
|
|
385
|
+
* question (renderedProgress). */
|
|
431
386
|
progress: number;
|
|
432
387
|
/** The chip's secondary activity mark (the rounded-square ring beside the
|
|
433
388
|
* status dot). Present on every chip, and costing no layout until a state
|
|
@@ -447,9 +402,10 @@ export interface Tab {
|
|
|
447
402
|
* offset away, and it stopped meaning the same line once the session produced
|
|
448
403
|
* output while backgrounded. In memory only. */
|
|
449
404
|
view: ViewMemory | null;
|
|
450
|
-
/** Sticky: true once this session emitted a genuine activity signal (OSC
|
|
451
|
-
*
|
|
452
|
-
*
|
|
405
|
+
/** Sticky: true once this session emitted a genuine activity signal (OSC 7501
|
|
406
|
+
* status, OSC 9;4 progress or a classified OSC 9 notification). Its activity
|
|
407
|
+
* dot is shown only while true; a session that never reports activity (a
|
|
408
|
+
* plain shell) keeps a clean, dot-less tab. Fed from the server's
|
|
453
409
|
* reportsActivity via applyStatus. */
|
|
454
410
|
reports: boolean;
|
|
455
411
|
/** The user's pinned name (the server's `pinnedTitle` wire field). Outranks
|
|
@@ -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
|
|
@@ -584,8 +565,8 @@ const SESSION_API_TIMEOUT_MS = 15000;
|
|
|
584
565
|
|
|
585
566
|
/** A non-2xx response from the session API, carrying what the server said. A
|
|
586
567
|
* host may TEMPORARILY refuse session creation (a 503 with `Retry-After` and a
|
|
587
|
-
* body message while it
|
|
588
|
-
*
|
|
568
|
+
* body message while it starts up), and a caller that only saw an `Error`
|
|
569
|
+
* read that as a broken page. */
|
|
589
570
|
export class SessionAPIError extends Error {
|
|
590
571
|
/** HTTP status, so a caller can branch on 503 (retry) vs 429 vs 5xx. */
|
|
591
572
|
readonly status: number;
|
|
@@ -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
|
|
|
@@ -14,7 +14,6 @@ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } fro
|
|
|
14
14
|
// motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
|
|
15
15
|
// the same split switcher.ts keeps for the mobile swipe.
|
|
16
16
|
|
|
17
|
-
// Twin of marotte static-src/tabs-drag.ts's constants; change both or neither.
|
|
18
17
|
/** How long (ms) the pointer must have been un-moved before a position report at an
|
|
19
18
|
* unchanged position (a `dragover`, or the pointer path's re-report on this tick) is
|
|
20
19
|
* believed as "stopped". Small, because such a report is positive evidence of rest;
|
|
@@ -149,14 +148,10 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
|
|
|
149
148
|
// background-tab notification dot rides on it (see switchButtonHTML). Same
|
|
150
149
|
// viewBox + stroke=currentcolor treatment as the others.
|
|
151
150
|
const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
|
|
152
|
-
// The
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
const SPLIT_SVG =
|
|
156
|
-
`<svg class="wt-split-icon" viewBox="0 0 20 20" aria-hidden="true">` +
|
|
157
|
-
`<rect class="wt-split-icon-left" x="1" y="4" width="6" height="12" rx="1.5"/>` +
|
|
158
|
-
`<rect class="wt-split-icon-divider" x="9" y="2" width="2" height="16" rx="1"/>` +
|
|
159
|
-
`<rect class="wt-split-icon-right" x="13" y="4" width="6" height="12" rx="1.5"/></svg>`;
|
|
151
|
+
// The keyboard glyph's rounded square split down the middle, so the two buttons
|
|
152
|
+
// beside each other read as one size and weight. The state is the button's
|
|
153
|
+
// aria-expanded, not its icon.
|
|
154
|
+
const SPLIT_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3.5"/><path d="M12 4v16"/></svg>`;
|
|
160
155
|
|
|
161
156
|
/** The ONE builder for a tab chip's content (status dot, label, progress bar,
|
|
162
157
|
* close), shared by the strip chip, the mobile active row and each mobile list
|
|
@@ -240,7 +235,7 @@ export function paintStatusDot(el: HTMLElement, status: string, reports: boolean
|
|
|
240
235
|
* a tooltip worded by activityPhrase. No state REMOVES the attribute, which is
|
|
241
236
|
* what collapses the mark's footprint to zero. The reveal is the mark's OWN
|
|
242
237
|
* state, not the status dot's reportsActivity gate: a background run is a fact
|
|
243
|
-
* independent of whether the program ever
|
|
238
|
+
* independent of whether the program ever reported status. */
|
|
244
239
|
export function paintActivityMark(el: HTMLElement, state: string, count: number): void {
|
|
245
240
|
const phrase = activityPhrase(state, count);
|
|
246
241
|
if (phrase === "") {
|
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/pane.ts
CHANGED
|
@@ -77,6 +77,18 @@ function typedChar(ev: KeyboardEvent): string | null {
|
|
|
77
77
|
return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** Ctrl+C (Shift allowed) or Ctrl+Insert: the chords that copy a selection. A
|
|
81
|
+
* non-Latin layout's C is its physical KeyC; a Latin layout's is `key`. */
|
|
82
|
+
function isCopyChord(ev: KeyboardEvent): boolean {
|
|
83
|
+
if (!ev.ctrlKey || ev.altKey || ev.metaKey) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (ev.key === "Insert" || ev.key === "c" || ev.key === "C") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return ev.code === "KeyC" && !/^[a-z]$/i.test(ev.key);
|
|
90
|
+
}
|
|
91
|
+
|
|
80
92
|
/** iOS sends U+00A0 where a space was typed; shared by every send path so one
|
|
81
93
|
* physical key cannot produce two byte sequences. */
|
|
82
94
|
function normalizeTypedText(text: string): string {
|
|
@@ -986,8 +998,9 @@ function buildPaneInto(
|
|
|
986
998
|
if (!ownsSelection() && !keysUnclaimed()) {
|
|
987
999
|
return;
|
|
988
1000
|
}
|
|
989
|
-
// Features while the selection is STILL INTACT and before the
|
|
990
|
-
//
|
|
1001
|
+
// Features while the selection is STILL INTACT and before the key is
|
|
1002
|
+
// encoded: clipboard's Ctrl+Shift+C reads the selection, and its claim on
|
|
1003
|
+
// Ctrl+V keeps 0x16 off the wire.
|
|
991
1004
|
if (runKeydownChain(ev)) {
|
|
992
1005
|
return;
|
|
993
1006
|
}
|
|
@@ -995,7 +1008,7 @@ function buildPaneInto(
|
|
|
995
1008
|
// beforeinput or input, `_tryToHandlePressesEvent:` in
|
|
996
1009
|
// https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm),
|
|
997
1010
|
// so a dead key or an IME takes focus and passes through, ahead of the
|
|
998
|
-
//
|
|
1011
|
+
// decision below, which drops a key that types nothing.
|
|
999
1012
|
if (
|
|
1000
1013
|
ev.key === "Dead" ||
|
|
1001
1014
|
ev.key === "Process" ||
|
|
@@ -1010,21 +1023,25 @@ function buildPaneInto(
|
|
|
1010
1023
|
return;
|
|
1011
1024
|
}
|
|
1012
1025
|
// Decided before taking focus, because a bare modifier press (Shift on its
|
|
1013
|
-
// way to Shift+click) must leave the selection alone.
|
|
1014
|
-
//
|
|
1026
|
+
// way to Shift+click) must leave the selection alone. Modifiers alone never
|
|
1027
|
+
// reject a key the encoder maps, as on the focused path.
|
|
1015
1028
|
const result = mapKeyboardEvent(ev, engine.modes);
|
|
1016
1029
|
const char = result.kind === "ignore" ? typedChar(ev) : null;
|
|
1017
|
-
if (result.kind === "ignore"
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1030
|
+
if (result.kind === "ignore") {
|
|
1031
|
+
if (char === null) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
// An unencoded character with Ctrl, Alt or Cmd is the browser's (Cmd+V,
|
|
1035
|
+
// Ctrl+1). A desktop AltGr character needs no exception: it arrives with
|
|
1036
|
+
// neither modifier (the engine's encodeKittyDisambiguate cites why), and
|
|
1037
|
+
// Firefox sets AltGraph on every macOS Option chord.
|
|
1038
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1026
1041
|
}
|
|
1027
|
-
|
|
1042
|
+
// Tab leaves the page (no keyboard trap); a copy chord over this
|
|
1043
|
+
// terminal's selection is the browser's copy, not a key for the shell.
|
|
1044
|
+
if (ev.key === "Tab" || (ownsSelection() && isCopyChord(ev))) {
|
|
1028
1045
|
return;
|
|
1029
1046
|
}
|
|
1030
1047
|
doc.getSelection()?.removeAllRanges();
|
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(
|