@cplieger/web-terminal-ui 5.4.5 → 5.5.0
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/css/30-tabs.css +4 -3
- package/css/31-switcher.css +38 -9
- package/package.json +1 -1
- package/src/features/tabs/attention.ts +234 -0
- package/src/features/tabs/index.ts +167 -63
- package/src/features/tabs/model.ts +70 -11
- package/src/kernel/kernel.ts +37 -1
- package/src/kernel/types.ts +15 -0
- package/src/presets/agent-tabbed.ts +3 -3
- package/src/presets/tabbed.ts +19 -3
- package/src/presets.ts +1 -0
package/css/30-tabs.css
CHANGED
|
@@ -875,9 +875,10 @@
|
|
|
875
875
|
opacity only, so it composites without repaints (a gradient background is
|
|
876
876
|
rasterized once, unlike an animated box-shadow spread, which repaints every
|
|
877
877
|
frame). It exists only in the three animated states — no stale layer to pin
|
|
878
|
-
when a dot changes status. (The switcher notification dot
|
|
879
|
-
them
|
|
880
|
-
|
|
878
|
+
when a dot changes status. (The switcher notification dot can now show one of
|
|
879
|
+
them, `failed`, since that status became cue-worthy; 31-switcher.css neutralises
|
|
880
|
+
this rule and the position: relative above for that surface, so its
|
|
881
|
+
position: absolute badge placement still holds.)
|
|
881
882
|
|
|
882
883
|
The final scale is deliberately capped at today's 27px footprint: the mobile
|
|
883
884
|
overview row (.wt-switcher-row-select) clips at overflow: hidden 12.5px from
|
package/css/31-switcher.css
CHANGED
|
@@ -326,22 +326,51 @@
|
|
|
326
326
|
/* Latest-wins background-tab notification dot on the switch button — the moved
|
|
327
327
|
attention cue: the input colour when a background terminal needs input, the
|
|
328
328
|
done colour when a background turn finished, the failed red when a background
|
|
329
|
-
process crashed. Those
|
|
330
|
-
want the user, like a notification", so the
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
the
|
|
334
|
-
(
|
|
329
|
+
process crashed or reported an error. Those four only (CueStatus) — the rule
|
|
330
|
+
the user set is "only states that want the user, like a notification", so the
|
|
331
|
+
ongoing progress states (working / warning) never raise it. `failed` DOES,
|
|
332
|
+
because OSC 9;4 state 2 is a result the program parked rather than a phase in
|
|
333
|
+
progress; the override below is what keeps it looking like a notification here.
|
|
334
|
+
Reuses the exact .wt-status-dot rules (30-tabs.css, --status-* tokens) so it
|
|
335
|
+
matches the per-tab dots. Hidden until the tabs feature sets data-status;
|
|
336
|
+
cleared when the list opens (expandSwitcher). */
|
|
335
337
|
:where(.wt-root) .wt-switcher-switch-dot {
|
|
336
338
|
position: absolute;
|
|
337
339
|
top: 6px;
|
|
338
340
|
right: 6px;
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
/* `failed` is one of 30-tabs.css's three ANIMATED states, and on this surface it
|
|
344
|
+
must not be. That rule set is why this override exists at all rather than the
|
|
345
|
+
dot simply inheriting:
|
|
346
|
+
|
|
347
|
+
- `.wt-status-dot[data-status="failed"]` is specificity (0,2,0) and sets
|
|
348
|
+
`position: relative`, which BEATS the (0,1,0) `position: absolute` above
|
|
349
|
+
(`:where()` contributes nothing), so the dot would leave its badge corner and
|
|
350
|
+
land in the button's normal flow, displacing the icon.
|
|
351
|
+
- It also attaches the glow ::before and the travelling-wave ::after, pinning a
|
|
352
|
+
perpetual animation to the switch button for a state that persists until the
|
|
353
|
+
program's next status change.
|
|
354
|
+
|
|
355
|
+
So `failed` renders here the way `crashed` already does: static solid red in the
|
|
356
|
+
badge corner. Colour needs no restating — background/border-color resolve
|
|
357
|
+
through --dot-color to the same --status-failed either way. */
|
|
358
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"] {
|
|
359
|
+
position: absolute;
|
|
360
|
+
animation: none;
|
|
361
|
+
transform: none;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"]::before,
|
|
365
|
+
:where(.wt-root) .wt-switcher-switch-dot[data-status="failed"]::after {
|
|
366
|
+
content: none;
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
/* The dot carries the .wt-status-dot class, so its size, shape, and colour come
|
|
342
|
-
straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"
|
|
343
|
-
rules (30-tabs.css) — the SAME dot colours every tab already uses,
|
|
344
|
-
once.
|
|
370
|
+
straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"|
|
|
371
|
+
"failed"] rules (30-tabs.css) — the SAME dot colours every tab already uses,
|
|
372
|
+
defined once. Three of the four animate nothing; `failed` is neutralised
|
|
373
|
+
directly above, so nothing here can pin a wave layer.
|
|
345
374
|
.wt-status-dot is display:none by default; force it visible only while a
|
|
346
375
|
notification is set (data-status present). */
|
|
347
376
|
:where(.wt-root) .wt-switcher-switch-dot[data-status] {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
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",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// tabs/attention.ts — the unseen-cue set, rendered onto the surfaces that exist
|
|
2
|
+
// OUTSIDE this page's chrome: the browser tab title, the installed app's icon
|
|
3
|
+
// badge, and the tab icon.
|
|
4
|
+
//
|
|
5
|
+
// Why these are needed at all: a tab holding a latched status paints a dot on its
|
|
6
|
+
// chip, and that dot is not reliably on screen. The desktop strip scrolls
|
|
7
|
+
// horizontally once it is full, a narrow coarse-pointer device hides the strip
|
|
8
|
+
// entirely (only the active row plus the switcher's aggregate dot remain), and a
|
|
9
|
+
// hidden page shows no chrome at all. notify.ts already covers the last case with
|
|
10
|
+
// a browser Notification, but that API is absent on iOS Safari outside an
|
|
11
|
+
// installed web app, which is exactly where this UI spends its time.
|
|
12
|
+
//
|
|
13
|
+
// STATE, not events. A notification fires once per occurrence and is deduped on a
|
|
14
|
+
// sequence number; everything here is a pure render of what is currently true, so
|
|
15
|
+
// it is safe to recompute on every status sweep and each sink no-ops when nothing
|
|
16
|
+
// changed. That split is why this module and notify.ts stay separate.
|
|
17
|
+
//
|
|
18
|
+
// The set is CueStatus and nothing else, so these surfaces cannot disagree with
|
|
19
|
+
// the switcher's aggregate dot about what wants the user; the fold and the raise
|
|
20
|
+
// share isUnseenCue (model.ts).
|
|
21
|
+
//
|
|
22
|
+
// DOM-free and global-free by construction, like notify.ts: every capability
|
|
23
|
+
// arrives through AttentionEnv, so the decisions are unit-testable with no
|
|
24
|
+
// document, no navigator and no icon assets, and browserAttentionEnv() below is
|
|
25
|
+
// the one place that touches globals.
|
|
26
|
+
|
|
27
|
+
import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
|
|
28
|
+
|
|
29
|
+
/** What the fold reads per tab. A structural type, so the caller passes its own
|
|
30
|
+
* tab objects without this module knowing what else they carry. */
|
|
31
|
+
export interface CueCandidate {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly status: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The whole attention state, and the only thing the sinks are allowed to see. */
|
|
37
|
+
export interface Attention {
|
|
38
|
+
/** How many sessions hold an unacknowledged cue. */
|
|
39
|
+
readonly count: number;
|
|
40
|
+
/** The most severe of them, or "" when there is none. */
|
|
41
|
+
readonly worst: CueStatus | "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NO_ATTENTION: Attention = { count: 0, worst: "" };
|
|
45
|
+
|
|
46
|
+
/** summarize folds the tab list and this viewer's acknowledgements into the one
|
|
47
|
+
* value every surface renders.
|
|
48
|
+
*
|
|
49
|
+
* A COUNT for the title and the badge, a single WORST for the icon. That split
|
|
50
|
+
* is deliberate: a count is set-valued, so it needs no rule for choosing among
|
|
51
|
+
* sessions, and severity is a total order, so the icon's choice is not arbitrary
|
|
52
|
+
* either. Neither surface can name a session, which is the standing constraint
|
|
53
|
+
* on anything written to a page-wide surface here (see the decision against
|
|
54
|
+
* per-session progress in the document title, index.ts). */
|
|
55
|
+
export function summarize(
|
|
56
|
+
tabs: readonly CueCandidate[],
|
|
57
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
58
|
+
): Attention {
|
|
59
|
+
let count = 0;
|
|
60
|
+
let worst: CueStatus | "" = "";
|
|
61
|
+
for (const tab of tabs) {
|
|
62
|
+
if (!isUnseenCue(tab.status, tab.id, seen)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
count += 1;
|
|
66
|
+
worst = worseCue(worst, tab.status);
|
|
67
|
+
}
|
|
68
|
+
return { count, worst };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The capabilities the sinks need, all injected, all optional except the title.
|
|
72
|
+
*
|
|
73
|
+
* An absent capability is an absent sink and therefore a silent no-op, the same
|
|
74
|
+
* contract notify.ts uses for a missing Notification constructor: an unsupported
|
|
75
|
+
* surface is a normal state of the world, not an error to report.
|
|
76
|
+
*
|
|
77
|
+
* Two of these can also fail INVISIBLY, and that is accepted rather than
|
|
78
|
+
* handled. `setBadge` resolves on Linux where the desktop paints no badge at
|
|
79
|
+
* all, and `setIcon` assigns an href that Safari ignores because it caches the
|
|
80
|
+
* first icon it fetched. Neither is detectable, and neither costs anything when
|
|
81
|
+
* it fails, because the title is not gated on any capability and so is the
|
|
82
|
+
* floor: every platform that renders a title at all gets the count. Do not
|
|
83
|
+
* arrange these into a fallback ladder where the title only appears if the
|
|
84
|
+
* others are missing — on those two platforms the detection would report
|
|
85
|
+
* success and the user would be left with nothing. */
|
|
86
|
+
export interface AttentionEnv {
|
|
87
|
+
/** Set (or clear, with "") the document-title prefix. The kernel's composing
|
|
88
|
+
* writer, so this cannot be erased by a program's OSC 0/2 window title. */
|
|
89
|
+
titlePrefix: (text: string) => void;
|
|
90
|
+
/** Set the installed app's icon badge to a count, or clear it at zero. */
|
|
91
|
+
setBadge?: ((count: number) => void) | undefined;
|
|
92
|
+
/** Point the page's icon links at a variant, or restore them with null. */
|
|
93
|
+
setIcon?: ((variant: "input" | "done" | "alert" | null) => void) | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AttentionSurfaces {
|
|
97
|
+
/** Render an attention state. Idempotent: a value equal to the last one
|
|
98
|
+
* applied touches nothing. */
|
|
99
|
+
apply(next: Attention): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** titlePrefixFor is the title text for a count, and the format is load-bearing
|
|
103
|
+
* enough to name: the count goes FIRST, because a browser tab strip truncates a
|
|
104
|
+
* title to its first few characters and a suffix would be the part that is cut.
|
|
105
|
+
* Parenthesised digits are also the convention every mail and chat client uses,
|
|
106
|
+
* so it needs no legend. */
|
|
107
|
+
export function titlePrefixFor(count: number): string {
|
|
108
|
+
return count > 0 ? `(${String(count)}) ` : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createAttention(env: AttentionEnv): AttentionSurfaces {
|
|
112
|
+
// Last applied, so each sink is called only on a real change. This matters
|
|
113
|
+
// beyond cost: the document title doubles as the browser-tab label and the
|
|
114
|
+
// bookmark name, and re-assigning an icon href makes some browsers re-fetch it.
|
|
115
|
+
let applied: Attention = NO_ATTENTION;
|
|
116
|
+
let first = true;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
apply(next: Attention): void {
|
|
120
|
+
const countChanged = first || next.count !== applied.count;
|
|
121
|
+
const worstChanged = first || next.worst !== applied.worst;
|
|
122
|
+
first = false;
|
|
123
|
+
applied = next;
|
|
124
|
+
|
|
125
|
+
if (countChanged) {
|
|
126
|
+
env.titlePrefix(titlePrefixFor(next.count));
|
|
127
|
+
// The badge takes the SAME number as the title, which is the whole
|
|
128
|
+
// reason both read one fold: two surfaces disagreeing about how many
|
|
129
|
+
// things want you is worse than either being absent.
|
|
130
|
+
env.setBadge?.(next.count);
|
|
131
|
+
}
|
|
132
|
+
if (worstChanged) {
|
|
133
|
+
env.setIcon?.(next.worst === "" ? null : cueIconName(next.worst));
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** iconVariantHref rewrites an icon URL to its variant, by the convention the
|
|
140
|
+
* asset generator writes (scripts/gen-attention-icons.py): the `favicon` token
|
|
141
|
+
* of the filename gains `-<variant>`, so `/favicon.svg` becomes
|
|
142
|
+
* `/favicon-input.svg` and `/favicon-32x32.png` becomes
|
|
143
|
+
* `/favicon-input-32x32.png`. The extension is preserved, so each link keeps
|
|
144
|
+
* pointing at its own format and no `type` attribute has to change.
|
|
145
|
+
*
|
|
146
|
+
* A URL whose filename does not start with `favicon` is returned unchanged,
|
|
147
|
+
* which leaves that link alone rather than pointing it at a 404. Exported for
|
|
148
|
+
* the test that pins this against the generator's own naming. */
|
|
149
|
+
export function iconVariantHref(href: string, variant: string): string | null {
|
|
150
|
+
const match = /(^|\/)favicon(?=[-.])/.exec(href);
|
|
151
|
+
if (!match) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const at = match.index + match[0].length;
|
|
155
|
+
return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** browserAttentionEnv binds the sinks to the real browser, given the kernel's
|
|
159
|
+
* title setter. Everything optional is decided HERE, once, so the core never
|
|
160
|
+
* probes for a capability.
|
|
161
|
+
*
|
|
162
|
+
* `icons` opts the icon sink in. It is off unless the consumer asks for it,
|
|
163
|
+
* because it needs asset files the library cannot ship: the dot's colour comes
|
|
164
|
+
* from the app's own `--status-*` theme, so the variants are per-app artifacts.
|
|
165
|
+
*/
|
|
166
|
+
export function browserAttentionEnv(
|
|
167
|
+
titlePrefix: (text: string) => void,
|
|
168
|
+
icons: boolean,
|
|
169
|
+
): AttentionEnv {
|
|
170
|
+
const env: AttentionEnv = { titlePrefix };
|
|
171
|
+
|
|
172
|
+
// The Badging API, read through `unknown` for the same reason notify.ts reads
|
|
173
|
+
// Notification that way: it is absent on most browsers and must degrade rather
|
|
174
|
+
// than be asserted. Installed apps only — a badge lives on an app icon, which
|
|
175
|
+
// exists only after the app is installed.
|
|
176
|
+
const nav: unknown = globalThis.navigator;
|
|
177
|
+
const setAppBadge = (nav as { setAppBadge?: unknown } | undefined)?.setAppBadge;
|
|
178
|
+
const clearAppBadge = (nav as { clearAppBadge?: unknown } | undefined)?.clearAppBadge;
|
|
179
|
+
if (typeof setAppBadge === "function") {
|
|
180
|
+
env.setBadge = (count: number): void => {
|
|
181
|
+
// Always a NUMBER, never the spec's bare flag form: iOS renders nothing at
|
|
182
|
+
// all for `setAppBadge()` with no argument. Zero clears, via clearAppBadge
|
|
183
|
+
// where it exists (the documented way) and setAppBadge(0) otherwise.
|
|
184
|
+
//
|
|
185
|
+
// Both return promises that reject on an unsupported platform, so the
|
|
186
|
+
// rejection is swallowed: a badge the OS declines to paint is not an error
|
|
187
|
+
// this page can do anything about, and an unhandled rejection in a status
|
|
188
|
+
// sweep would be reported as a page fault.
|
|
189
|
+
try {
|
|
190
|
+
const call =
|
|
191
|
+
count > 0
|
|
192
|
+
? (setAppBadge as (n: number) => unknown).call(nav, count)
|
|
193
|
+
: typeof clearAppBadge === "function"
|
|
194
|
+
? (clearAppBadge as () => unknown).call(nav)
|
|
195
|
+
: (setAppBadge as (n: number) => unknown).call(nav, 0);
|
|
196
|
+
void Promise.resolve(call).catch(() => {
|
|
197
|
+
/* an OS that will not paint a badge is a title-only OS */
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
/* a synchronous throw is the same non-event */
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (icons) {
|
|
206
|
+
// Every icon link, not one of them: which link a browser picks differs
|
|
207
|
+
// between browsers (Chrome prefers the SVG), so mutating a single element is
|
|
208
|
+
// unreliable. apple-touch-icon is deliberately NOT matched — `rel~="icon"`
|
|
209
|
+
// does not select it — because that icon and the manifest's are cached by the
|
|
210
|
+
// OS when the app is installed and a swap cannot reach them.
|
|
211
|
+
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')];
|
|
212
|
+
const originals = new Map<HTMLLinkElement, string>();
|
|
213
|
+
for (const link of links) {
|
|
214
|
+
originals.set(link, link.getAttribute("href") ?? "");
|
|
215
|
+
}
|
|
216
|
+
if (links.length > 0) {
|
|
217
|
+
env.setIcon = (variant): void => {
|
|
218
|
+
for (const link of links) {
|
|
219
|
+
const original = originals.get(link) ?? "";
|
|
220
|
+
if (variant === null) {
|
|
221
|
+
link.setAttribute("href", original);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const next = iconVariantHref(original, variant);
|
|
225
|
+
if (next !== null) {
|
|
226
|
+
link.setAttribute("href", next);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return env;
|
|
234
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
hasPinnedName,
|
|
33
33
|
isCueStatus,
|
|
34
34
|
isEndedStatus,
|
|
35
|
+
isUnseenCue,
|
|
35
36
|
normalizeProgress,
|
|
36
37
|
orderedInsertIndex,
|
|
37
38
|
parseCueSeen,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
statusPhrase,
|
|
42
43
|
tabAccessibleName,
|
|
43
44
|
} from "./model.js";
|
|
45
|
+
import { NO_ATTENTION, browserAttentionEnv, createAttention, summarize } from "./attention.js";
|
|
44
46
|
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
45
47
|
import {
|
|
46
48
|
REORDER_MOVE_EPS_PX,
|
|
@@ -133,6 +135,25 @@ export interface TabsOptions {
|
|
|
133
135
|
* then merely confirms. Default false (evidence-driven reveal: a plain
|
|
134
136
|
* shell keeps clean, label-only tabs). presetAgentTabbed enables this. */
|
|
135
137
|
presumeReports?: boolean;
|
|
138
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
139
|
+
* holds an unacknowledged cue, so the browser tab icon carries the same colour
|
|
140
|
+
* its chip's dot does. Default false, because it needs asset files the library
|
|
141
|
+
* cannot ship: the dot's colour comes from the app's own `--status-*` theme, so
|
|
142
|
+
* the variants are per-app artifacts.
|
|
143
|
+
*
|
|
144
|
+
* Enabling it is a promise that those files exist. For every
|
|
145
|
+
* `link[rel=icon]` whose filename starts with `favicon`, three variants must
|
|
146
|
+
* be served alongside it with `-input`, `-done` and `-alert` inserted after
|
|
147
|
+
* that token (`/favicon.svg` needs `/favicon-input.svg`; `/favicon-32x32.png`
|
|
148
|
+
* needs `/favicon-input-32x32.png`). Generate them with
|
|
149
|
+
* `scripts/gen-attention-icons.py`, which writes exactly those names, and
|
|
150
|
+
* assert their presence in the APP's own tests — the library cannot check a
|
|
151
|
+
* file it does not ship, and a missing variant is a blank tab icon.
|
|
152
|
+
*
|
|
153
|
+
* Not every platform honours it: Safari caches the first icon it fetched and
|
|
154
|
+
* ignores later changes, and an installed app has no tab icon at all. The
|
|
155
|
+
* title count is not gated on this, so those cases lose nothing. */
|
|
156
|
+
attentionIcons?: boolean;
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
// looksLikeHardwareKey reports whether a keydown could only have come from a
|
|
@@ -486,8 +507,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
486
507
|
// by each qualifying background-tab event (applyStatus) and cleared when
|
|
487
508
|
// the list opens (expandSwitcher) or the raising tab is visited or closed
|
|
488
509
|
// (acknowledgeSwitchNotify). "" = no pending cue; the dot is hidden. The
|
|
489
|
-
// cue-worthy set is CueStatus (input / done / crashed — the states
|
|
490
|
-
// want the user), declared once in model.ts beside isCueStatus.
|
|
510
|
+
// cue-worthy set is CueStatus (input / done / crashed / failed — the states
|
|
511
|
+
// that want the user), declared once in model.ts beside isCueStatus.
|
|
491
512
|
let switchNotify: "" | CueStatus = "";
|
|
492
513
|
// The session that raised the pending cue, so arriving on that tab (a
|
|
493
514
|
// swipe or any switch) acknowledges it without opening the list.
|
|
@@ -522,6 +543,10 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
522
543
|
cueSeen.delete(oldest);
|
|
523
544
|
}
|
|
524
545
|
writeCueSeen(cueSeen);
|
|
546
|
+
// An acknowledgement can change the unseen count without touching the tab
|
|
547
|
+
// list, so syncChrome's sweep is not enough on its own: opening the tray
|
|
548
|
+
// and arriving on a tab both land here and neither adds or removes a chip.
|
|
549
|
+
paintAttention();
|
|
525
550
|
}
|
|
526
551
|
// forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
|
|
527
552
|
// is a fresh cue: called when its status moves off the acknowledged value
|
|
@@ -529,6 +554,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
529
554
|
function forgetCueSeen(id: string): void {
|
|
530
555
|
if (cueSeen.delete(id)) {
|
|
531
556
|
writeCueSeen(cueSeen);
|
|
557
|
+
paintAttention();
|
|
532
558
|
}
|
|
533
559
|
}
|
|
534
560
|
// OSC 9 Form B notifications: the one signal the specs say to surface
|
|
@@ -538,6 +564,60 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
538
564
|
// degradation — lives in notify.ts; this feature only feeds it events and
|
|
539
565
|
// gestures.
|
|
540
566
|
const notifier = createNotifier(browserNotifierEnv());
|
|
567
|
+
// The out-of-page surfaces for the SAME unseen-cue set the switch dot
|
|
568
|
+
// shows: the document-title count, the installed app's icon badge, and the
|
|
569
|
+
// tab icon. Constructed here so every capability decision is made once,
|
|
570
|
+
// and fed only through paintAttention below.
|
|
571
|
+
const attention = createAttention(
|
|
572
|
+
browserAttentionEnv((text) => {
|
|
573
|
+
ctx.titlePrefix(text);
|
|
574
|
+
}, opts.attentionIcons === true),
|
|
575
|
+
);
|
|
576
|
+
// pageVisible is the "can the user see this page at all" test, and
|
|
577
|
+
// visibilityState is the only reliable one: document.hasFocus() is false for
|
|
578
|
+
// a visible-but-unfocused window, where the terminal IS on screen. Same
|
|
579
|
+
// signal notify.ts reads, for the same decision.
|
|
580
|
+
function pageVisible(): boolean {
|
|
581
|
+
return document.visibilityState !== "hidden";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// onPageVisible is the DEFERRED half of the active-tab acknowledgement in
|
|
585
|
+
// applyStatus: a cue that latched while the page was hidden was deliberately
|
|
586
|
+
// not acknowledged then, so it could raise the out-of-page surfaces, and this
|
|
587
|
+
// acknowledges it the moment the user can actually see the terminal.
|
|
588
|
+
//
|
|
589
|
+
// Scoped to the ACTIVE tab only. A wholesale clear on becoming visible is the
|
|
590
|
+
// obvious shortcut and it is wrong: the tab the user returns to is not
|
|
591
|
+
// necessarily the one that was waiting, and blanking a background tab's cue
|
|
592
|
+
// it never saw is how a viewer loses the thing it came back for.
|
|
593
|
+
function onPageVisible(): void {
|
|
594
|
+
if (!pageVisible() || activeId === null) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const active = tabList.find((t) => t.id === activeId);
|
|
598
|
+
if (active) {
|
|
599
|
+
markCueSeen(active.id, statusOf(active));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
document.addEventListener("visibilitychange", onPageVisible);
|
|
603
|
+
|
|
604
|
+
// paintAttention re-derives the whole attention state from the tab list and
|
|
605
|
+
// this viewer's acknowledgements, and hands it to the surfaces.
|
|
606
|
+
//
|
|
607
|
+
// A FOLD rather than incremental bookkeeping, for the reason applyServerOrder
|
|
608
|
+
// is one: there is no state of its own to get out of step, so no path can
|
|
609
|
+
// leave it stale, and it is cheap enough to run on every tick (a loop over a
|
|
610
|
+
// handful of tabs, then sinks that no-op when nothing changed). The dot is
|
|
611
|
+
// the store — statusOf reads each tab's status back off it — so this reads
|
|
612
|
+
// exactly what the user is looking at.
|
|
613
|
+
function paintAttention(): void {
|
|
614
|
+
attention.apply(
|
|
615
|
+
summarize(
|
|
616
|
+
tabList.map((t) => ({ id: t.id, status: statusOf(t) })),
|
|
617
|
+
cueSeen,
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
541
621
|
function paintSwitchDot(): void {
|
|
542
622
|
// Reuse the per-tab status-dot colours (single source, css/05-tabs.css
|
|
543
623
|
// .wt-status-dot[data-status="input"|"done"|"crashed"]) instead of
|
|
@@ -552,6 +632,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
552
632
|
swSwitchDot.dataset["status"] = switchNotify;
|
|
553
633
|
swSwitchDot.title = statusPhrase(switchNotify);
|
|
554
634
|
}
|
|
635
|
+
// The out-of-page surfaces answer to the same set, so they repaint
|
|
636
|
+
// wherever this does: a raise, and every clear through clearSwitchNotify.
|
|
637
|
+
paintAttention();
|
|
555
638
|
}
|
|
556
639
|
function clearSwitchNotify(): void {
|
|
557
640
|
switchNotify = "";
|
|
@@ -678,13 +761,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
678
761
|
// back to the stylesheet — including when a second drag interrupts the first.
|
|
679
762
|
const shifted = new Set<HTMLElement>();
|
|
680
763
|
let shiftTimer: ReturnType<typeof setTimeout> | null = null;
|
|
681
|
-
// The
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
// stillness (see trackRest).
|
|
764
|
+
// The no-events fallback timer. It carries its own target, so there is
|
|
765
|
+
// deliberately NO pending-slot field here: one was the cause of the reorder
|
|
766
|
+
// dropping commits (see trackRest). restX is the last pointer position seen, which
|
|
767
|
+
// is how movement is told from stillness.
|
|
686
768
|
let restTimer: ReturnType<typeof setTimeout> | null = null;
|
|
687
|
-
let restBefore: HTMLElement | null = null;
|
|
688
769
|
let restX: number | null = null;
|
|
689
770
|
// When the pointer last actually MOVED. A stationary dragover this long after it
|
|
690
771
|
// is believed as a stop (see trackRest).
|
|
@@ -1156,6 +1237,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1156
1237
|
}
|
|
1157
1238
|
maybeSwipeHint();
|
|
1158
1239
|
applyServerOrder();
|
|
1240
|
+
// Hung off syncChrome for the reason applyServerOrder is: it is the one
|
|
1241
|
+
// function every list mutation already ends with (reorder, create, adopt,
|
|
1242
|
+
// close, reconcile), so no path can forget the out-of-page surfaces. The
|
|
1243
|
+
// fold is idempotent and the sinks no-op when unchanged, so running it
|
|
1244
|
+
// this often costs a comparison.
|
|
1245
|
+
paintAttention();
|
|
1159
1246
|
}
|
|
1160
1247
|
|
|
1161
1248
|
// The percentage is deliberately NOT written into the browser document
|
|
@@ -1167,6 +1254,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1167
1254
|
// the bookmark name, and it invites reading one session's progress as the
|
|
1168
1255
|
// window's. The per-chip prefix carries the same information without the
|
|
1169
1256
|
// conflict, because each chip shows its own session. Do not add it back.
|
|
1257
|
+
//
|
|
1258
|
+
// What DOES go there is the unseen-cue COUNT (see paintAttention), and it
|
|
1259
|
+
// is not a softening of this rule: a count names no session, so it needs no
|
|
1260
|
+
// arbitrary choice, and it changes only when a cue is raised or acknowledged
|
|
1261
|
+
// rather than on every progress tick. A per-session value in that prefix
|
|
1262
|
+
// would be this same mistake in a new place.
|
|
1170
1263
|
|
|
1171
1264
|
// applyServerOrder re-sorts the strip into the order the SERVER holds, and
|
|
1172
1265
|
// is the read half of tab-order sync: a drag on another device arrives as a
|
|
@@ -1896,7 +1989,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1896
1989
|
// was too small to tell them apart).
|
|
1897
1990
|
//
|
|
1898
1991
|
// A release never waits either: `drop` commits whatever slot is pending
|
|
1899
|
-
// (
|
|
1992
|
+
// (the drop handler commits the slot under the pointer), so dropping mid-sweep
|
|
1993
|
+
// still lands the tab exactly where it was headed.
|
|
1900
1994
|
//
|
|
1901
1995
|
// dropTargetBefore returns the first tab whose horizontal midpoint is past
|
|
1902
1996
|
// x (the element the dragged tab should sit before), or null to drop at the
|
|
@@ -2003,21 +2097,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2003
2097
|
if (!dragged) {
|
|
2004
2098
|
return;
|
|
2005
2099
|
}
|
|
2100
|
+
const now = Date.now();
|
|
2006
2101
|
const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
|
|
2007
2102
|
restX = clientX;
|
|
2008
|
-
const before = dropTargetBefore(clientX);
|
|
2009
|
-
// Already the slot the tab holds, so there is nothing to preview or commit.
|
|
2010
|
-
// Withdraw anything pending: the pointer came back.
|
|
2011
|
-
if (before === dragged || before === dragged.nextElementSibling) {
|
|
2012
|
-
cancelRest();
|
|
2013
|
-
return;
|
|
2014
|
-
}
|
|
2015
|
-
restBefore = before;
|
|
2016
|
-
const now = Date.now();
|
|
2017
2103
|
if (moved) {
|
|
2018
|
-
// Still travelling. Push the net out and wait.
|
|
2019
2104
|
restMovedAt = now;
|
|
2020
|
-
|
|
2105
|
+
}
|
|
2106
|
+
// The candidate is recomputed from THIS event, every time, and the commit below
|
|
2107
|
+
// uses that value rather than anything stored. That is the fix for a real
|
|
2108
|
+
// unreliability, so it must not be refactored back into a pending-target field:
|
|
2109
|
+
//
|
|
2110
|
+
// There used to be a `restBefore` holding the pending slot, and both the
|
|
2111
|
+
// already-there branch and the leave-the-strip paths nulled it. So a drag that
|
|
2112
|
+
// passed through "already there" on its way somewhere else lost the pending
|
|
2113
|
+
// state, and the stillness branch below — which then early-returned because no
|
|
2114
|
+
// timer was armed — committed NOTHING when the pointer stopped. The tab stayed on
|
|
2115
|
+
// its previous slot until the user jiggled the mouse to re-arm, which is exactly
|
|
2116
|
+
// the reported "it does not let go of the old drop spot". It hit leftward drags
|
|
2117
|
+
// hardest, because after each commit the dragged chip sits immediately left of
|
|
2118
|
+
// where the pointer is heading, so `nextElementSibling` matches constantly.
|
|
2119
|
+
const before = dropTargetBefore(clientX);
|
|
2120
|
+
// Already the slot it would land in, so there is nothing to commit. Only the net
|
|
2121
|
+
// is dropped; there is no pending target left to lose.
|
|
2122
|
+
if (before === dragged.nextElementSibling) {
|
|
2123
|
+
endRestNet();
|
|
2021
2124
|
return;
|
|
2022
2125
|
}
|
|
2023
2126
|
// A dragover at an UNCHANGED position is positive evidence that the pointer has
|
|
@@ -2026,48 +2129,31 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2026
2129
|
// cadence. The elapsed check filters the coincidence where one event of a sweep
|
|
2027
2130
|
// lands within REORDER_MOVE_EPS_PX of the previous (a reversal, or a frame whose
|
|
2028
2131
|
// motion was almost all vertical); a real stop clears it on the next event.
|
|
2029
|
-
if (now - restMovedAt >= REORDER_STILL_MS) {
|
|
2030
|
-
|
|
2132
|
+
if (!moved && now - restMovedAt >= REORDER_STILL_MS) {
|
|
2133
|
+
endRestNet();
|
|
2134
|
+
commitSlot(before);
|
|
2031
2135
|
return;
|
|
2032
2136
|
}
|
|
2033
|
-
armRestNet();
|
|
2137
|
+
armRestNet(before);
|
|
2034
2138
|
}
|
|
2035
2139
|
// armRestNet is the no-events fallback: if dragover stops arriving altogether, no
|
|
2036
|
-
// stationary event will ever confirm the stop, so this commits anyway.
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2140
|
+
// stationary event will ever confirm the stop, so this commits anyway. It carries
|
|
2141
|
+
// its own target for the same reason trackRest recomputes one — a net whose target
|
|
2142
|
+
// lived in shared mutable state was how the old pending slot got lost. Sized to
|
|
2143
|
+
// out-wait the cadence (see REORDER_REST_MS) because it is the one timer a fast
|
|
2144
|
+
// sweep could otherwise expire between two of its own events.
|
|
2145
|
+
function armRestNet(before: HTMLElement | null): void {
|
|
2146
|
+
endRestNet();
|
|
2043
2147
|
restTimer = setTimeout(() => {
|
|
2044
2148
|
restTimer = null;
|
|
2045
|
-
|
|
2046
|
-
restBefore = null;
|
|
2047
|
-
commitSlot(target);
|
|
2149
|
+
commitSlot(before);
|
|
2048
2150
|
}, REORDER_REST_MS);
|
|
2049
2151
|
}
|
|
2050
|
-
function
|
|
2051
|
-
if (restTimer
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
clearTimeout(restTimer);
|
|
2055
|
-
restTimer = null;
|
|
2056
|
-
restBefore = null;
|
|
2057
|
-
}
|
|
2058
|
-
// flushRest commits a pending slot at once, because a release is a decision. It is
|
|
2059
|
-
// what stops the rest window from ever being felt as a floor on the gesture: drop
|
|
2060
|
-
// mid-sweep and the tab still lands where it was headed. Only a `drop` calls it;
|
|
2061
|
-
// an abandoned drag reaches dragend without one and reverts.
|
|
2062
|
-
function flushRest(): void {
|
|
2063
|
-
if (restTimer === null) {
|
|
2064
|
-
return;
|
|
2152
|
+
function endRestNet(): void {
|
|
2153
|
+
if (restTimer !== null) {
|
|
2154
|
+
clearTimeout(restTimer);
|
|
2155
|
+
restTimer = null;
|
|
2065
2156
|
}
|
|
2066
|
-
const target = restBefore;
|
|
2067
|
-
clearTimeout(restTimer);
|
|
2068
|
-
restTimer = null;
|
|
2069
|
-
restBefore = null;
|
|
2070
|
-
commitSlot(target);
|
|
2071
2157
|
}
|
|
2072
2158
|
|
|
2073
2159
|
// flipTo runs a rearranging mutation and animates its result: every chip that
|
|
@@ -2263,7 +2349,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2263
2349
|
// start from rest, and a torn-down feature may leave no inline style on an
|
|
2264
2350
|
// element it no longer owns.
|
|
2265
2351
|
function endReorderPreview(): void {
|
|
2266
|
-
|
|
2352
|
+
endRestNet();
|
|
2267
2353
|
restX = null;
|
|
2268
2354
|
endSlotFade();
|
|
2269
2355
|
}
|
|
@@ -2283,9 +2369,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2283
2369
|
// in progress" and so would preventDefault every unrelated file or text drop on
|
|
2284
2370
|
// the page from then on.
|
|
2285
2371
|
function abortReorderFor(removed: HTMLElement): void {
|
|
2286
|
-
if (removed === restBefore) {
|
|
2287
|
-
cancelRest();
|
|
2288
|
-
}
|
|
2289
2372
|
if (removed !== draggingEl) {
|
|
2290
2373
|
return;
|
|
2291
2374
|
}
|
|
@@ -3174,11 +3257,19 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3174
3257
|
// acknowledgement so the next latch is a fresh cue.
|
|
3175
3258
|
if (!isCueStatus(rec.status)) {
|
|
3176
3259
|
forgetCueSeen(rec.id);
|
|
3177
|
-
} else if (rec.id === activeId) {
|
|
3260
|
+
} else if (rec.id === activeId && pageVisible()) {
|
|
3178
3261
|
// The user is looking at this terminal as it latches, so there is
|
|
3179
3262
|
// nothing to notify — and nothing to re-raise once they move away.
|
|
3263
|
+
//
|
|
3264
|
+
// BOTH halves are required, for the reason notify.ts states for its own
|
|
3265
|
+
// suppression rule: keyed on "is this the active tab" alone, this
|
|
3266
|
+
// swallowed the cue of the very session the user left running. That is
|
|
3267
|
+
// the single-session case — one tab is necessarily the active one — and
|
|
3268
|
+
// it is precisely the case the out-of-page surfaces exist for, so a
|
|
3269
|
+
// latch arriving on a HIDDEN page now raises them and is acknowledged
|
|
3270
|
+
// when the user comes back (see onPageVisible).
|
|
3180
3271
|
markCueSeen(rec.id, rec.status);
|
|
3181
|
-
} else if (
|
|
3272
|
+
} else if (isUnseenCue(rec.status, rec.id, cueSeen)) {
|
|
3182
3273
|
switchNotify = rec.status;
|
|
3183
3274
|
switchNotifyId = rec.id;
|
|
3184
3275
|
paintSwitchDot();
|
|
@@ -3359,7 +3450,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3359
3450
|
}
|
|
3360
3451
|
e.preventDefault();
|
|
3361
3452
|
dropped = true;
|
|
3362
|
-
|
|
3453
|
+
// A release decides, and it decides by POSITION: commit the slot the pointer is
|
|
3454
|
+
// actually over rather than whatever a timer had pending. commitSlot no-ops when
|
|
3455
|
+
// the tab already sits there, so a drop with nothing to move costs nothing.
|
|
3456
|
+
endRestNet();
|
|
3457
|
+
commitSlot(dropTargetBefore(e.clientX));
|
|
3363
3458
|
syncOrderFromDom();
|
|
3364
3459
|
announceMoved(moved); // the ONE completed-move announcement per gesture
|
|
3365
3460
|
});
|
|
@@ -3382,7 +3477,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3382
3477
|
}
|
|
3383
3478
|
const next = e.relatedTarget;
|
|
3384
3479
|
if (next === null || !bar.contains(next as Node)) {
|
|
3385
|
-
|
|
3480
|
+
endRestNet();
|
|
3386
3481
|
}
|
|
3387
3482
|
});
|
|
3388
3483
|
// A tab released anywhere OTHER than the strip — over the terminal, over
|
|
@@ -3412,14 +3507,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3412
3507
|
// So: leave `dropped` false, withdraw anything pending, and let dragend run the
|
|
3413
3508
|
// revert. Nothing is lost silently — the revert animates and announces, so a
|
|
3414
3509
|
// mis-release reads as "that did not take" rather than as a mystery.
|
|
3415
|
-
|
|
3510
|
+
endRestNet();
|
|
3416
3511
|
return;
|
|
3417
3512
|
}
|
|
3418
3513
|
// dragover, off the strip: no candidate slot exists out here, so withdraw a
|
|
3419
3514
|
// pending one rather than let the rest window open a gap for a target the pointer
|
|
3420
3515
|
// has already left. A slot already COMMITTED stays put; the pointer may yet come
|
|
3421
3516
|
// back onto the strip and release there.
|
|
3422
|
-
|
|
3517
|
+
endRestNet();
|
|
3423
3518
|
};
|
|
3424
3519
|
document.addEventListener("dragover", onDocTabDrop);
|
|
3425
3520
|
document.addEventListener("drop", onDocTabDrop);
|
|
@@ -3985,6 +4080,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3985
4080
|
tabList.map((t) => ({ id: t.id, title: t.display, active: t.id === activeId })),
|
|
3986
4081
|
},
|
|
3987
4082
|
teardown() {
|
|
4083
|
+
// Restore the out-of-page surfaces first, while the sinks still exist.
|
|
4084
|
+
// The kernel clears the title prefix on its own teardown, but the app
|
|
4085
|
+
// badge and the icon links are OUR state and nothing else will clear
|
|
4086
|
+
// them: the badge is OS-level and outlives the page entirely, and a
|
|
4087
|
+
// destroyed terminal in an embedded panel leaves the document's icon
|
|
4088
|
+
// pointed at a status variant for a tab that no longer exists.
|
|
4089
|
+
// Change-gated, so this is a no-op when nothing was lit.
|
|
4090
|
+
attention.apply(NO_ATTENTION);
|
|
3988
4091
|
tornDown = true;
|
|
3989
4092
|
resolveImpl = null;
|
|
3990
4093
|
offStatus?.();
|
|
@@ -3993,6 +4096,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3993
4096
|
offArmed?.();
|
|
3994
4097
|
offMenuKey();
|
|
3995
4098
|
document.removeEventListener("click", onDocClickMenu);
|
|
4099
|
+
document.removeEventListener("visibilitychange", onPageVisible);
|
|
3996
4100
|
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
3997
4101
|
document.removeEventListener("pointerup", onDocTapDismiss, true);
|
|
3998
4102
|
document.removeEventListener("dragover", onDocTabDrop);
|
|
@@ -192,20 +192,79 @@ export const ACTIVE_TAB_KEY = "wt-active-session";
|
|
|
192
192
|
// One-time "swipe to switch" hint, remembered across loads.
|
|
193
193
|
export const SWIPE_HINT_KEY = "wt-swipe-hint-seen";
|
|
194
194
|
|
|
195
|
-
/** The session statuses that raise the mobile switcher's
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
195
|
+
/** The session statuses that raise an attention cue: the mobile switcher's
|
|
196
|
+
* aggregate dot, and the three out-of-page surfaces derived from the same set
|
|
197
|
+
* (see attention.ts). The rule is "only states that want the user — it's like a
|
|
198
|
+
* notification": a background terminal blocked on the user ("input"), one whose
|
|
199
|
+
* turn finished ("done"), one whose process died badly ("crashed"), and one that
|
|
200
|
+
* reported an error ("failed").
|
|
201
|
+
*
|
|
202
|
+
* `working` and `warning` are excluded because they are ONGOING and
|
|
203
|
+
* informational: an animated dot pinned to the switch button would nag with
|
|
204
|
+
* nothing to act on. `failed` was excluded on the same reasoning and that was
|
|
205
|
+
* wrong — OSC 9;4 state 2 is not ongoing, it is a result the program parked and
|
|
206
|
+
* does not revisit until its next state change, which is exactly the shape of a
|
|
207
|
+
* thing to tell the user about. It is also the half of "red" a viewer cares most
|
|
208
|
+
* about: `crashed` was already here, so an errored turn raised nothing while a
|
|
209
|
+
* dead process raised everything.
|
|
210
|
+
*
|
|
211
|
+
* Declared once here so the raise test, the acknowledgement store and every
|
|
212
|
+
* attention sink cannot disagree about which statuses are cue-worthy. */
|
|
213
|
+
export type CueStatus = "input" | "done" | "crashed" | "failed";
|
|
214
|
+
|
|
215
|
+
/** Severity order over CueStatus, most severe first, AND the complete set: this
|
|
216
|
+
* array is what isCueStatus tests against, so the type and the runtime list
|
|
217
|
+
* cannot drift the way two hand-maintained four-way checks could.
|
|
218
|
+
*
|
|
219
|
+
* The order is read by the one surface that can show a single state (the browser
|
|
220
|
+
* tab icon), which paints the most severe unseen cue. That is a total order and
|
|
221
|
+
* so a defensible rule, unlike picking a session.
|
|
222
|
+
*
|
|
223
|
+
* A dead process outranks a reported error (the session is gone, not merely
|
|
224
|
+
* unhappy), an error outranks a request for input, and a finished turn is the
|
|
225
|
+
* mildest thing worth a cue at all. */
|
|
226
|
+
export const CUE_SEVERITY: readonly CueStatus[] = [STATUS_CRASHED, STATUS_FAILED, "input", "done"];
|
|
205
227
|
|
|
206
228
|
/** isCueStatus narrows a raw server status to a cue-worthy one. */
|
|
207
229
|
export function isCueStatus(status: string): status is CueStatus {
|
|
208
|
-
return
|
|
230
|
+
return (CUE_SEVERITY as readonly string[]).includes(status);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** worseCue returns whichever of two cues is more severe; "" means no cue. */
|
|
234
|
+
export function worseCue(a: CueStatus | "", b: CueStatus | ""): CueStatus | "" {
|
|
235
|
+
if (a === "") {
|
|
236
|
+
return b;
|
|
237
|
+
}
|
|
238
|
+
if (b === "") {
|
|
239
|
+
return a;
|
|
240
|
+
}
|
|
241
|
+
return CUE_SEVERITY.indexOf(a) <= CUE_SEVERITY.indexOf(b) ? a : b;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The icon variant a cue paints, which is NOT one per status: `crashed` and
|
|
245
|
+
* `failed` both render --status-failed, so they share one asset. An app ships
|
|
246
|
+
* one icon per name returned here (see scripts/gen-attention-icons.py). */
|
|
247
|
+
export function cueIconName(status: CueStatus): "input" | "done" | "alert" {
|
|
248
|
+
if (status === "input" || status === "done") {
|
|
249
|
+
return status;
|
|
250
|
+
}
|
|
251
|
+
return "alert";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** isUnseenCue reports whether a session's CURRENT status is a cue this viewer
|
|
255
|
+
* has not acknowledged. The single predicate behind both the raise and the
|
|
256
|
+
* attention count, so a dot can never light for a session the count omits.
|
|
257
|
+
*
|
|
258
|
+
* Note what it does NOT consider: whether the session is the active tab. A
|
|
259
|
+
* latch arriving on the active tab is acknowledged at arrival (applyStatus), so
|
|
260
|
+
* it is already absent from the unseen set by the time anything folds over it,
|
|
261
|
+
* and no caller needs a special case. */
|
|
262
|
+
export function isUnseenCue(
|
|
263
|
+
status: string,
|
|
264
|
+
id: string,
|
|
265
|
+
seen: ReadonlyMap<string, CueStatus>,
|
|
266
|
+
): status is CueStatus {
|
|
267
|
+
return isCueStatus(status) && seen.get(id) !== status;
|
|
209
268
|
}
|
|
210
269
|
|
|
211
270
|
/** localStorage key for the cues this viewer has already SEEN: session id -> the
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -258,6 +258,31 @@ function buildTerminal(
|
|
|
258
258
|
narrowObserver.observe(root);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
// --- Document title (kernel-owned, composed from two inputs) ---
|
|
262
|
+
//
|
|
263
|
+
// The page has ONE title and two things want to write it: a program's OSC 0/2
|
|
264
|
+
// window title, and a feature's attention prefix (tabs' unseen-cue count). So
|
|
265
|
+
// the kernel holds both and is the only assigner. Two direct writers was the
|
|
266
|
+
// alternative and it cannot work: whichever ran last won, so a shell or an
|
|
267
|
+
// editor emitting OSC 2 silently erased the prefix, and there is no ordering
|
|
268
|
+
// that fixes it because both inputs change on their own schedule.
|
|
269
|
+
//
|
|
270
|
+
// The base starts as whatever the served document declared, so a consumer that
|
|
271
|
+
// never receives an OSC title (web-terminal-kiro sets kiro-cli's
|
|
272
|
+
// chat.terminalTitle=false) keeps its own <title> as the base rather than
|
|
273
|
+
// needing to declare it twice.
|
|
274
|
+
let baseTitle = document.title;
|
|
275
|
+
let titlePrefixText = "";
|
|
276
|
+
function paintTitle(): void {
|
|
277
|
+
const next = titlePrefixText + baseTitle;
|
|
278
|
+
// Idempotent: the title doubles as the browser-tab label and the bookmark
|
|
279
|
+
// name, and this runs on every status sweep, so an unchanged assignment must
|
|
280
|
+
// not churn it.
|
|
281
|
+
if (document.title !== next) {
|
|
282
|
+
document.title = next;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
261
286
|
// Toast surface (kernel-owned shared primitive, section 22.3).
|
|
262
287
|
const toastEl = document.createElement("div");
|
|
263
288
|
toastEl.className = "wt-toast";
|
|
@@ -607,7 +632,8 @@ function buildTerminal(
|
|
|
607
632
|
// when it redraws its prompt after idling, so hold the last-good browser
|
|
608
633
|
// title instead of flickering blank.
|
|
609
634
|
if (msg.title.trim() !== "") {
|
|
610
|
-
|
|
635
|
+
baseTitle = msg.title;
|
|
636
|
+
paintTitle();
|
|
611
637
|
}
|
|
612
638
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
613
639
|
} else if (msg.type === "modes") {
|
|
@@ -1216,6 +1242,10 @@ function buildTerminal(
|
|
|
1216
1242
|
// place rather than at each caller.
|
|
1217
1243
|
loadingStatus.reason(message);
|
|
1218
1244
|
},
|
|
1245
|
+
titlePrefix: (text) => {
|
|
1246
|
+
titlePrefixText = text;
|
|
1247
|
+
paintTitle();
|
|
1248
|
+
},
|
|
1219
1249
|
tablist: () => tablistController,
|
|
1220
1250
|
newLineStore: (sessionId) => {
|
|
1221
1251
|
// Registered on EVERY path, because this factory is the only way a store
|
|
@@ -1307,6 +1337,12 @@ function buildTerminal(
|
|
|
1307
1337
|
scrollbackKeeper?.flush();
|
|
1308
1338
|
scrollbackKeeper?.stop();
|
|
1309
1339
|
teardownFeatures();
|
|
1340
|
+
// A destroyed terminal must not leave its attention prefix on the page: the
|
|
1341
|
+
// tab it counted is gone, and the title outlives this runtime (an embedded
|
|
1342
|
+
// panel closing, a reload path). The base is left alone -- it belongs to the
|
|
1343
|
+
// document, not to us.
|
|
1344
|
+
titlePrefixText = "";
|
|
1345
|
+
paintTitle();
|
|
1310
1346
|
kernelAbort.abort();
|
|
1311
1347
|
narrowObserver?.disconnect();
|
|
1312
1348
|
narrowObserver = null;
|
package/src/kernel/types.ts
CHANGED
|
@@ -274,6 +274,21 @@ export interface TerminalContext {
|
|
|
274
274
|
* re-read the same sentence every few seconds. Also a no-op once the overlay
|
|
275
275
|
* has been dismissed, so a late retry cannot resurrect it. */
|
|
276
276
|
loadingReason(message: string): void;
|
|
277
|
+
/** Set the prefix the kernel composes onto the document title, or "" to clear
|
|
278
|
+
* it. The kernel is the only writer of `document.title`: it holds the base
|
|
279
|
+
* (the served `<title>`, replaced by a program's OSC 0/2 window title) and
|
|
280
|
+
* this prefix, and re-composes on either change. A feature that assigned
|
|
281
|
+
* `document.title` directly would be erased by the next OSC 2 from any shell
|
|
282
|
+
* or editor running in a session, with no ordering that avoids it.
|
|
283
|
+
*
|
|
284
|
+
* Idempotent and safe on every status tick: an unchanged result is not
|
|
285
|
+
* re-assigned, because the title doubles as the browser-tab label and the
|
|
286
|
+
* bookmark name. Cleared automatically when the terminal is destroyed.
|
|
287
|
+
*
|
|
288
|
+
* Keep it an AGGREGATE. A page has one title while this UI multiplexes many
|
|
289
|
+
* sessions, so any per-session value has to pick a session arbitrarily (see
|
|
290
|
+
* the standing decision against writing progress here in features/tabs). */
|
|
291
|
+
titlePrefix(text: string): void;
|
|
277
292
|
/** The kernel's tablist/tabpanel ARIA controller (used by tabs). */
|
|
278
293
|
tablist(): TablistController;
|
|
279
294
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// presetAgentTabbed: the tabbed composition tuned for an agent shell.
|
|
2
2
|
|
|
3
3
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
4
|
-
import { buildTabbed } from "./tabbed.js";
|
|
4
|
+
import { type TabbedPresetOptions, buildTabbed } from "./tabbed.js";
|
|
5
5
|
|
|
6
6
|
/** Tabbed UI for an agent shell (web-terminal-kiro). Same features as
|
|
7
7
|
* presetTabbed, tuned for sessions that ARE agents:
|
|
@@ -15,6 +15,6 @@ import { buildTabbed } from "./tabbed.js";
|
|
|
15
15
|
* status classifier that maps OSC 9 notifications to done/needs-input, and the
|
|
16
16
|
* input-derived session name (terminal.WithInputTitle), which the agent host
|
|
17
17
|
* enables because its program's own window title is not worth showing. */
|
|
18
|
-
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
19
|
-
return buildTabbed(true);
|
|
18
|
+
export function presetAgentTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
19
|
+
return buildTabbed(true, opts);
|
|
20
20
|
}
|
package/src/presets/tabbed.ts
CHANGED
|
@@ -27,7 +27,22 @@ import { presetSingle } from "./single.js";
|
|
|
27
27
|
* foreground-process/cwd inference — and both presets render what it reports. A
|
|
28
28
|
* browser that re-derived any of that could only disagree with the server and
|
|
29
29
|
* with every other client attached to the same session. */
|
|
30
|
-
|
|
30
|
+
/** Options shared by the two tabbed presets, passed through to the tabs feature.
|
|
31
|
+
* A preset that takes arguments is called as `features: () =>
|
|
32
|
+
* presetTabbed({...})`, which keeps the call inside createTerminal's failure
|
|
33
|
+
* boundary (see CreateTerminalOptions.features). */
|
|
34
|
+
export interface TabbedPresetOptions {
|
|
35
|
+
/** Swap the page's icon links to a status variant while a background session
|
|
36
|
+
* wants the user. Off by default, and enabling it is a promise that the
|
|
37
|
+
* variant assets are served — see TabsOptions.attentionIcons for the naming
|
|
38
|
+
* contract and scripts/gen-attention-icons.py, which writes them. */
|
|
39
|
+
attentionIcons?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildTabbed(
|
|
43
|
+
agentShell: boolean,
|
|
44
|
+
opts: TabbedPresetOptions = {},
|
|
45
|
+
): TerminalFeature<unknown>[] {
|
|
31
46
|
const kb = mobileToolbar({ externalToggle: true });
|
|
32
47
|
const monitor = activityMonitor();
|
|
33
48
|
return [
|
|
@@ -38,6 +53,7 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
|
38
53
|
keyboardToggle: kb,
|
|
39
54
|
activityMonitor: monitor,
|
|
40
55
|
presumeReports: agentShell,
|
|
56
|
+
attentionIcons: opts.attentionIcons === true,
|
|
41
57
|
}),
|
|
42
58
|
animations(),
|
|
43
59
|
];
|
|
@@ -49,6 +65,6 @@ export function buildTabbed(agentShell: boolean): TerminalFeature<unknown>[] {
|
|
|
49
65
|
* activity dot stays hidden until its session reports activity via OSC 9;4
|
|
50
66
|
* progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
|
|
51
67
|
* label-only tabs. */
|
|
52
|
-
export function presetTabbed(): TerminalFeature<unknown>[] {
|
|
53
|
-
return buildTabbed(false);
|
|
68
|
+
export function presetTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
|
|
69
|
+
return buildTabbed(false, opts);
|
|
54
70
|
}
|
package/src/presets.ts
CHANGED
|
@@ -19,4 +19,5 @@
|
|
|
19
19
|
export { presetSingle } from "./presets/single.js";
|
|
20
20
|
export { presetTouch } from "./presets/touch.js";
|
|
21
21
|
export { presetTabbed } from "./presets/tabbed.js";
|
|
22
|
+
export type { TabbedPresetOptions } from "./presets/tabbed.js";
|
|
22
23
|
export { presetAgentTabbed } from "./presets/agent-tabbed.js";
|