@reticlehq/browser 2.11.0 → 2.13.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/dist/actions/actions-dom.js +41 -3
- package/dist/actions/actions.js +43 -11
- package/dist/commands/commands.js +6 -2
- package/dist/connect-options.d.ts +18 -1
- package/dist/dom/a11y.js +92 -9
- package/dist/dom/dom-ignore.d.ts +9 -0
- package/dist/dom/dom-ignore.js +32 -3
- package/dist/dom/query.js +168 -97
- package/dist/dom/refs.d.ts +11 -0
- package/dist/dom/refs.js +30 -1
- package/dist/dom/snapshot.d.ts +37 -0
- package/dist/dom/snapshot.js +141 -37
- package/dist/edit-epoch.d.ts +32 -0
- package/dist/edit-epoch.js +108 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/observers/animation.js +6 -8
- package/dist/observers/context-open.d.ts +12 -0
- package/dist/observers/context-open.js +25 -0
- package/dist/observers/focus.js +5 -6
- package/dist/observers/health.js +6 -9
- package/dist/observers/install-all.js +2 -0
- package/dist/observers/network-redact.d.ts +13 -0
- package/dist/observers/network-redact.js +12 -1
- package/dist/observers/network.d.ts +2 -2
- package/dist/observers/network.js +24 -17
- package/dist/observers/scroll.js +4 -2
- package/dist/observers/storage.js +1 -1
- package/dist/presenter/presenter-brand.d.ts +4 -18
- package/dist/presenter/presenter-brand.js +6 -18
- package/dist/presenter/presenter-config.d.ts +11 -8
- package/dist/presenter/presenter-config.js +9 -3
- package/dist/presenter/presenter-controls.d.ts +0 -3
- package/dist/presenter/presenter-controls.js +0 -3
- package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
- package/dist/presenter/presenter-report-copy.d.ts +30 -1
- package/dist/presenter/presenter-report-copy.js +31 -2
- package/dist/presenter/presenter-report-styles.js +24 -0
- package/dist/presenter/presenter-report.d.ts +2 -2
- package/dist/presenter/presenter-report.js +95 -3
- package/dist/presenter/presenter-settings.js +10 -2
- package/dist/presenter/presenter-shell-styles.js +20 -1
- package/dist/presenter/presenter-shell.d.ts +0 -2
- package/dist/presenter/presenter-shell.js +1 -6
- package/dist/presenter/presenter.d.ts +13 -0
- package/dist/presenter/presenter.js +37 -2
- package/dist/registry/auto-testids.d.ts +21 -0
- package/dist/registry/auto-testids.js +48 -0
- package/dist/registry/capabilities.d.ts +28 -1
- package/dist/registry/capabilities.js +59 -7
- package/dist/registry/store-adapters.d.ts +1 -1
- package/dist/registry/store-adapters.js +6 -1
- package/dist/registry/stores.d.ts +4 -0
- package/dist/registry/stores.js +35 -0
- package/dist/reticle.d.ts +12 -0
- package/dist/reticle.js +40 -1
- package/dist/transport/unreachable-message.d.ts +7 -0
- package/dist/transport/unreachable-message.js +11 -1
- package/package.json +4 -7
- package/dist/dom/blind-spots.d.ts +0 -13
- package/dist/dom/blind-spots.js +0 -29
- package/dist/observers/subresources.d.ts +0 -10
- package/dist/observers/subresources.js +0 -63
|
@@ -70,7 +70,15 @@ const DEFAULT_SETTINGS = {
|
|
|
70
70
|
blockPageInteractions: true,
|
|
71
71
|
showTally: false,
|
|
72
72
|
showTimestamps: true,
|
|
73
|
-
|
|
73
|
+
// ON by default: the chat IS the HUD's content, and shipping it off meant the default experience
|
|
74
|
+
// was a bare toolbar until somebody found the toggle. `expand()` has always opened the chat for
|
|
75
|
+
// the same reason; this is the other half — session start, with no click at all.
|
|
76
|
+
//
|
|
77
|
+
// Safe to default because `openChat()` expands a collapsed HUD, and session start is the one
|
|
78
|
+
// moment where that is what you want. A user who prefers the bare toolbar turns this off and it
|
|
79
|
+
// stays off: a stored `false` is honoured over the default, which is pinned in
|
|
80
|
+
// presenter-chat-default.test.ts so the product cannot change somebody's answer behind their back.
|
|
81
|
+
autoOpenChat: true,
|
|
74
82
|
reduceMotion: false,
|
|
75
83
|
};
|
|
76
84
|
let activeSettings = loadPresenterSettings();
|
|
@@ -169,7 +177,7 @@ export function settingsPanelHtml() {
|
|
|
169
177
|
const hideHelp = 'Hide the Reticle HUD until you reload the page';
|
|
170
178
|
const tallyHelp = 'Show the pass/fail score pill in the toolbar';
|
|
171
179
|
const timestampsHelp = 'Show relative timestamps on each activity-log row';
|
|
172
|
-
const autoChatHelp = '
|
|
180
|
+
const autoChatHelp = 'On by default. Opens the agent chat by itself when a session starts and when you expand the HUD. Off leaves the toolbar bare until you ask for the chat.';
|
|
173
181
|
const motionHelp = 'Reduce HUD animations for accessibility';
|
|
174
182
|
const glowHelp = 'Glow the page edges in the status colour while a session is live. Off keeps the HUD signals and leaves your app alone.';
|
|
175
183
|
return `<div ${SETTINGS_PANEL_ATTR} class="reticle-settings ${HUD_SURFACE_CLASS}" role="dialog" aria-label="Reticle settings" aria-hidden="true">
|
|
@@ -306,8 +306,19 @@ export const SHELL_CSS = `
|
|
|
306
306
|
padding:2px 6px;border-radius:999px;white-space:nowrap;pointer-events:none;z-index:2;
|
|
307
307
|
transition:opacity .12s ease;}
|
|
308
308
|
[${OVERLAY}][${STATE}="paused"] [data-reticle-badge]{display:inline-flex;}
|
|
309
|
+
/**
|
|
310
|
+
* The mode, ON the status row rather than under it.
|
|
311
|
+
*
|
|
312
|
+
* The action text beside it is flex:1, so this claims the right edge of the row and takes its space
|
|
313
|
+
* from the elided action text. Nothing above or below moves: the panel used to grow and shrink by
|
|
314
|
+
* the height of this pill on every single tool call, which is what made one status line look like a
|
|
315
|
+
* second UI appearing and leaving.
|
|
316
|
+
*/
|
|
309
317
|
[${DOCK_ATTR}] .reticle-chip{display:none;flex:none;align-items:center;gap:4px;font-size:8px;font-weight:600;letter-spacing:.06em;
|
|
310
|
-
padding:
|
|
318
|
+
height:16px;padding:0 7px;line-height:1;border-radius:999px;text-transform:uppercase;
|
|
319
|
+
background:rgba(255,255,255,.06);opacity:0;transition:opacity .12s ease;}
|
|
320
|
+
[${DOCK_ATTR}] .reticle-chip[data-mode="reading"],
|
|
321
|
+
[${DOCK_ATTR}] .reticle-chip[data-mode="acting"]{opacity:1;}
|
|
311
322
|
[${DOCK_ATTR}] .reticle-chip[data-mode="reading"],
|
|
312
323
|
[${DOCK_ATTR}] .reticle-chip[data-mode="acting"]{display:inline-flex;color:var(--reticle-fg);}
|
|
313
324
|
[${DOCK_ATTR}] .reticle-tally[hidden]{display:none;}
|
|
@@ -344,6 +355,14 @@ export const SHELL_CSS = `
|
|
|
344
355
|
0 0 8px color-mix(in srgb,var(--reticle-state) 45%,transparent);}
|
|
345
356
|
[${CHAT_PANEL}] .reticle-act-strip[data-liveness="idle"] .reticle-act-dot{
|
|
346
357
|
animation:reticle-idle-pulse 2.4s ease-in-out infinite;}
|
|
358
|
+
/**
|
|
359
|
+
* Unreachable: present, and plainly not working.
|
|
360
|
+
*
|
|
361
|
+
* Muted rather than alarming. Nothing is broken in the user's app — Reticle simply has nobody to
|
|
362
|
+
* talk to — and a dev overlay that shouts about its own plumbing is one the user turns off.
|
|
363
|
+
*/
|
|
364
|
+
[${OVERLAY}][${STATE}="unreachable"]{--reticle-state:var(--reticle-faint);}
|
|
365
|
+
[${OVERLAY}][${STATE}="unreachable"] [${CHAT_PANEL}] .reticle-act-dot{animation:none;}
|
|
347
366
|
[${OVERLAY}][${STATE}="paused"] [${CHAT_PANEL}] .reticle-act-dot,
|
|
348
367
|
[${OVERLAY}][${STATE}="ended"] [${CHAT_PANEL}] .reticle-act-dot{animation:none;}
|
|
349
368
|
@keyframes reticle-idle-pulse{0%,100%{opacity:.45;transform:scale(.92)}50%{opacity:1;transform:scale(1)}}
|
|
@@ -44,5 +44,3 @@ export declare class HudShell {
|
|
|
44
44
|
/** Pulse the FAB when new activity arrives while collapsed. */
|
|
45
45
|
pulseFab(active: boolean): void;
|
|
46
46
|
}
|
|
47
|
-
/** Whether the dock has been dragged off the default bottom-right position. */
|
|
48
|
-
export declare function isDockDragged(dock: HTMLElement): boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PresenterReport, reportPanelHtml } from './presenter-report.js';
|
|
2
|
-
import { CHAT_MIN_ATTR, CHAT_PILL_ATTR, REPORT_ATTR, REPORT_BTN_ATTR, ANNOTATE_BTN_ATTR, CHAT_ATTR, CHAT_TOGGLE_ATTR, CHAT_PANEL_ATTR, DOCK_ATTR, FAB_ATTR, MIN_ATTR,
|
|
2
|
+
import { CHAT_MIN_ATTR, CHAT_PILL_ATTR, REPORT_ATTR, REPORT_BTN_ATTR, ANNOTATE_BTN_ATTR, CHAT_ATTR, CHAT_TOGGLE_ATTR, CHAT_PANEL_ATTR, DOCK_ATTR, FAB_ATTR, MIN_ATTR, SETTINGS_ATTR, SETTINGS_BTN_ATTR, } from './presenter-config.js';
|
|
3
3
|
import { BRAND_NAME, FAB_TOGGLE_HTML, MARK_SVG } from './presenter-brand.js';
|
|
4
4
|
import { settleLogAtLatest } from './presenter-log.js';
|
|
5
5
|
import { installHudDragHandles, installHudPositionGuards } from './presenter-drag.js';
|
|
@@ -93,7 +93,6 @@ export class HudShell {
|
|
|
93
93
|
<button type="button" ${CHAT_MIN_ATTR} class="reticle-chat-min" title="${CHAT_MIN_LABEL}" aria-label="${CHAT_MIN_LABEL}">${hiIconHtml(PresenterIcon.CARET_DOWN, PRESENTER_ICON_SIZE.TOOLBAR)}</button>
|
|
94
94
|
${actStripHtml}
|
|
95
95
|
<span class="reticle-tally" data-reticle-tally hidden></span>
|
|
96
|
-
<span class="reticle-chip" data-reticle-chip></span>
|
|
97
96
|
${bannerHtml}
|
|
98
97
|
<div class="${HUD_LOG_WELL_CLASS}"><div ${logAttr}></div></div>
|
|
99
98
|
${flowsHtml}
|
|
@@ -420,7 +419,3 @@ export class HudShell {
|
|
|
420
419
|
}
|
|
421
420
|
};
|
|
422
421
|
}
|
|
423
|
-
/** Whether the dock has been dragged off the default bottom-right position. */
|
|
424
|
-
export function isDockDragged(dock) {
|
|
425
|
-
return '1' === dock.getAttribute(HUD_DRAGGED_ATTR);
|
|
426
|
-
}
|
|
@@ -41,6 +41,19 @@ export declare class Presenter {
|
|
|
41
41
|
* sessionEnd. Idempotent, and a no-op when unmounted or in 'busy' border mode.
|
|
42
42
|
*/
|
|
43
43
|
sessionStart(): void;
|
|
44
|
+
/**
|
|
45
|
+
* The bridge never answered: show that, rather than showing nothing.
|
|
46
|
+
*
|
|
47
|
+
* An instrumented page with a dead bridge used to be indistinguishable from a page with no
|
|
48
|
+
* Reticle in it — overlay mounted, dock off, nothing on screen. The user cannot tell "I forgot to
|
|
49
|
+
* start the daemon" from "the install did not work", and the commonest cause is the cheapest to
|
|
50
|
+
* say: the port. So the HUD appears, states the URL it tried, and marks itself unreachable so it
|
|
51
|
+
* is never mistaken for a live session.
|
|
52
|
+
*
|
|
53
|
+
* Not an error dialog and not modal: the page is the user's, and a dev overlay that shouts is one
|
|
54
|
+
* they turn off. It is the same capsule they would have had, saying the one thing it knows.
|
|
55
|
+
*/
|
|
56
|
+
showUnreachable(url: string, attempts: number): void;
|
|
44
57
|
/**
|
|
45
58
|
* Session end: hides the log/HUD and (in 'session' mode) clears the base border. Idempotent; a
|
|
46
59
|
* no-op without a prior sessionStart or when unmounted.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { ActionType, ReticleCommand, PresenterMode, SessionState, isPresenterTone, isSessionState, } from '@reticlehq/core';
|
|
2
2
|
import { refs } from '../dom/refs.js';
|
|
3
|
+
import { unreachableStripText } from '../transport/unreachable-message.js';
|
|
3
4
|
import { actionVerb } from './presenter-verbs.js';
|
|
4
5
|
import { nativeSetTimeout, nativeClearTimeout, nativeNow } from '../timers/native-timers.js';
|
|
5
6
|
import { LOG_KIND, CHIP_LABEL, DATA_RETICLE_LOG, clampLogMax, formatElapsed, humanDuration, appendLogRow, } from './presenter-log.js';
|
|
6
7
|
import { PRESENTER_CSS } from './presenter-styles.js';
|
|
7
8
|
import { HudShell } from './presenter-shell.js';
|
|
8
9
|
import { parseImpactSnapshot } from './presenter-report-copy.js';
|
|
9
|
-
import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, effectivePaceMs, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, ACT_STRIP, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, THROTTLED_ATTR, LIVENESS_ATTR, MARKERS_BTN_ATTR, CLEAR_MARKS_ATTR, MARK_COUNT_ATTR, } from './presenter-config.js';
|
|
10
|
+
import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, effectivePaceMs, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, ACT_STRIP, STATE_ATTR, UNREACHABLE_STATE, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, THROTTLED_ATTR, LIVENESS_ATTR, MARKERS_BTN_ATTR, CLEAR_MARKS_ATTR, MARK_COUNT_ATTR, } from './presenter-config.js';
|
|
10
11
|
import { buildRunState } from './presenter-run-state.js';
|
|
11
12
|
import { moveCursor, ringAround, spawnRipple, pace } from './presenter-effects.js';
|
|
12
13
|
import { GlowController } from './presenter-glow.js';
|
|
@@ -165,7 +166,11 @@ export class Presenter {
|
|
|
165
166
|
document.head.appendChild(style);
|
|
166
167
|
const root = document.createElement('div');
|
|
167
168
|
root.setAttribute('data-reticle-overlay', '');
|
|
168
|
-
|
|
169
|
+
// The mode rides ON the status row, next to the dot and the elapsed time it already reports.
|
|
170
|
+
// It used to be a sibling BELOW the strip, hidden except while reading or acting — so every
|
|
171
|
+
// single tool call popped a block into the panel and took it away again, which reads as a
|
|
172
|
+
// second UI flashing in rather than as the one status line changing what it says.
|
|
173
|
+
const actStrip = `<div class="reticle-act-strip" data-liveness="idle"><span class="reticle-act-dot" aria-hidden="true"></span><span class="reticle-act">${ACT_STRIP.READY}</span><span class="reticle-chip" data-reticle-chip></span></div>`;
|
|
169
174
|
root.innerHTML = `
|
|
170
175
|
${blockerHtml()}
|
|
171
176
|
<div data-reticle-glow></div>
|
|
@@ -253,6 +258,9 @@ export class Presenter {
|
|
|
253
258
|
}
|
|
254
259
|
if (this.#sessionActive)
|
|
255
260
|
return;
|
|
261
|
+
// A bridge that answered supersedes whatever the failed connect left on screen.
|
|
262
|
+
if (UNREACHABLE_STATE === this.#root?.getAttribute(STATE_ATTR))
|
|
263
|
+
this.#root.removeAttribute(STATE_ATTR);
|
|
256
264
|
this.#sessionActive = true;
|
|
257
265
|
this.#startMs ??= this.#now();
|
|
258
266
|
this.#endMs = undefined;
|
|
@@ -263,6 +271,33 @@ export class Presenter {
|
|
|
263
271
|
this.#shell.openChat();
|
|
264
272
|
}
|
|
265
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* The bridge never answered: show that, rather than showing nothing.
|
|
276
|
+
*
|
|
277
|
+
* An instrumented page with a dead bridge used to be indistinguishable from a page with no
|
|
278
|
+
* Reticle in it — overlay mounted, dock off, nothing on screen. The user cannot tell "I forgot to
|
|
279
|
+
* start the daemon" from "the install did not work", and the commonest cause is the cheapest to
|
|
280
|
+
* say: the port. So the HUD appears, states the URL it tried, and marks itself unreachable so it
|
|
281
|
+
* is never mistaken for a live session.
|
|
282
|
+
*
|
|
283
|
+
* Not an error dialog and not modal: the page is the user's, and a dev overlay that shouts is one
|
|
284
|
+
* they turn off. It is the same capsule they would have had, saying the one thing it knows.
|
|
285
|
+
*/
|
|
286
|
+
showUnreachable(url, attempts) {
|
|
287
|
+
if (this.#sessionActive)
|
|
288
|
+
return;
|
|
289
|
+
this.#root?.setAttribute(STATE_ATTR, UNREACHABLE_STATE);
|
|
290
|
+
// Dock and HUD, but NOT the border glow: the glow means the agent is working, and nothing is.
|
|
291
|
+
const dock = this.#root?.querySelector('[data-reticle-dock]');
|
|
292
|
+
dock?.setAttribute(DATA_ON, GLOW_ON);
|
|
293
|
+
this.#hud?.setAttribute(DATA_ON, GLOW_ON);
|
|
294
|
+
this.#lastActionText = unreachableStripText(url, attempts);
|
|
295
|
+
this.#paintActStrip(this.#lastActionText, true);
|
|
296
|
+
// The message IS the reason this state exists, and a collapsed capsule hides it. Same setting
|
|
297
|
+
// a live session honours, so a user who wants the bare toolbar still gets one.
|
|
298
|
+
if (getPresenterSettings().autoOpenChat)
|
|
299
|
+
this.#shell.openChat();
|
|
300
|
+
}
|
|
266
301
|
/** Turn the base border (session mode) + the HUD/log on - the visible "session is live" state. */
|
|
267
302
|
#showSession() {
|
|
268
303
|
const dock = this.#root?.querySelector('[data-reticle-dock]');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The testids the page is actually showing, read from the live DOM.
|
|
3
|
+
*
|
|
4
|
+
* `registerCapabilities({ testids: [...] })` used to be the only source, and `init` filled it by
|
|
5
|
+
* grepping the source at install time. That list is a snapshot of the codebase on the day `init` ran:
|
|
6
|
+
* it goes stale on the next commit that renames an element, it is empty for every app whose testids
|
|
7
|
+
* arrive with a lazy route, and finishing it was homework handed to whoever ran the install.
|
|
8
|
+
*
|
|
9
|
+
* The attribute in the DOM is the same declaration, read fresh, so nothing has to be copied into a
|
|
10
|
+
* config file to be true. Reticle's own presenter markup is excluded — advertising our own controls
|
|
11
|
+
* as the app's testable surface would be the tool describing itself.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Ceiling on the reported list.
|
|
15
|
+
*
|
|
16
|
+
* A virtualized 500-row grid stamps a testid per cell, and the capabilities payload rides in every
|
|
17
|
+
* announce — an uncapped read turns one long table into a permanently expensive message. The cap is
|
|
18
|
+
* on the REPORT, not on what can be driven: a testid past it is still findable by query.
|
|
19
|
+
*/
|
|
20
|
+
export declare const MAX_AUTO_TESTIDS = 200;
|
|
21
|
+
export declare function domTestids(doc: Document): string[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The testids the page is actually showing, read from the live DOM.
|
|
3
|
+
*
|
|
4
|
+
* `registerCapabilities({ testids: [...] })` used to be the only source, and `init` filled it by
|
|
5
|
+
* grepping the source at install time. That list is a snapshot of the codebase on the day `init` ran:
|
|
6
|
+
* it goes stale on the next commit that renames an element, it is empty for every app whose testids
|
|
7
|
+
* arrive with a lazy route, and finishing it was homework handed to whoever ran the install.
|
|
8
|
+
*
|
|
9
|
+
* The attribute in the DOM is the same declaration, read fresh, so nothing has to be copied into a
|
|
10
|
+
* config file to be true. Reticle's own presenter markup is excluded — advertising our own controls
|
|
11
|
+
* as the app's testable surface would be the tool describing itself.
|
|
12
|
+
*/
|
|
13
|
+
const TESTID_ATTR = 'data-testid';
|
|
14
|
+
const TESTID_SELECTOR = `[${TESTID_ATTR}]`;
|
|
15
|
+
/**
|
|
16
|
+
* Ceiling on the reported list.
|
|
17
|
+
*
|
|
18
|
+
* A virtualized 500-row grid stamps a testid per cell, and the capabilities payload rides in every
|
|
19
|
+
* announce — an uncapped read turns one long table into a permanently expensive message. The cap is
|
|
20
|
+
* on the REPORT, not on what can be driven: a testid past it is still findable by query.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_AUTO_TESTIDS = 200;
|
|
23
|
+
/** Reticle's own UI carries testids; they are not the host app's surface. */
|
|
24
|
+
const RETICLE_OWN_PREFIX = 'reticle-';
|
|
25
|
+
export function domTestids(doc) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
let nodes;
|
|
29
|
+
try {
|
|
30
|
+
nodes = doc.querySelectorAll(TESTID_SELECTOR);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return out; // a document that cannot be queried (detached, hostile) simply advertises nothing
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i < nodes.length && out.length < MAX_AUTO_TESTIDS; i += 1) {
|
|
36
|
+
const value = nodes[i]?.getAttribute(TESTID_ATTR);
|
|
37
|
+
if (null === value || undefined === value)
|
|
38
|
+
continue;
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
if (0 === trimmed.length || seen.has(trimmed))
|
|
41
|
+
continue;
|
|
42
|
+
if (trimmed.startsWith(RETICLE_OWN_PREFIX))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(trimmed);
|
|
45
|
+
out.push(trimmed);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -8,6 +8,15 @@ export interface Capabilities {
|
|
|
8
8
|
signals: string[];
|
|
9
9
|
stores: string[];
|
|
10
10
|
flows: CapabilityFlow[];
|
|
11
|
+
/**
|
|
12
|
+
* True when Reticle's OWN presenter is visible to snapshots and queries.
|
|
13
|
+
*
|
|
14
|
+
* Present only when the hatch is open, and reported here rather than left implicit, because a
|
|
15
|
+
* verdict drawn against Reticle's own interface is not an ordinary verdict: an agent that can see
|
|
16
|
+
* the impact panel can also assert against it. Anybody reading a result should be able to tell
|
|
17
|
+
* which kind they are holding without going to look at a build config.
|
|
18
|
+
*/
|
|
19
|
+
presenterExposed?: boolean;
|
|
11
20
|
}
|
|
12
21
|
/** What the host app passes to reticle.describe; all fields optional. */
|
|
13
22
|
export interface CapabilitiesInput {
|
|
@@ -22,5 +31,23 @@ export declare function setCapabilitiesListener(cb: (() => void) | undefined): v
|
|
|
22
31
|
export declare function registerCapabilities(input: CapabilitiesInput): void;
|
|
23
32
|
/** Snapshot copy of the registered capabilities (defensive — never hand out the live arrays). */
|
|
24
33
|
export declare function getCapabilities(): Capabilities;
|
|
25
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Testids the app DECLARED, without the ones merely observed in the DOM.
|
|
36
|
+
*
|
|
37
|
+
* The difference carries meaning the merged list cannot. `knownEmptyState` asks "did the app NAME
|
|
38
|
+
* this element as part of its testable surface?", which is how a zero-match query against
|
|
39
|
+
* `cart-empty-region` is told apart from a broken selector. Every testid on the page is observed, so
|
|
40
|
+
* answering that over the merged list makes it true whenever the page has any testid at all — the
|
|
41
|
+
* same as not asking.
|
|
42
|
+
*/
|
|
43
|
+
export declare function declaredTestids(): string[];
|
|
44
|
+
/**
|
|
45
|
+
* Whether the app has a testable surface at all (used in the HELLO flag).
|
|
46
|
+
*
|
|
47
|
+
* Answered over the OBSERVED surface as well as the declared one. It used to be answerable only by
|
|
48
|
+
* declaration, so an app with two hundred testids in its DOM and a live Redux store reported
|
|
49
|
+
* `hasCapabilities: false` until somebody typed those facts into a config file — and the agent
|
|
50
|
+
* reading that flag concluded, correctly by the flag and wrongly in fact, that there was nothing to
|
|
51
|
+
* drive.
|
|
52
|
+
*/
|
|
26
53
|
export declare function hasCapabilities(): boolean;
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
/** Self-describing capability registry — the testable surface the app advertises. */
|
|
2
|
+
import { RETICLE_RENDERS_STORE } from '@reticlehq/core';
|
|
3
|
+
import { isPresenterVisible } from '../dom/dom-ignore.js';
|
|
4
|
+
import { domTestids } from './auto-testids.js';
|
|
5
|
+
import { storeNames } from './stores.js';
|
|
6
|
+
/**
|
|
7
|
+
* The two halves of the capability report the app no longer has to write down.
|
|
8
|
+
*
|
|
9
|
+
* `testids` and `stores` used to be *declarations* — strings copied into a generated file by `init`
|
|
10
|
+
* and then expected to stay true. Both are now read from the thing they describe: the testids from
|
|
11
|
+
* the DOM, the store names from the registry (which the React adapter fills by discovery). What the
|
|
12
|
+
* app declares is still merged in and still wins nothing away: `signals` and `flows` cannot be
|
|
13
|
+
* observed and remain the app's to state.
|
|
14
|
+
*/
|
|
15
|
+
function liveTestids() {
|
|
16
|
+
if ('undefined' === typeof document)
|
|
17
|
+
return [];
|
|
18
|
+
return domTestids(document);
|
|
19
|
+
}
|
|
20
|
+
/** Registered stores, minus the ones Reticle registered on the app's behalf. */
|
|
21
|
+
function liveStores() {
|
|
22
|
+
return storeNames().filter((name) => RETICLE_RENDERS_STORE !== name);
|
|
23
|
+
}
|
|
24
|
+
function union(declared, observed) {
|
|
25
|
+
const out = [...declared];
|
|
26
|
+
for (const value of observed)
|
|
27
|
+
if (!out.includes(value))
|
|
28
|
+
out.push(value);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
2
31
|
// Persist on a global so the registry survives HMR module re-evaluation (matches __reticleAdapters).
|
|
3
32
|
const globalStore = globalThis;
|
|
4
33
|
function empty() {
|
|
@@ -48,16 +77,39 @@ export function registerCapabilities(input) {
|
|
|
48
77
|
/** Snapshot copy of the registered capabilities (defensive — never hand out the live arrays). */
|
|
49
78
|
export function getCapabilities() {
|
|
50
79
|
return {
|
|
51
|
-
testids:
|
|
80
|
+
testids: union(capabilities.testids, liveTestids()),
|
|
52
81
|
signals: [...capabilities.signals],
|
|
53
|
-
stores:
|
|
82
|
+
stores: union(capabilities.stores, liveStores()),
|
|
54
83
|
flows: capabilities.flows.map((f) => ({ name: f.name, steps: [...f.steps] })),
|
|
84
|
+
// Only when open — an absent field is the ordinary case and should not cost a line in every
|
|
85
|
+
// capabilities payload ever sent.
|
|
86
|
+
...(isPresenterVisible() ? { presenterExposed: true } : {}),
|
|
55
87
|
};
|
|
56
88
|
}
|
|
57
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Testids the app DECLARED, without the ones merely observed in the DOM.
|
|
91
|
+
*
|
|
92
|
+
* The difference carries meaning the merged list cannot. `knownEmptyState` asks "did the app NAME
|
|
93
|
+
* this element as part of its testable surface?", which is how a zero-match query against
|
|
94
|
+
* `cart-empty-region` is told apart from a broken selector. Every testid on the page is observed, so
|
|
95
|
+
* answering that over the merged list makes it true whenever the page has any testid at all — the
|
|
96
|
+
* same as not asking.
|
|
97
|
+
*/
|
|
98
|
+
export function declaredTestids() {
|
|
99
|
+
return [...capabilities.testids];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether the app has a testable surface at all (used in the HELLO flag).
|
|
103
|
+
*
|
|
104
|
+
* Answered over the OBSERVED surface as well as the declared one. It used to be answerable only by
|
|
105
|
+
* declaration, so an app with two hundred testids in its DOM and a live Redux store reported
|
|
106
|
+
* `hasCapabilities: false` until somebody typed those facts into a config file — and the agent
|
|
107
|
+
* reading that flag concluded, correctly by the flag and wrongly in fact, that there was nothing to
|
|
108
|
+
* drive.
|
|
109
|
+
*/
|
|
58
110
|
export function hasCapabilities() {
|
|
59
|
-
return (capabilities.
|
|
60
|
-
capabilities.
|
|
61
|
-
capabilities.
|
|
62
|
-
capabilities.
|
|
111
|
+
return (capabilities.signals.length > 0 ||
|
|
112
|
+
capabilities.flows.length > 0 ||
|
|
113
|
+
union(capabilities.testids, liveTestids()).length > 0 ||
|
|
114
|
+
union(capabilities.stores, liveStores()).length > 0);
|
|
63
115
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { nativeWarn } from '../timers/native-console.js';
|
|
2
|
+
import { markAdapterSource } from './stores.js';
|
|
2
3
|
/**
|
|
3
4
|
* Expose a TanStack Query cache as a Reticle store, keyed by query key.
|
|
4
5
|
*
|
|
@@ -16,7 +17,7 @@ export function tanstackQueryStore(client) {
|
|
|
16
17
|
// effects, a provider remount, HMR — and an adapter holding the old cache would keep answering
|
|
17
18
|
// from a store the app no longer reads, which is a stale-data bug inside the tool whose job is
|
|
18
19
|
// catching stale data.
|
|
19
|
-
|
|
20
|
+
const store = {
|
|
20
21
|
getState: () => {
|
|
21
22
|
const out = {};
|
|
22
23
|
for (const query of client.getQueryCache().getAll()) {
|
|
@@ -34,6 +35,10 @@ export function tanstackQueryStore(client) {
|
|
|
34
35
|
},
|
|
35
36
|
subscribe: (listener) => client.getQueryCache().subscribe(listener),
|
|
36
37
|
};
|
|
38
|
+
// Registering this adapter claims the CLIENT too, so fiber-tree discovery can tell that the app
|
|
39
|
+
// already wired this exact QueryClient and must not add a second entry for it.
|
|
40
|
+
markAdapterSource(store, client);
|
|
41
|
+
return store;
|
|
37
42
|
}
|
|
38
43
|
/**
|
|
39
44
|
* Expose a chosen set of Jotai atoms as one Reticle store.
|
|
@@ -48,3 +48,7 @@ export declare function readStoresWithTruncation(only?: string): {
|
|
|
48
48
|
stores: Record<string, unknown>;
|
|
49
49
|
truncation?: Record<string, TruncationReport>;
|
|
50
50
|
};
|
|
51
|
+
/** Called by an adapter to record what it was built from. See `sourceOwner`. */
|
|
52
|
+
export declare function markAdapterSource(store: object, source: object): void;
|
|
53
|
+
/** The name this store source is registered under, or undefined if nothing has claimed it. */
|
|
54
|
+
export declare function sourceOwner(value: unknown): string | undefined;
|
package/dist/registry/stores.js
CHANGED
|
@@ -34,6 +34,7 @@ function isStoreLike(source) {
|
|
|
34
34
|
* leaves the store on pull-only reads: no change events, and an empty `stateDiffs` in every act summary.
|
|
35
35
|
*/
|
|
36
36
|
export function registerStore(name, source, subscribe) {
|
|
37
|
+
claimSource(name, source);
|
|
37
38
|
if (isStoreLike(source)) {
|
|
38
39
|
const getter = () => source.getState();
|
|
39
40
|
const sub = (listener) => source.subscribe(listener);
|
|
@@ -149,3 +150,37 @@ function warnSilentStoreOnce(name) {
|
|
|
149
150
|
`changes are invisible: no STATE_CHANGE events, no state diffs in causal summaries, and a state ` +
|
|
150
151
|
`predicate will never see it update. Pass the store object (or a subscribe callback) to fix.`);
|
|
151
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Who owns a given store SOURCE — the object the app (or an adapter) was built from.
|
|
155
|
+
*
|
|
156
|
+
* Registration is keyed by name, which cannot answer "is this same store already readable?". The
|
|
157
|
+
* question matters because the React adapter discovers stores from the fiber tree, and an app that
|
|
158
|
+
* has already registered the very same store by hand must not get a second entry for it under a
|
|
159
|
+
* second name. Comparing the READ VALUE cannot answer it either: `tanstackQueryStore` builds a fresh
|
|
160
|
+
* snapshot object on every `getState`, so identity never matches. Measured on bench-app, which
|
|
161
|
+
* registers `queries` itself and got an auto-discovered `query` beside it — one cache, listed twice,
|
|
162
|
+
* in every state read.
|
|
163
|
+
*
|
|
164
|
+
* A WeakMap so nothing here keeps an app's store alive.
|
|
165
|
+
*/
|
|
166
|
+
const sourceOwners = new WeakMap();
|
|
167
|
+
/** Adapter instance → the object it wraps, so registering an adapter also claims its source. */
|
|
168
|
+
const adapterSources = new WeakMap();
|
|
169
|
+
/** Called by an adapter to record what it was built from. See `sourceOwner`. */
|
|
170
|
+
export function markAdapterSource(store, source) {
|
|
171
|
+
adapterSources.set(store, source);
|
|
172
|
+
}
|
|
173
|
+
function claimSource(name, source) {
|
|
174
|
+
if (null === source || ('object' !== typeof source && 'function' !== typeof source))
|
|
175
|
+
return;
|
|
176
|
+
sourceOwners.set(source, name);
|
|
177
|
+
const wrapped = adapterSources.get(source);
|
|
178
|
+
if (wrapped !== undefined)
|
|
179
|
+
sourceOwners.set(wrapped, name);
|
|
180
|
+
}
|
|
181
|
+
/** The name this store source is registered under, or undefined if nothing has claimed it. */
|
|
182
|
+
export function sourceOwner(value) {
|
|
183
|
+
if (null === value || ('object' !== typeof value && 'function' !== typeof value))
|
|
184
|
+
return undefined;
|
|
185
|
+
return sourceOwners.get(value);
|
|
186
|
+
}
|
package/dist/reticle.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export declare function buildEvent(args: {
|
|
|
55
55
|
sessionId: string;
|
|
56
56
|
data: Record<string, unknown>;
|
|
57
57
|
ref?: string | undefined;
|
|
58
|
+
documentId?: string | undefined;
|
|
59
|
+
editEpoch?: number | undefined;
|
|
58
60
|
}): ReticleEvent;
|
|
59
61
|
/**
|
|
60
62
|
* The browser-side orchestrator. Wires observers -> events -> bridge, and bridge
|
|
@@ -79,5 +81,15 @@ export declare class Reticle {
|
|
|
79
81
|
describe(input: CapabilitiesInput): void;
|
|
80
82
|
/** Live-control: end the session programmatically from the host app (drives the panel to ended). */
|
|
81
83
|
endSession(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Hand the SDK the page's hot-update channel, so a stale ref can say the code changed underneath it.
|
|
86
|
+
*
|
|
87
|
+
* `unknown` on purpose: the only caller that has one is the build integration, the shape it passes
|
|
88
|
+
* is Vite's `import.meta.hot`, and the SDK must not depend on Vite — it ships to Next, Electron,
|
|
89
|
+
* Tauri and plain pages. Anything that is not a subscribable channel is ignored, which is the
|
|
90
|
+
* normal case: with no channel the epoch stays at `NO_EDITS_OBSERVED`, meaning "no edits OBSERVED",
|
|
91
|
+
* never "no edits happened".
|
|
92
|
+
*/
|
|
93
|
+
observeHotUpdates(hot: unknown): void;
|
|
82
94
|
disconnect(): void;
|
|
83
95
|
}
|
package/dist/reticle.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { setPresenterVisible } from './dom/dom-ignore.js';
|
|
2
|
+
import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, newDocumentId, NO_EDITS_OBSERVED, } from '@reticlehq/core';
|
|
2
3
|
import { rememberSessionLabel } from './session-continuity.js';
|
|
4
|
+
import { editEpoch } from './edit-epoch.js';
|
|
3
5
|
import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
|
|
4
6
|
import { Transport } from './transport/transport.js';
|
|
5
7
|
import { unreachableMessage } from './transport/unreachable-message.js';
|
|
@@ -128,6 +130,15 @@ export function buildEvent(args) {
|
|
|
128
130
|
type: args.type,
|
|
129
131
|
sessionId: args.sessionId,
|
|
130
132
|
ref: args.ref,
|
|
133
|
+
// Which document this was observed under, so the server can refuse evidence minted before a
|
|
134
|
+
// navigation replaced the page. Stamped HERE because it is the one place every event passes
|
|
135
|
+
// through: an observer added later would otherwise emit unstamped events, which read as
|
|
136
|
+
// "current" by design and would reintroduce the defect silently for one event type.
|
|
137
|
+
documentId: args.documentId,
|
|
138
|
+
// Which round of source edits this was observed under. Stamped HERE for exactly the reason
|
|
139
|
+
// documentId is, and omitted while nothing has hot-updated: absence already reads as "current"
|
|
140
|
+
// downstream, so `NO_EDITS_OBSERVED` on the wire would be bytes spent saying "unknown".
|
|
141
|
+
editEpoch: NO_EDITS_OBSERVED === args.editEpoch ? undefined : args.editEpoch,
|
|
131
142
|
data: args.data,
|
|
132
143
|
};
|
|
133
144
|
}
|
|
@@ -141,6 +152,13 @@ export class Reticle {
|
|
|
141
152
|
#teardowns = [];
|
|
142
153
|
#connected = false;
|
|
143
154
|
#session = 'default';
|
|
155
|
+
/**
|
|
156
|
+
* Minted once, here, because this class is constructed once per document: a full navigation tears
|
|
157
|
+
* down the JavaScript context and builds a new one, so the id dies with the document it names. An
|
|
158
|
+
* SPA route change does not reconstruct it, which is correct — same context, same in-flight
|
|
159
|
+
* requests, same evidence.
|
|
160
|
+
*/
|
|
161
|
+
#documentId = newDocumentId(Math.random);
|
|
144
162
|
#start = 0;
|
|
145
163
|
#overlay;
|
|
146
164
|
#presenter;
|
|
@@ -234,11 +252,18 @@ export class Reticle {
|
|
|
234
252
|
// page observed rather than guessing at the daemon, which it cannot see from in here.
|
|
235
253
|
onUnreachable: ({ url: tried, attempts }) => {
|
|
236
254
|
nativeWarn(unreachableMessage(tried, attempts));
|
|
255
|
+
// And on screen, not only in a console nobody has open. An instrumented page with a dead
|
|
256
|
+
// bridge looked exactly like a page with no Reticle in it, so the user could not tell a
|
|
257
|
+
// daemon they forgot to start from an install that did not work.
|
|
258
|
+
this.#presenter?.showUnreachable(tried, attempts);
|
|
237
259
|
},
|
|
238
260
|
});
|
|
239
261
|
// Capabilities registered AFTER connect (which is when they are registered, by design) must
|
|
240
262
|
// reach the bridge, or an app that declared its whole testable surface reads as having none.
|
|
241
263
|
setCapabilitiesListener(() => this.#transport?.reannounce());
|
|
264
|
+
// Contributors only, and reported in capabilities so a verdict drawn with it open is never
|
|
265
|
+
// mistaken for an ordinary one. See connect-options.ts.
|
|
266
|
+
setPresenterVisible(true === options.exposePresenter);
|
|
242
267
|
const emit = this.#emit;
|
|
243
268
|
this.#teardowns = installAllObservers(emit, {
|
|
244
269
|
captureBodies: true === options.captureNetworkBodies,
|
|
@@ -321,6 +346,18 @@ export class Reticle {
|
|
|
321
346
|
endSession() {
|
|
322
347
|
this.#presenter?.setState(SessionState.ENDED);
|
|
323
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Hand the SDK the page's hot-update channel, so a stale ref can say the code changed underneath it.
|
|
351
|
+
*
|
|
352
|
+
* `unknown` on purpose: the only caller that has one is the build integration, the shape it passes
|
|
353
|
+
* is Vite's `import.meta.hot`, and the SDK must not depend on Vite — it ships to Next, Electron,
|
|
354
|
+
* Tauri and plain pages. Anything that is not a subscribable channel is ignored, which is the
|
|
355
|
+
* normal case: with no channel the epoch stays at `NO_EDITS_OBSERVED`, meaning "no edits OBSERVED",
|
|
356
|
+
* never "no edits happened".
|
|
357
|
+
*/
|
|
358
|
+
observeHotUpdates(hot) {
|
|
359
|
+
editEpoch.observe(hot);
|
|
360
|
+
}
|
|
324
361
|
disconnect() {
|
|
325
362
|
if (!this.#connected)
|
|
326
363
|
return;
|
|
@@ -347,6 +384,8 @@ export class Reticle {
|
|
|
347
384
|
t: Math.round(performance.now() - this.#start),
|
|
348
385
|
type,
|
|
349
386
|
sessionId: this.#session,
|
|
387
|
+
documentId: this.#documentId,
|
|
388
|
+
editEpoch: editEpoch.current,
|
|
350
389
|
data,
|
|
351
390
|
ref,
|
|
352
391
|
});
|
|
@@ -10,3 +10,10 @@
|
|
|
10
10
|
* diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
|
|
11
11
|
*/
|
|
12
12
|
export declare function unreachableMessage(url: string, attempts: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* The same fact, sized for the HUD's one-line status row.
|
|
15
|
+
*
|
|
16
|
+
* The console warning explains; this one only has room to name the URL, which is the answer in
|
|
17
|
+
* most cases anyway — a bridge on a port the page is not dialling.
|
|
18
|
+
*/
|
|
19
|
+
export declare function unreachableStripText(url: string, attempts: number): string;
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* than a template inlined in a warn call. The rule it exists to hold: report what this page observed
|
|
6
6
|
* and what it could not determine, never a cause. See unreachable-message.test.ts.
|
|
7
7
|
*/
|
|
8
|
+
import { UNREACHABLE_NOTICE_PREFIX } from '@reticlehq/core';
|
|
8
9
|
/**
|
|
9
10
|
* The page tried, failed, and cannot see why. Names the address and the attempt count, refuses to
|
|
10
11
|
* diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
|
|
11
12
|
*/
|
|
12
13
|
export function unreachableMessage(url, attempts) {
|
|
13
|
-
return (
|
|
14
|
+
return (`${UNREACHABLE_NOTICE_PREFIX}${url}. ${String(attempts)} attempts, all ` +
|
|
14
15
|
`failed. That is everything the page can see: from inside the browser it cannot tell a daemon ` +
|
|
15
16
|
`that is not there from one it is not allowed to reach, so this is not evidence about the ` +
|
|
16
17
|
`daemon. What answers it: run \`npx @reticlehq/server status\` to see whether one is listening ` +
|
|
@@ -18,3 +19,12 @@ export function unreachableMessage(url, attempts) {
|
|
|
18
19
|
`different host, so set the URL explicitly (Vite: VITE_RETICLE_WS_URL, or ` +
|
|
19
20
|
`reticle.connect({ url })). An https page cannot open a ws:// socket at all. Still retrying…`);
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The same fact, sized for the HUD's one-line status row.
|
|
24
|
+
*
|
|
25
|
+
* The console warning explains; this one only has room to name the URL, which is the answer in
|
|
26
|
+
* most cases anyway — a bridge on a port the page is not dialling.
|
|
27
|
+
*/
|
|
28
|
+
export function unreachableStripText(url, attempts) {
|
|
29
|
+
return `no bridge at ${url} — ${String(attempts)} attempt${1 === attempts ? '' : 's'}`;
|
|
30
|
+
}
|