@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.
- package/dist/actions/actions-dom.js +41 -3
- package/dist/actions/actions.js +37 -5
- package/dist/commands/commands.js +6 -2
- package/dist/dom/a11y.js +92 -9
- package/dist/dom/query.js +113 -94
- package/dist/dom/refs.d.ts +11 -0
- package/dist/dom/refs.js +30 -1
- package/dist/dom/snapshot.d.ts +29 -0
- package/dist/dom/snapshot.js +72 -34
- package/dist/edit-epoch.d.ts +32 -0
- package/dist/edit-epoch.js +108 -0
- package/dist/observers/context-open.d.ts +12 -0
- package/dist/observers/context-open.js +25 -0
- package/dist/observers/install-all.js +2 -0
- package/dist/observers/network.js +59 -0
- 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 +0 -6
- package/dist/presenter/presenter-config.js +0 -3
- package/dist/presenter/presenter-controls.d.ts +0 -3
- package/dist/presenter/presenter-controls.js +21 -11
- package/dist/presenter/presenter-drag.js +17 -14
- package/dist/presenter/presenter-settings.js +10 -2
- package/dist/presenter/presenter-shell.d.ts +0 -2
- package/dist/presenter/presenter-shell.js +30 -16
- package/dist/reticle.d.ts +12 -0
- package/dist/reticle.js +32 -1
- package/dist/transport/unreachable-message.js +2 -1
- package/package.json +2 -5
- package/dist/dom/blind-spots.d.ts +0 -13
- package/dist/dom/blind-spots.js +0 -29
|
@@ -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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
@@ -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';
|
|
@@ -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
|
-
|
|
243
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
"@
|
|
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;
|
package/dist/dom/blind-spots.js
DELETED
|
@@ -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
|
-
}
|