@djangocfg/ui-tools 2.1.409 → 2.1.412

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 (83) hide show
  1. package/package.json +13 -13
  2. package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
  3. package/src/lib/browser-bridge/commands/chat.ts +42 -0
  4. package/src/lib/browser-bridge/commands/highlight.ts +70 -0
  5. package/src/lib/browser-bridge/commands/index.ts +15 -0
  6. package/src/lib/browser-bridge/commands/inspect.ts +31 -0
  7. package/src/lib/browser-bridge/commands/scroll.ts +31 -0
  8. package/src/lib/browser-bridge/commands/write.ts +45 -0
  9. package/src/lib/browser-bridge/directive-bus.ts +120 -0
  10. package/src/lib/browser-bridge/index.ts +56 -0
  11. package/src/lib/browser-bridge/logger.ts +27 -0
  12. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
  13. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
  14. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
  15. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
  16. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
  17. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
  18. package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
  19. package/src/lib/browser-bridge/registry.ts +41 -0
  20. package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
  21. package/src/lib/browser-bridge/window.ts +76 -0
  22. package/src/lib/page-snapshot/capture/walk.ts +13 -5
  23. package/src/lib/page-snapshot/engine.ts +9 -4
  24. package/src/lib/page-snapshot/index.ts +5 -0
  25. package/src/lib/page-snapshot/react/provider.tsx +70 -3
  26. package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
  27. package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
  28. package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
  29. package/src/lib/page-snapshot/refs/locator.ts +218 -0
  30. package/src/lib/page-snapshot/refs/registry.ts +29 -14
  31. package/src/tools/Chat/README.md +1 -1
  32. package/src/tools/Chat/composer/AttachContext.tsx +22 -0
  33. package/src/tools/Chat/composer/Composer.tsx +108 -6
  34. package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
  35. package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
  36. package/src/tools/Chat/composer/index.ts +16 -1
  37. package/src/tools/Chat/composer/types.ts +71 -0
  38. package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
  39. package/src/tools/Chat/constants.ts +24 -1
  40. package/src/tools/Chat/context/ChatProvider.tsx +17 -2
  41. package/src/tools/Chat/core/logger.ts +15 -2
  42. package/src/tools/Chat/hooks/useChat.ts +32 -0
  43. package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
  44. package/src/tools/Chat/index.ts +34 -2
  45. package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
  46. package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
  47. package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
  48. package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
  49. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
  50. package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
  51. package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
  52. package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
  53. package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
  54. package/src/tools/Chat/lazy.tsx +34 -2
  55. package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
  56. package/src/tools/Chat/public.ts +17 -0
  57. package/src/tools/Chat/settings/README.md +87 -0
  58. package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
  59. package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
  60. package/src/tools/Chat/settings/index.ts +23 -0
  61. package/src/tools/Chat/settings/types.ts +108 -0
  62. package/src/tools/Chat/settings/useChatSettings.ts +168 -0
  63. package/src/tools/Chat/types/events.ts +50 -0
  64. package/src/tools/Chat/types/index.ts +1 -1
  65. package/src/tools/Chat/types/message.ts +5 -0
  66. package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
  67. package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
  68. package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
  69. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
  70. package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
  71. package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
  72. package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
  73. package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
  74. package/src/tools/CronScheduler/context/hooks.ts +8 -0
  75. package/src/tools/CronScheduler/context/index.ts +1 -0
  76. package/src/tools/CronScheduler/index.tsx +2 -0
  77. package/src/tools/CronScheduler/lazy.tsx +1 -0
  78. package/src/tools/CronScheduler/types/index.ts +18 -1
  79. package/src/tools/Map/lazy.tsx +11 -4
  80. package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
  81. package/src/tools/index.ts +2 -0
  82. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
  83. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
@@ -19,6 +19,25 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
19
19
  };
20
20
  }
21
21
 
22
+ // jsdom has no IntersectionObserver — the overlay uses it to wait for
23
+ // an off-screen target to become visible. Stub it to report the
24
+ // observed element as immediately intersecting so the wait resolves.
25
+ if (typeof globalThis.IntersectionObserver === 'undefined') {
26
+ (globalThis as Record<string, unknown>).IntersectionObserver = class {
27
+ private cb: (entries: unknown[]) => void;
28
+ constructor(cb: (entries: unknown[]) => void) {
29
+ this.cb = cb;
30
+ }
31
+ observe(target: Element) {
32
+ this.cb([
33
+ { target, isIntersecting: true, intersectionRatio: 1 },
34
+ ]);
35
+ }
36
+ unobserve() {}
37
+ disconnect() {}
38
+ };
39
+ }
40
+
22
41
  let container: HTMLDivElement;
23
42
  let root: Root;
24
43
 
@@ -109,4 +128,37 @@ describe('HighlightOverlay', () => {
109
128
  );
110
129
  expect(document.activeElement).not.toBe(el);
111
130
  });
131
+
132
+ it('scrolls an off-screen target into view before drawing it', async () => {
133
+ const el = document.createElement('button');
134
+ document.body.appendChild(el);
135
+
136
+ // Report the element below the viewport so it counts as off-screen.
137
+ el.getBoundingClientRect = () =>
138
+ ({ top: 5000, left: 0, bottom: 5040, right: 80, width: 80, height: 40, x: 0, y: 5000 }) as DOMRect;
139
+
140
+ let scrolled = false;
141
+ el.scrollIntoView = () => {
142
+ scrolled = true;
143
+ };
144
+
145
+ await render(
146
+ [{ type: 'point', ref: '@e1' }],
147
+ makeResolver({ '@e1': el }),
148
+ );
149
+
150
+ // Let the visibility wait resolve and its rAF-scheduled re-measure
151
+ // run, so the now-ready target is drawn.
152
+ await act(async () => {
153
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
154
+ });
155
+
156
+ // The off-screen target was scrolled into view, and once the
157
+ // (stubbed) IntersectionObserver reports it visible the overlay
158
+ // draws the spotlight.
159
+ expect(scrolled).toBe(true);
160
+ expect(
161
+ document.querySelector('[data-chat-highlight-overlay]'),
162
+ ).not.toBeNull();
163
+ });
112
164
  });
@@ -1,6 +1,8 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from 'vitest';
3
3
 
4
+ import { computeLocator } from '../../../page-snapshot/refs/locator';
5
+ import { RefRegistry } from '../../../page-snapshot/refs/registry';
4
6
  import { resolveRefs, type RefResolver } from '../resolveRef';
5
7
  import type { CSTRefId } from '../types';
6
8
 
@@ -53,3 +55,40 @@ describe('resolveRefs', () => {
53
55
  expect(out.map((o) => o.ref)).toEqual(['@e1']);
54
56
  });
55
57
  });
58
+
59
+ describe('resolveRefs against a live RefRegistry', () => {
60
+ it('resolves a ref after the captured node is recreated by a re-render', () => {
61
+ // Capture: the snapshot registry stores a locator, not a node.
62
+ const original = document.createElement('a');
63
+ original.id = 'cta';
64
+ original.textContent = 'Browse Catalog';
65
+ document.body.appendChild(original);
66
+
67
+ const registry = new RefRegistry('snap-1');
68
+ registry.set('@e1', computeLocator(original, 'link'));
69
+
70
+ // React re-render replaces the node with a fresh equivalent — the
71
+ // original is now detached, exactly the production failure mode.
72
+ original.remove();
73
+ const fresh = document.createElement('a');
74
+ fresh.id = 'cta';
75
+ fresh.textContent = 'Browse Catalog';
76
+ document.body.appendChild(fresh);
77
+
78
+ const out = resolveRefs(['@e1'], registry);
79
+ expect(out).toHaveLength(1);
80
+ expect(out[0].element).toBe(fresh);
81
+ });
82
+
83
+ it('drops a ref whose element is genuinely gone', () => {
84
+ const el = document.createElement('button');
85
+ el.textContent = 'Delete';
86
+ document.body.appendChild(el);
87
+
88
+ const registry = new RefRegistry('snap-1');
89
+ registry.set('@e1', computeLocator(el, 'button'));
90
+
91
+ el.remove();
92
+ expect(resolveRefs(['@e1'], registry)).toEqual([]);
93
+ });
94
+ });
@@ -1,18 +1,21 @@
1
1
  'use client';
2
2
 
3
3
  /**
4
- * Chat highlightAI-driven `point` directives.
4
+ * Highlight overlayhow a `point` directive is drawn.
5
5
  *
6
- * When the assistant returns `point` directives, this module resolves
7
- * the CST refs to live elements and draws a spotlight overlay over
8
- * them (and optionally focuses one). Read-only: it never changes the
9
- * user's data.
6
+ * Resolves CST refs to live elements, tracks their geometry, and draws
7
+ * an SVG-mask spotlight over them. Read-only: it highlights and
8
+ * optionally focuses elements, never changes the user's data.
10
9
  */
11
10
 
12
11
  export { HighlightOverlay, type HighlightOverlayProps } from './HighlightOverlay';
13
12
  export { SpotlightCanvas, type SpotlightCanvasProps } from './SpotlightCanvas';
14
13
  export { useHighlightTargets } from './useHighlightTargets';
15
14
  export { resolveRefs, type RefResolver } from './resolveRef';
15
+ export {
16
+ waitForVisible,
17
+ type WaitForVisibleOptions,
18
+ } from './waitForVisible';
16
19
  export type {
17
20
  PointDirective,
18
21
  HighlightTarget,
@@ -8,6 +8,11 @@
8
8
  *
9
9
  * A ref that no longer resolves is "stale" — the element was removed or
10
10
  * the user navigated away. Callers treat a null result as stale.
11
+ *
12
+ * The registry resolves each ref with a live-DOM query, so a ref
13
+ * survives React recreating nodes between capture and directive
14
+ * application. The `isConnected` check below is a defensive backstop —
15
+ * a live query already returns connected elements.
11
16
  */
12
17
 
13
18
  import type { CSTRefId } from './types';
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
4
4
 
5
5
  import { resolveRefs, type RefResolver } from './resolveRef';
6
6
  import type { HighlightTarget, PointDirective } from './types';
7
+ import { waitForVisible } from './waitForVisible';
7
8
 
8
9
  /** Is a rect fully inside the viewport. */
9
10
  function isOnScreen(rect: DOMRect): boolean {
@@ -20,8 +21,10 @@ function isOnScreen(rect: DOMRect): boolean {
20
21
  * targets.
21
22
  *
22
23
  * - resolves each ref to a DOM element (drops stale ones);
24
+ * - scrolls an off-screen target into view and waits until it is
25
+ * actually visible before exposing it — so the spotlight is drawn at
26
+ * the element's settled position, never mid-scroll;
23
27
  * - measures rects and keeps them fresh on scroll / resize;
24
- * - scrolls the first off-screen target into view;
25
28
  * - moves focus into a target when the directive asked for it;
26
29
  * - drops a target automatically if its element leaves the DOM.
27
30
  *
@@ -57,11 +60,21 @@ export function useHighlightTargets(
57
60
  return;
58
61
  }
59
62
 
60
- // Measure all targets — called now and on every scroll / resize.
63
+ let cancelled = false;
64
+
65
+ // A pair is only drawn once `ready` is true. Already-visible
66
+ // targets are ready immediately; off-screen ones become ready
67
+ // after their scroll-into-view settles (or its safety timeout).
68
+ const ready = new WeakSet<HTMLElement>();
69
+
70
+ // Measure all *ready* targets — called now and on every
71
+ // scroll / resize. Off-screen-but-not-yet-settled targets are
72
+ // skipped so their spotlight is never drawn at a stale position.
61
73
  const measure = () => {
62
74
  const next: HighlightTarget[] = [];
63
75
  for (const { element, directive } of pairs) {
64
76
  if (!element.isConnected) continue; // element left the DOM
77
+ if (!ready.has(element)) continue; // still scrolling into view
65
78
  next.push({
66
79
  element,
67
80
  rect: element.getBoundingClientRect(),
@@ -78,37 +91,55 @@ export function useHighlightTargets(
78
91
  frame = requestAnimationFrame(measure);
79
92
  };
80
93
 
81
- // Scroll the first target into view if it is off-screen.
82
- const first = pairs[0].element;
83
- const firstOffScreen = !isOnScreen(first.getBoundingClientRect());
84
- if (firstOffScreen) {
85
- first.scrollIntoView({ behavior: 'smooth', block: 'center' });
94
+ // Split targets into the ones already on screen and the ones that
95
+ // need to be scrolled into view first.
96
+ const offScreen: HTMLElement[] = [];
97
+ for (const { element } of pairs) {
98
+ if (isOnScreen(element.getBoundingClientRect())) {
99
+ ready.add(element);
100
+ } else {
101
+ offScreen.push(element);
102
+ }
86
103
  }
87
104
 
88
- // Focus a target if requested after any scroll settles.
105
+ // Scroll each off-screen target into view, then wait until it is
106
+ // actually visible before marking it ready and re-measuring.
107
+ for (const element of offScreen) {
108
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
109
+ void waitForVisible(element).then(() => {
110
+ if (cancelled || !element.isConnected) return;
111
+ ready.add(element);
112
+ schedule();
113
+ });
114
+ }
115
+
116
+ // Focus a target if requested — after its scroll-into-view settles
117
+ // so focus() does not fight the smooth scroll.
89
118
  const focusPair = pairs.find((p) => p.directive.focus);
90
- let focusTimer = 0;
91
119
  if (focusPair) {
92
120
  const el = focusPair.element;
93
- focusTimer = window.setTimeout(
94
- () => {
95
- if (!el.isConnected) return;
96
- // Make a non-focusable element focusable so focus() lands.
97
- const nativelyFocusable = [
98
- 'INPUT',
99
- 'TEXTAREA',
100
- 'SELECT',
101
- 'BUTTON',
102
- 'A',
103
- ].includes(el.tagName);
104
- if (el.tabIndex < 0 && !nativelyFocusable) el.tabIndex = -1;
105
- el.focus({ preventScroll: true });
106
- },
107
- firstOffScreen ? 400 : 0,
108
- );
121
+ const applyFocus = () => {
122
+ if (cancelled || !el.isConnected) return;
123
+ // Make a non-focusable element focusable so focus() lands.
124
+ const nativelyFocusable = [
125
+ 'INPUT',
126
+ 'TEXTAREA',
127
+ 'SELECT',
128
+ 'BUTTON',
129
+ 'A',
130
+ ].includes(el.tagName);
131
+ if (el.tabIndex < 0 && !nativelyFocusable) el.tabIndex = -1;
132
+ el.focus({ preventScroll: true });
133
+ };
134
+ if (isOnScreen(el.getBoundingClientRect())) {
135
+ applyFocus();
136
+ } else {
137
+ void waitForVisible(el).then(applyFocus);
138
+ }
109
139
  }
110
140
 
111
- // Initial measure + keep geometry fresh.
141
+ // Initial measure (draws any already-visible targets) + keep
142
+ // geometry fresh.
112
143
  measure();
113
144
  window.addEventListener('scroll', schedule, true);
114
145
  window.addEventListener('resize', schedule);
@@ -116,8 +147,8 @@ export function useHighlightTargets(
116
147
  for (const { element } of pairs) observer.observe(element);
117
148
 
118
149
  return () => {
150
+ cancelled = true;
119
151
  cancelAnimationFrame(frame);
120
- window.clearTimeout(focusTimer);
121
152
  window.removeEventListener('scroll', schedule, true);
122
153
  window.removeEventListener('resize', schedule);
123
154
  observer.disconnect();
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Wait until an element is actually visible in the viewport.
3
+ *
4
+ * The highlight overlay scrolls an off-screen target into view before
5
+ * drawing its spotlight — but `scrollIntoView({ behavior: 'smooth' })`
6
+ * settles asynchronously, so measuring the rect right after the call
7
+ * yields a stale (mid-scroll or still off-screen) position. This waits
8
+ * for the browser to confirm the element is on screen.
9
+ */
10
+
11
+ /** Options for {@link waitForVisible}. */
12
+ export interface WaitForVisibleOptions {
13
+ /**
14
+ * Fraction of the element that must be inside the viewport before it
15
+ * counts as visible (IntersectionObserver threshold).
16
+ */
17
+ threshold?: number;
18
+ /**
19
+ * Safety cap (ms). If the element never reaches the threshold — e.g.
20
+ * it is larger than the viewport, or hidden — the promise resolves
21
+ * anyway so the overlay never hangs. Fail soft.
22
+ */
23
+ timeoutMs?: number;
24
+ }
25
+
26
+ /**
27
+ * Resolve once `element` is at least `threshold` visible in the
28
+ * viewport, or once `timeoutMs` elapses — whichever comes first.
29
+ *
30
+ * Always resolves (never rejects): a never-intersecting element must
31
+ * not stall the overlay. The boolean result reports whether the
32
+ * element became visible (`true`) or the timeout fired (`false`), for
33
+ * callers that want to log it.
34
+ */
35
+ export function waitForVisible(
36
+ element: Element,
37
+ { threshold = 0.5, timeoutMs = 800 }: WaitForVisibleOptions = {},
38
+ ): Promise<boolean> {
39
+ return new Promise((resolve) => {
40
+ // No IntersectionObserver (old/test env) — resolve immediately and
41
+ // let the caller measure as before.
42
+ if (typeof IntersectionObserver === 'undefined') {
43
+ resolve(false);
44
+ return;
45
+ }
46
+
47
+ let settled = false;
48
+ const finish = (visible: boolean) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ window.clearTimeout(timer);
52
+ observer.disconnect();
53
+ resolve(visible);
54
+ };
55
+
56
+ const timer = window.setTimeout(() => finish(false), timeoutMs);
57
+ const observer = new IntersectionObserver(
58
+ (entries) => {
59
+ for (const entry of entries) {
60
+ if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
61
+ finish(true);
62
+ return;
63
+ }
64
+ }
65
+ },
66
+ { threshold },
67
+ );
68
+ observer.observe(element);
69
+ });
70
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Bridge command registry — the named, callable capabilities the chat
5
+ * assistant can run against the user's live page.
6
+ *
7
+ * The registry is the core of the bridge: directives, the dev
8
+ * `window.__chatBridge`, and e2e tests all dispatch the same commands
9
+ * through it, so a new capability is added without touching any caller.
10
+ */
11
+
12
+ /** One bridge command — a named, documented, callable capability. */
13
+ export interface BridgeCommand<A extends unknown[] = unknown[], R = unknown> {
14
+ /** Invocation name, also the `window.__chatBridge` key. */
15
+ name: string;
16
+ /** One-line human description — shown by `__chatBridge.help()`. */
17
+ description: string;
18
+ /** Whether the command mutates the user's data (vs read-only). */
19
+ mutates: boolean;
20
+ /** The implementation. */
21
+ run: (...args: A) => R;
22
+ }
23
+
24
+ /** Every registered command, keyed by name. */
25
+ export const registry = new Map<string, BridgeCommand>();
26
+
27
+ /**
28
+ * Register (or replace) a bridge command. Returns the command so it can
29
+ * be exported directly. Replacing is allowed — handy for HMR.
30
+ */
31
+ export function registerBridgeCommand<A extends unknown[], R>(
32
+ cmd: BridgeCommand<A, R>,
33
+ ): BridgeCommand<A, R> {
34
+ registry.set(cmd.name, cmd as BridgeCommand);
35
+ return cmd;
36
+ }
37
+
38
+ /** Look up a registered command by name. */
39
+ export function getBridgeCommand(name: string): BridgeCommand | undefined {
40
+ return registry.get(name);
41
+ }
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Live connections handed to the bridge by the React layer.
5
+ *
6
+ * The bridge has no React context, so providers publish their
7
+ * capabilities here on mount and retract them on unmount:
8
+ *
9
+ * - the ref→element resolver, owned by `PageSnapshotProvider`;
10
+ * - the chat `sendMessage` function, owned by `ChatProvider`.
11
+ *
12
+ * Bridge commands read them through the getters below.
13
+ */
14
+
15
+ import { type RefResolver } from './overlay/resolveRef';
16
+
17
+ let activeResolver: RefResolver | null = null;
18
+
19
+ /** Called by `PageSnapshotProvider` to publish / retract its resolver. */
20
+ export function setBridgeResolver(resolver: RefResolver | null): void {
21
+ activeResolver = resolver;
22
+ }
23
+
24
+ /** Read the resolver currently published by the provider, if any. */
25
+ export function getActiveResolver(): RefResolver | null {
26
+ return activeResolver;
27
+ }
28
+
29
+ /** Sends a message into the chat — same path as the composer's send. */
30
+ export type BridgeSender = (content: string) => Promise<void> | void;
31
+
32
+ let activeSender: BridgeSender | null = null;
33
+
34
+ /** Called by `ChatProvider` to publish / retract its `sendMessage`. */
35
+ export function setBridgeSender(sender: BridgeSender | null): void {
36
+ activeSender = sender;
37
+ }
38
+
39
+ /** Read the chat sender currently published by the provider, if any. */
40
+ export function getActiveSender(): BridgeSender | null {
41
+ return activeSender;
42
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `window.__chatBridge` — exposes the bridge registry in development so
5
+ * the commands can be driven manually without an AI / SSE round-trip.
6
+ *
7
+ * Run `__chatBridge.highlight(['@e4'])` in the console to see the
8
+ * spotlight directly. A dev affordance, not a public API surface — it
9
+ * is gated on `isDev` and is a no-op on the server and in production.
10
+ */
11
+
12
+ import { isDev } from '@djangocfg/ui-core/lib';
13
+
14
+ import { log } from './logger';
15
+ import { registry } from './registry';
16
+ import {
17
+ highlight,
18
+ focus,
19
+ clear,
20
+ inspect,
21
+ scrollTo,
22
+ fill,
23
+ click,
24
+ sendMessage,
25
+ } from './commands';
26
+
27
+ /** Shape exposed on `window.__chatBridge`. */
28
+ export interface ChatBridge {
29
+ highlight: typeof highlight.run;
30
+ focus: typeof focus.run;
31
+ scrollTo: typeof scrollTo.run;
32
+ clear: typeof clear.run;
33
+ inspect: typeof inspect.run;
34
+ fill: typeof fill.run;
35
+ click: typeof click.run;
36
+ sendMessage: typeof sendMessage.run;
37
+ /** List every registered command with its description. */
38
+ help: () => void;
39
+ }
40
+
41
+ declare global {
42
+ interface Window {
43
+ __chatBridge?: ChatBridge;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Install `window.__chatBridge` for manual testing. No-op on the server
49
+ * and in production (gated on `isDev`) — the bridge is a dev affordance,
50
+ * not a public API surface.
51
+ */
52
+ export function installChatBridge(): void {
53
+ if (typeof window === 'undefined' || !isDev) return;
54
+
55
+ window.__chatBridge = {
56
+ highlight: highlight.run,
57
+ focus: focus.run,
58
+ scrollTo: scrollTo.run,
59
+ clear: clear.run,
60
+ inspect: inspect.run,
61
+ fill: fill.run,
62
+ click: click.run,
63
+ sendMessage: sendMessage.run,
64
+ help: () => {
65
+ // eslint-disable-next-line no-console
66
+ console.table(
67
+ [...registry.values()].map((c) => ({
68
+ command: c.name,
69
+ mutates: c.mutates,
70
+ description: c.description,
71
+ })),
72
+ );
73
+ },
74
+ };
75
+ log.info('window.__chatBridge installed — run __chatBridge.help()');
76
+ }
@@ -17,6 +17,7 @@ import type {
17
17
  CSTRefId,
18
18
  CSTTextNode,
19
19
  } from '../cst/types';
20
+ import { computeLocator, type RefLocator } from '../refs/locator';
20
21
  import { accessibleName } from './accessible-name';
21
22
  import {
22
23
  classify,
@@ -51,9 +52,14 @@ export const noopRedact: RedactValue = (value) => value;
51
52
  export interface WalkResult {
52
53
  /** Captured child nodes (the root node is assembled by the engine). */
53
54
  children: CSTNode[];
54
- /** Map of assigned ref id → element — kept so directives can later
55
- * resolve a CST ref back to the live DOM node. */
56
- refMap: Map<CSTRefId, HTMLElement>;
55
+ /**
56
+ * Map of assigned ref id re-locatable descriptor. A descriptor
57
+ * not a node pointer — because React recreates DOM nodes between
58
+ * capture and directive application; a frozen `HTMLElement` would be
59
+ * detached by then. The descriptor re-finds the element in the live
60
+ * DOM at resolve time.
61
+ */
62
+ refMap: Map<CSTRefId, RefLocator>;
57
63
  /** Number of elements visited. */
58
64
  nodesVisited: number;
59
65
  }
@@ -69,7 +75,7 @@ export interface WalkOptions {
69
75
  /** Mutable state threaded through one walk. */
70
76
  interface WalkState {
71
77
  redact: RedactValue;
72
- refMap: Map<CSTRefId, HTMLElement>;
78
+ refMap: Map<CSTRefId, RefLocator>;
73
79
  refCounter: number;
74
80
  visited: number;
75
81
  maxNodes: number;
@@ -98,7 +104,9 @@ function controlValue(el: Element): string | undefined {
98
104
  function buildInteractive(el: HTMLElement, state: WalkState): CSTInteractiveNode {
99
105
  const role = interactiveRole(el)!;
100
106
  const ref = `@e${state.refCounter++}` as CSTRefId;
101
- state.refMap.set(ref, el);
107
+ // Store a re-locatable descriptor, never the node itself — the node
108
+ // will be recreated by React before the directive arrives.
109
+ state.refMap.set(ref, computeLocator(el, role));
102
110
 
103
111
  const rawValue = controlValue(el);
104
112
  const value =
@@ -58,7 +58,12 @@ export interface CaptureTelemetry {
58
58
  export interface CaptureResult {
59
59
  payload: PageContextPayload;
60
60
  telemetry: CaptureTelemetry;
61
- /** Per-snapshot ref → element map, for resolving `point` directives. */
61
+ /**
62
+ * Per-snapshot ref registry, for resolving `point` directives. Holds
63
+ * re-locatable descriptors (not node pointers) so a ref still
64
+ * resolves after React re-renders the page between capture and
65
+ * directive application.
66
+ */
62
67
  refs: RefRegistry;
63
68
  }
64
69
 
@@ -114,9 +119,9 @@ export class PageSnapshotEngine {
114
119
  const result = walkDOM(root, { redactValue });
115
120
  walked = walked.concat(result.children);
116
121
  nodesVisited += result.nodesVisited;
117
- // Carry the walk's refs into the snapshot registry.
118
- for (const [ref, el] of result.refMap) {
119
- refs.set(ref as CSTRefId, el);
122
+ // Carry the walk's ref locators into the snapshot registry.
123
+ for (const [ref, locator] of result.refMap) {
124
+ refs.set(ref as CSTRefId, locator);
120
125
  }
121
126
  }
122
127
 
@@ -63,6 +63,11 @@ export {
63
63
 
64
64
  // Refs.
65
65
  export { RefRegistry } from './refs/registry';
66
+ export {
67
+ computeLocator,
68
+ resolveLocator,
69
+ type RefLocator,
70
+ } from './refs/locator';
66
71
 
67
72
  // Utilities.
68
73
  export { estimateTokens } from './tokens';