@reticlehq/browser 2.10.0 → 2.12.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.
@@ -91,7 +91,6 @@ export const SETTINGS_PLACEMENT_ATTR = 'data-reticle-settings-placement';
91
91
  /** Horizontal alignment of dock-anchored panels (start = left, end = right). */
92
92
  export const DOCK_ALIGN_ATTR = 'data-reticle-dock-align';
93
93
  export const Placement = { ABOVE: 'above', BELOW: 'below' };
94
- export const DockAlign = { START: 'start', END: 'end' };
95
94
  /** Show/hide annotation markers. */
96
95
  export const MARKERS_BTN_ATTR = 'data-reticle-markers-btn';
97
96
  /** Clear every annotation on the page. */
@@ -139,8 +138,6 @@ export const LOG_TIMESTAMPS_ATTR = 'data-reticle-log-ts';
139
138
  /** When 1, the chat panel uses a narrower width. */
140
139
  /** When 1, HUD motion is reduced for accessibility. */
141
140
  export const REDUCE_MOTION_ATTR = 'data-reticle-reduce-motion';
142
- /** Presenter settings card version label (browser package). */
143
- export const PRESENTER_UI_VERSION = '2.7.0';
144
141
  /** Docs opened from the settings footer. */
145
142
  export const MCP_DOCS_URL = 'https://github.com/reticlehq/reticle/blob/main/docs/getting-started.md';
146
143
  /** HUD has been dragged off the default dock - positioned with explicit left/top. */
@@ -1,5 +1,4 @@
1
1
  import { HumanControlKind, PresenterTone, SessionState } from '@reticlehq/core';
2
- export declare const PAUSED_BADGE_LABEL = "PAUSED";
3
2
  /** Border fade-out delay after a session ends (native timer; presenter-only tunable). */
4
3
  export declare const ENDED_FADE_MS = 4000;
5
4
  /** Payload the panel hands to its host when the human drives a control. */
@@ -12,8 +11,6 @@ export type ControlHandler = (intent: ControlIntent) => void;
12
11
  export declare const CONTROLS_CSS: string;
13
12
  /** Icon toolbar: pause, chat, markers, end, clear - copy/export appear after a run ends. */
14
13
  export declare const CONTROLS_TOOLBAR_HTML: string;
15
- /** @deprecated Legacy head markup - replaced by CONTROLS_TOOLBAR_HTML in the morphing shell. */
16
- export declare const CONTROLS_HEAD_HTML: string;
17
14
  /** Banner markup (between head and log, hidden unless ended). */
18
15
  export declare const CONTROLS_BANNER_HTML = "<div data-reticle-banner class=\"reticle-banner\">Session ended</div>";
19
16
  /** Replay-a-flow row (between log and footer); buttons are filled in by setFlows once flows arrive. */
@@ -26,7 +26,6 @@ const INPUT_PLACEHOLDER = 'Tell the agent something…';
26
26
  /** Accessible name for the composer (a placeholder is not an accessible name). */
27
27
  const INPUT_ARIA_LABEL = 'Message to the agent';
28
28
  const PAUSED_BADGE_TEXT = 'PAUSED';
29
- export const PAUSED_BADGE_LABEL = PAUSED_BADGE_TEXT;
30
29
  const ENDED_BANNER_TEXT = 'Session ended';
31
30
  const COPY_LABEL = 'Copy run';
32
31
  const EXPORT_LABEL = 'Export';
@@ -177,8 +176,6 @@ export const CONTROLS_TOOLBAR_HTML = [
177
176
  `<button type="button" data-reticle-export class="reticle-tb-btn reticle-tb-btn--export" title="${EXPORT_LABEL}" aria-label="${EXPORT_LABEL}" hidden>${hiIconHtml(PresenterIcon.DOWNLOAD, TB)}</button>`,
178
177
  '<span data-reticle-export-msg class="reticle-export-msg" aria-live="polite"></span>',
179
178
  ].join('');
180
- /** @deprecated Legacy head markup - replaced by CONTROLS_TOOLBAR_HTML in the morphing shell. */
181
- export const CONTROLS_HEAD_HTML = CONTROLS_TOOLBAR_HTML;
182
179
  /** Banner markup (between head and log, hidden unless ended). */
183
180
  export const CONTROLS_BANNER_HTML = `<div data-reticle-banner class="reticle-banner">${ENDED_BANNER_TEXT}</div>`;
184
181
  /** Replay-a-flow row (between log and footer); buttons are filled in by setFlows once flows arrive. */
@@ -223,6 +220,14 @@ export class ControlPanel {
223
220
  /** The full replayable-flow list from the last push; re-filtered per page on route change. */
224
221
  #flowItems = [];
225
222
  #workspaceTeardown;
223
+ /**
224
+ * One signal for every listener this controller registers.
225
+ *
226
+ * All eight are anonymous closures over `this`, so there was no reference to hand
227
+ * `removeEventListener` and teardown removed none of them — each kept the controller reachable
228
+ * for as long as its element lived, and a second mount stacked another set on top.
229
+ */
230
+ #listeners;
226
231
  #host;
227
232
  constructor(host) {
228
233
  this.#host = host;
@@ -232,6 +237,8 @@ export class ControlPanel {
232
237
  }
233
238
  /** Query control refs out of the mounted root and bind the DOM listeners, then paint active. */
234
239
  mount(root, glow) {
240
+ this.#listeners = new AbortController();
241
+ const { signal } = this.#listeners;
235
242
  this.#root = root;
236
243
  this.#glow = glow;
237
244
  this.#refs = queryControlRefs(root);
@@ -239,17 +246,17 @@ export class ControlPanel {
239
246
  if (pauseBtn !== undefined) {
240
247
  paintPauseBtn(pauseBtn, this.#state === SessionState.PAUSED);
241
248
  }
242
- this.#refs.pauseBtn?.addEventListener('click', () => this.#onPauseToggle());
243
- this.#refs.endBtn?.addEventListener('click', () => this.#onEnd());
244
- this.#refs.sendBtn?.addEventListener('click', () => this.#onSend());
249
+ this.#refs.pauseBtn?.addEventListener('click', () => this.#onPauseToggle(), { signal });
250
+ this.#refs.endBtn?.addEventListener('click', () => this.#onEnd(), { signal });
251
+ this.#refs.sendBtn?.addEventListener('click', () => this.#onSend(), { signal });
245
252
  this.#refs.input?.addEventListener('keydown', (e) => {
246
253
  // Enter sends; Shift+Enter inserts a newline (falls through to the textarea's default).
247
254
  if (e instanceof KeyboardEvent && 'Enter' === e.key && !e.shiftKey) {
248
255
  e.preventDefault();
249
256
  this.#onSend();
250
257
  }
251
- });
252
- this.#refs.input?.addEventListener('input', () => this.#autosize());
258
+ }, { signal });
259
+ this.#refs.input?.addEventListener('input', () => this.#autosize(), { signal });
253
260
  // Replay-a-flow: one ▶ click re-runs a saved flow (no agent). Delegated so it covers all chips.
254
261
  this.#refs.flows?.addEventListener('click', (e) => {
255
262
  const target = e.target;
@@ -259,9 +266,9 @@ export class ControlPanel {
259
266
  if (name !== null && name !== undefined && name.length > 0) {
260
267
  this.#host.emit(HumanControlKind.REPLAY, name);
261
268
  }
262
- });
263
- this.#refs.copyBtn?.addEventListener('click', () => this.#onCopy());
264
- this.#refs.exportBtn?.addEventListener('click', () => this.#onExport());
269
+ }, { signal });
270
+ this.#refs.copyBtn?.addEventListener('click', () => this.#onCopy(), { signal });
271
+ this.#refs.exportBtn?.addEventListener('click', () => this.#onExport(), { signal });
265
272
  this.#workspaceTeardown = mountWorkspaceSelector(root);
266
273
  this.setState(SessionState.ACTIVE);
267
274
  }
@@ -297,6 +304,9 @@ export class ControlPanel {
297
304
  }
298
305
  /** Clear any pending ended-fade timer (called from Presenter.destroy). */
299
306
  teardown() {
307
+ // All eight registrations, in one call that cannot drift from mount().
308
+ this.#listeners?.abort();
309
+ this.#listeners = undefined;
300
310
  this.#workspaceTeardown?.();
301
311
  this.#workspaceTeardown = undefined;
302
312
  if (this.#fadeTimer !== undefined)
@@ -68,8 +68,12 @@ export function installHudPositionGuards(hud, overlay) {
68
68
  const scheduleRelayout = () => {
69
69
  scheduleSyncDockLayout(hud, overlay);
70
70
  };
71
+ // One signal for all three listeners here. The observers below are NOT covered — neither
72
+ // ResizeObserver nor MutationObserver accepts one — so they keep their explicit disconnects.
73
+ const listeners = new AbortController();
74
+ const { signal } = listeners;
71
75
  const onResize = () => scheduleRelayout();
72
- window.addEventListener('resize', onResize);
76
+ window.addEventListener('resize', onResize, { signal });
73
77
  let resizeObserver;
74
78
  if (typeof ResizeObserver !== 'undefined') {
75
79
  resizeObserver = new ResizeObserver(() => scheduleRelayout());
@@ -81,12 +85,10 @@ export function installHudPositionGuards(hud, overlay) {
81
85
  attributeFilter: [MIN_ATTR, CHAT_ATTR, SETTINGS_ATTR],
82
86
  });
83
87
  const onVisualViewportResize = () => scheduleRelayout();
84
- window.visualViewport?.addEventListener('resize', onVisualViewportResize);
85
- window.visualViewport?.addEventListener('scroll', onVisualViewportResize);
88
+ window.visualViewport?.addEventListener('resize', onVisualViewportResize, { signal });
89
+ window.visualViewport?.addEventListener('scroll', onVisualViewportResize, { signal });
86
90
  return () => {
87
- window.removeEventListener('resize', onResize);
88
- window.visualViewport?.removeEventListener('resize', onVisualViewportResize);
89
- window.visualViewport?.removeEventListener('scroll', onVisualViewportResize);
91
+ listeners.abort();
90
92
  resizeObserver?.disconnect();
91
93
  minObserver.disconnect();
92
94
  };
@@ -171,15 +173,16 @@ export function installHudDrag(hud, head, callbacks = {}) {
171
173
  releaseCapture(e.pointerId);
172
174
  finishDrag();
173
175
  };
174
- head.addEventListener('pointerdown', onPointerDown);
175
- head.addEventListener('pointermove', onPointerMove);
176
- head.addEventListener('pointerup', onPointerUp);
177
- head.addEventListener('pointercancel', onPointerCancel);
176
+ // All four pointer phases share one signal: they are added together and must come off
177
+ // together, and a partial removal would leave a half-live drag.
178
+ const listeners = new AbortController();
179
+ const { signal } = listeners;
180
+ head.addEventListener('pointerdown', onPointerDown, { signal });
181
+ head.addEventListener('pointermove', onPointerMove, { signal });
182
+ head.addEventListener('pointerup', onPointerUp, { signal });
183
+ head.addEventListener('pointercancel', onPointerCancel, { signal });
178
184
  return () => {
179
- head.removeEventListener('pointerdown', onPointerDown);
180
- head.removeEventListener('pointermove', onPointerMove);
181
- head.removeEventListener('pointerup', onPointerUp);
182
- head.removeEventListener('pointercancel', onPointerCancel);
185
+ listeners.abort();
183
186
  if (dragging)
184
187
  releaseCapture(activePointerId ?? 0);
185
188
  finishDrag();
@@ -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">
@@ -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';
@@ -48,6 +48,15 @@ export class HudShell {
48
48
  */
49
49
  #annotateOn = false;
50
50
  #toggleSync;
51
+ /**
52
+ * One signal for every listener this shell registers, so teardown cannot drift from mount.
53
+ *
54
+ * The click handlers below are anonymous closures over `this`: there is no reference to hand to
55
+ * `removeEventListener`, so before this they were never removed at all. Mounting twice onto the
56
+ * same root therefore stacked a second set, and every handler kept the shell reachable for as
57
+ * long as its element lived.
58
+ */
59
+ #listeners;
51
60
  #report = new PresenterReport({
52
61
  onBeforeOpen: () => {
53
62
  // One panel at a time in the slot above the toolbar. The chat, the settings and the report
@@ -155,6 +164,8 @@ export class HudShell {
155
164
  this.#annotateBtn?.setAttribute('data-active', on ? '1' : '0');
156
165
  }
157
166
  mount(root) {
167
+ this.#listeners = new AbortController();
168
+ const { signal } = this.#listeners;
158
169
  this.#root = root;
159
170
  this.#dock = root.querySelector(`[${DOCK_ATTR}]`) ?? undefined;
160
171
  const fabEl = root.querySelector(`[${FAB_ATTR}]`);
@@ -170,19 +181,19 @@ export class HudShell {
170
181
  e.stopPropagation();
171
182
  this.setAnnotateOn(!this.#annotateOn);
172
183
  this.#callbacks.onAnnotateToggle?.(this.#annotateOn);
173
- });
184
+ }, { signal });
174
185
  const pillEl = root.querySelector(`[${CHAT_PILL_ATTR}]`);
175
186
  pillEl?.addEventListener('click', (e) => {
176
187
  e.preventDefault();
177
188
  e.stopPropagation();
178
189
  this.showChat();
179
- });
190
+ }, { signal });
180
191
  const chatMinEl = root.querySelector(`[${CHAT_MIN_ATTR}]`);
181
192
  chatMinEl?.addEventListener('click', (e) => {
182
193
  e.preventDefault();
183
194
  e.stopPropagation();
184
195
  this.closeChat();
185
- });
196
+ }, { signal });
186
197
  const collapseEl = root.querySelector('[data-reticle-min-btn]');
187
198
  this.#collapseBtn = collapseEl instanceof HTMLButtonElement ? collapseEl : undefined;
188
199
  root.setAttribute(MIN_ATTR, '1');
@@ -204,7 +215,7 @@ export class HudShell {
204
215
  e.preventDefault();
205
216
  e.stopPropagation();
206
217
  this.#report.toggle();
207
- });
218
+ }, { signal });
208
219
  this.#fab?.addEventListener('click', (e) => {
209
220
  e.stopPropagation();
210
221
  if (this.#suppressFabClick) {
@@ -212,15 +223,15 @@ export class HudShell {
212
223
  return;
213
224
  }
214
225
  this.expand();
215
- });
226
+ }, { signal });
216
227
  this.#collapseBtn?.addEventListener('click', (e) => {
217
228
  e.stopPropagation();
218
229
  this.collapse();
219
- });
230
+ }, { signal });
220
231
  this.#chatToggle?.addEventListener('click', (e) => {
221
232
  e.stopPropagation();
222
233
  this.toggleChat();
223
- });
234
+ }, { signal });
224
235
  const toolbarDrag = root.querySelector(`.${DRAG_HANDLE_CLASS}`);
225
236
  const dragHandles = [this.#fab, toolbarDrag].filter((el) => el instanceof HTMLElement);
226
237
  if (this.#dock !== undefined && dragHandles.length > 0) {
@@ -235,12 +246,19 @@ export class HudShell {
235
246
  });
236
247
  this.#layoutTeardown = installHudPositionGuards(this.#dock, root);
237
248
  }
238
- document.addEventListener('pointerdown', this.#onDocPointerDown);
239
- document.addEventListener('keydown', this.#onKeyDown);
249
+ document.addEventListener('pointerdown', this.#onDocPointerDown, { signal });
250
+ document.addEventListener('keydown', this.#onKeyDown, { signal });
240
251
  }
241
252
  teardown() {
242
- document.removeEventListener('pointerdown', this.#onDocPointerDown);
243
- document.removeEventListener('keydown', this.#onKeyDown);
253
+ // One call for all nine registrations. It cannot fall out of step with mount() the way a list
254
+ // of removeEventListener calls can, which is the whole point.
255
+ this.#listeners?.abort();
256
+ this.#listeners = undefined;
257
+ // NOT covered by the signal: a MutationObserver takes no `signal` option. This disconnect was
258
+ // missing entirely, so the observer kept `root` — and through it the shell — alive after
259
+ // teardown, and a re-mount left the previous one still firing.
260
+ this.#toggleSync?.disconnect();
261
+ this.#toggleSync = undefined;
244
262
  this.#dragTeardown?.();
245
263
  this.#dragTeardown = undefined;
246
264
  this.#layoutTeardown?.();
@@ -402,7 +420,3 @@ export class HudShell {
402
420
  }
403
421
  };
404
422
  }
405
- /** Whether the dock has been dragged off the default bottom-right position. */
406
- export function isDockDragged(dock) {
407
- return '1' === dock.getAttribute(HUD_DRAGGED_ATTR);
408
- }
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,6 @@
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 { 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
2
  import { rememberSessionLabel } from './session-continuity.js';
3
+ import { editEpoch } from './edit-epoch.js';
3
4
  import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
4
5
  import { Transport } from './transport/transport.js';
5
6
  import { unreachableMessage } from './transport/unreachable-message.js';
@@ -128,6 +129,15 @@ export function buildEvent(args) {
128
129
  type: args.type,
129
130
  sessionId: args.sessionId,
130
131
  ref: args.ref,
132
+ // Which document this was observed under, so the server can refuse evidence minted before a
133
+ // navigation replaced the page. Stamped HERE because it is the one place every event passes
134
+ // through: an observer added later would otherwise emit unstamped events, which read as
135
+ // "current" by design and would reintroduce the defect silently for one event type.
136
+ documentId: args.documentId,
137
+ // Which round of source edits this was observed under. Stamped HERE for exactly the reason
138
+ // documentId is, and omitted while nothing has hot-updated: absence already reads as "current"
139
+ // downstream, so `NO_EDITS_OBSERVED` on the wire would be bytes spent saying "unknown".
140
+ editEpoch: NO_EDITS_OBSERVED === args.editEpoch ? undefined : args.editEpoch,
131
141
  data: args.data,
132
142
  };
133
143
  }
@@ -141,6 +151,13 @@ export class Reticle {
141
151
  #teardowns = [];
142
152
  #connected = false;
143
153
  #session = 'default';
154
+ /**
155
+ * Minted once, here, because this class is constructed once per document: a full navigation tears
156
+ * down the JavaScript context and builds a new one, so the id dies with the document it names. An
157
+ * SPA route change does not reconstruct it, which is correct — same context, same in-flight
158
+ * requests, same evidence.
159
+ */
160
+ #documentId = newDocumentId(Math.random);
144
161
  #start = 0;
145
162
  #overlay;
146
163
  #presenter;
@@ -321,6 +338,18 @@ export class Reticle {
321
338
  endSession() {
322
339
  this.#presenter?.setState(SessionState.ENDED);
323
340
  }
341
+ /**
342
+ * Hand the SDK the page's hot-update channel, so a stale ref can say the code changed underneath it.
343
+ *
344
+ * `unknown` on purpose: the only caller that has one is the build integration, the shape it passes
345
+ * is Vite's `import.meta.hot`, and the SDK must not depend on Vite — it ships to Next, Electron,
346
+ * Tauri and plain pages. Anything that is not a subscribable channel is ignored, which is the
347
+ * normal case: with no channel the epoch stays at `NO_EDITS_OBSERVED`, meaning "no edits OBSERVED",
348
+ * never "no edits happened".
349
+ */
350
+ observeHotUpdates(hot) {
351
+ editEpoch.observe(hot);
352
+ }
324
353
  disconnect() {
325
354
  if (!this.#connected)
326
355
  return;
@@ -347,6 +376,8 @@ export class Reticle {
347
376
  t: Math.round(performance.now() - this.#start),
348
377
  type,
349
378
  sessionId: this.#session,
379
+ documentId: this.#documentId,
380
+ editEpoch: editEpoch.current,
350
381
  data,
351
382
  ref,
352
383
  });
@@ -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 ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,21 +13,18 @@
13
13
  "./package.json": "./package.json"
14
14
  },
15
15
  "dependencies": {
16
- "@testing-library/dom": "^10.4.1",
17
- "@reticlehq/core": "2.10.0"
16
+ "@reticlehq/core": "2.12.0"
18
17
  },
19
18
  "devDependencies": {
20
19
  "@iconify-json/heroicons-outline": "^1.2.1",
21
20
  "@iconify-json/heroicons-solid": "^1.2.1",
22
21
  "@tanstack/query-core": "^5.101.4",
23
- "@types/jsdom": "^30.0.0",
24
22
  "jotai": "^2.20.2",
25
23
  "jsdom": "^30.0.1",
26
24
  "mobx": "^7.0.0",
27
25
  "pinia": "^4.0.3",
28
26
  "react": "^19.2.8",
29
27
  "recoil": "^0.7.7",
30
- "sharp": "^0.35.3",
31
28
  "svelte": "^5.56.8",
32
29
  "valtio": "^2.3.2",
33
30
  "vue": "^3.5.41",
@@ -1,13 +0,0 @@
1
- /**
2
- * Blind-spot detection, the detectable half). Cross-origin iframes are a real observation gap — the
3
- * SDK cannot see inside them (same-origin policy). We COUNT them so a result can say "coverage: partial —
4
- * 2 cross-origin frames unobserved" instead of implying it saw the whole page. Closed shadow roots are a
5
- * blind spot too, but undetectable by design (element.shadowRoot === null), so they are not counted here.
6
- */
7
- /** Pure: how many frames are cross-origin, given each frame's same-origin flag + whether it has a src. */
8
- export declare function classifyCrossOriginFrames(frames: ReadonlyArray<{
9
- sameOrigin: boolean;
10
- hasSrc: boolean;
11
- }>): number;
12
- /** Probe the live document: count iframes whose document is unreachable (cross-origin). */
13
- export declare function countCrossOriginFrames(): number;
@@ -1,29 +0,0 @@
1
- /**
2
- * Blind-spot detection, the detectable half). Cross-origin iframes are a real observation gap — the
3
- * SDK cannot see inside them (same-origin policy). We COUNT them so a result can say "coverage: partial —
4
- * 2 cross-origin frames unobserved" instead of implying it saw the whole page. Closed shadow roots are a
5
- * blind spot too, but undetectable by design (element.shadowRoot === null), so they are not counted here.
6
- */
7
- /** Pure: how many frames are cross-origin, given each frame's same-origin flag + whether it has a src. */
8
- export function classifyCrossOriginFrames(frames) {
9
- return frames.filter((f) => f.hasSrc && !f.sameOrigin).length;
10
- }
11
- /** Probe the live document: count iframes whose document is unreachable (cross-origin). */
12
- export function countCrossOriginFrames() {
13
- if ('undefined' === typeof document)
14
- return 0;
15
- const frames = [];
16
- for (const frame of document.querySelectorAll('iframe')) {
17
- const hasSrc = frame.getAttribute('src') !== null && frame.getAttribute('src') !== '';
18
- let sameOrigin;
19
- try {
20
- // Reaching contentDocument throws (or yields null) for a cross-origin frame.
21
- sameOrigin = frame.contentDocument !== null;
22
- }
23
- catch {
24
- sameOrigin = false;
25
- }
26
- frames.push({ sameOrigin, hasSrc });
27
- }
28
- return classifyCrossOriginFrames(frames);
29
- }