@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.
Files changed (63) hide show
  1. package/dist/actions/actions-dom.js +41 -3
  2. package/dist/actions/actions.js +43 -11
  3. package/dist/commands/commands.js +6 -2
  4. package/dist/connect-options.d.ts +18 -1
  5. package/dist/dom/a11y.js +92 -9
  6. package/dist/dom/dom-ignore.d.ts +9 -0
  7. package/dist/dom/dom-ignore.js +32 -3
  8. package/dist/dom/query.js +168 -97
  9. package/dist/dom/refs.d.ts +11 -0
  10. package/dist/dom/refs.js +30 -1
  11. package/dist/dom/snapshot.d.ts +37 -0
  12. package/dist/dom/snapshot.js +141 -37
  13. package/dist/edit-epoch.d.ts +32 -0
  14. package/dist/edit-epoch.js +108 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/observers/animation.js +6 -8
  18. package/dist/observers/context-open.d.ts +12 -0
  19. package/dist/observers/context-open.js +25 -0
  20. package/dist/observers/focus.js +5 -6
  21. package/dist/observers/health.js +6 -9
  22. package/dist/observers/install-all.js +2 -0
  23. package/dist/observers/network-redact.d.ts +13 -0
  24. package/dist/observers/network-redact.js +12 -1
  25. package/dist/observers/network.d.ts +2 -2
  26. package/dist/observers/network.js +24 -17
  27. package/dist/observers/scroll.js +4 -2
  28. package/dist/observers/storage.js +1 -1
  29. package/dist/presenter/presenter-brand.d.ts +4 -18
  30. package/dist/presenter/presenter-brand.js +6 -18
  31. package/dist/presenter/presenter-config.d.ts +11 -8
  32. package/dist/presenter/presenter-config.js +9 -3
  33. package/dist/presenter/presenter-controls.d.ts +0 -3
  34. package/dist/presenter/presenter-controls.js +0 -3
  35. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  36. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  37. package/dist/presenter/presenter-report-copy.js +31 -2
  38. package/dist/presenter/presenter-report-styles.js +24 -0
  39. package/dist/presenter/presenter-report.d.ts +2 -2
  40. package/dist/presenter/presenter-report.js +95 -3
  41. package/dist/presenter/presenter-settings.js +10 -2
  42. package/dist/presenter/presenter-shell-styles.js +20 -1
  43. package/dist/presenter/presenter-shell.d.ts +0 -2
  44. package/dist/presenter/presenter-shell.js +1 -6
  45. package/dist/presenter/presenter.d.ts +13 -0
  46. package/dist/presenter/presenter.js +37 -2
  47. package/dist/registry/auto-testids.d.ts +21 -0
  48. package/dist/registry/auto-testids.js +48 -0
  49. package/dist/registry/capabilities.d.ts +28 -1
  50. package/dist/registry/capabilities.js +59 -7
  51. package/dist/registry/store-adapters.d.ts +1 -1
  52. package/dist/registry/store-adapters.js +6 -1
  53. package/dist/registry/stores.d.ts +4 -0
  54. package/dist/registry/stores.js +35 -0
  55. package/dist/reticle.d.ts +12 -0
  56. package/dist/reticle.js +40 -1
  57. package/dist/transport/unreachable-message.d.ts +7 -0
  58. package/dist/transport/unreachable-message.js +11 -1
  59. package/package.json +4 -7
  60. package/dist/dom/blind-spots.d.ts +0 -13
  61. package/dist/dom/blind-spots.js +0 -29
  62. package/dist/observers/subresources.d.ts +0 -10
  63. 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
- autoOpenChat: false,
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 = 'Open 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.';
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:3px 7px;border-radius:999px;text-transform:uppercase;background:rgba(255,255,255,.06);}
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, HUD_DRAGGED_ATTR, SETTINGS_ATTR, SETTINGS_BTN_ATTR, } from './presenter-config.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, 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
- 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></div>`;
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
- /** Whether the app has advertised any capabilities (used in the HELLO flag). */
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: [...capabilities.testids],
80
+ testids: union(capabilities.testids, liveTestids()),
52
81
  signals: [...capabilities.signals],
53
- stores: [...capabilities.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
- /** Whether the app has advertised any capabilities (used in the HELLO flag). */
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.testids.length > 0 ||
60
- capabilities.signals.length > 0 ||
61
- capabilities.stores.length > 0 ||
62
- capabilities.flows.length > 0);
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,4 @@
1
- import type { StoreLike, StoreSubscribe } from './stores.js';
1
+ import { type StoreLike, type StoreSubscribe } from './stores.js';
2
2
  /**
3
3
  * Adapters that give non-`{getState, subscribe}` state libraries the shape `registerStore` wants.
4
4
  *
@@ -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
- return {
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;
@@ -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 { 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, } from '@reticlehq/core';
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 (`[Reticle] this page could not open a websocket to ${url}. ${String(attempts)} attempts, all ` +
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
+ }