@djangocfg/ui-tools 2.1.409 → 2.1.411
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/package.json +13 -13
- package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
- package/src/lib/browser-bridge/commands/chat.ts +42 -0
- package/src/lib/browser-bridge/commands/highlight.ts +70 -0
- package/src/lib/browser-bridge/commands/index.ts +15 -0
- package/src/lib/browser-bridge/commands/inspect.ts +31 -0
- package/src/lib/browser-bridge/commands/scroll.ts +31 -0
- package/src/lib/browser-bridge/commands/write.ts +45 -0
- package/src/lib/browser-bridge/directive-bus.ts +120 -0
- package/src/lib/browser-bridge/index.ts +56 -0
- package/src/lib/browser-bridge/logger.ts +27 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
- package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
- package/src/lib/browser-bridge/registry.ts +41 -0
- package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
- package/src/lib/browser-bridge/window.ts +76 -0
- package/src/lib/page-snapshot/capture/walk.ts +13 -5
- package/src/lib/page-snapshot/engine.ts +9 -4
- package/src/lib/page-snapshot/index.ts +5 -0
- package/src/lib/page-snapshot/react/provider.tsx +70 -3
- package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
- package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
- package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
- package/src/lib/page-snapshot/refs/locator.ts +218 -0
- package/src/lib/page-snapshot/refs/registry.ts +29 -14
- package/src/tools/Chat/README.md +1 -1
- package/src/tools/Chat/constants.ts +24 -1
- package/src/tools/Chat/context/ChatProvider.tsx +17 -2
- package/src/tools/Chat/core/logger.ts +15 -2
- package/src/tools/Chat/index.ts +34 -2
- package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
- package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
- package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
- package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
- package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
- package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
- package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
- package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
- package/src/tools/Chat/lazy.tsx +34 -2
- package/src/tools/Chat/public.ts +16 -0
- package/src/tools/Chat/settings/README.md +87 -0
- package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
- package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
- package/src/tools/Chat/settings/index.ts +23 -0
- package/src/tools/Chat/settings/types.ts +108 -0
- package/src/tools/Chat/settings/useChatSettings.ts +168 -0
- /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
- /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
|
|
12
12
|
import { useLocationProperty } from '@djangocfg/ui-core/hooks';
|
|
13
13
|
|
|
14
|
+
import {
|
|
15
|
+
installChatBridge,
|
|
16
|
+
setBridgeResolver,
|
|
17
|
+
} from '../../browser-bridge';
|
|
18
|
+
import { useChatSettings } from '../../../tools/Chat/settings';
|
|
19
|
+
|
|
14
20
|
import type { CaptureEngineOptions, CaptureResult } from '../engine';
|
|
15
21
|
import { PageSnapshotEngine } from '../engine';
|
|
16
22
|
import {
|
|
@@ -23,10 +29,17 @@ export interface PageSnapshotProviderProps {
|
|
|
23
29
|
/** Engine configuration. */
|
|
24
30
|
config?: CaptureEngineOptions;
|
|
25
31
|
/**
|
|
26
|
-
*
|
|
27
|
-
* sharing page context.
|
|
32
|
+
* Opt-in value used when nothing is stored yet. Default false — the
|
|
33
|
+
* user must explicitly enable sharing page context. Once the user
|
|
34
|
+
* toggles it, the choice is persisted and this is ignored.
|
|
28
35
|
*/
|
|
29
36
|
defaultLinked?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* localStorage key for the centralized chat-settings object that holds
|
|
39
|
+
* the opt-in (along with the rest of the chat's persisted settings).
|
|
40
|
+
* Override to scope the chat settings per app / per chat.
|
|
41
|
+
*/
|
|
42
|
+
storageKey?: string;
|
|
30
43
|
children: ReactNode;
|
|
31
44
|
}
|
|
32
45
|
|
|
@@ -43,12 +56,42 @@ export interface PageSnapshotProviderProps {
|
|
|
43
56
|
export function PageSnapshotProvider({
|
|
44
57
|
config,
|
|
45
58
|
defaultLinked = false,
|
|
59
|
+
storageKey,
|
|
46
60
|
children,
|
|
47
61
|
}: PageSnapshotProviderProps) {
|
|
48
|
-
|
|
62
|
+
// The opt-in choice persists across sessions — the user enables screen
|
|
63
|
+
// sharing once and it is remembered. It now lives in the centralized
|
|
64
|
+
// chat-settings object (`ChatSettings.pageContext.linked`) instead of a
|
|
65
|
+
// standalone key, so the chat owns all its persisted state in one place.
|
|
66
|
+
const settingsDefaults = useMemo(
|
|
67
|
+
() => ({ pageContext: { linked: defaultLinked } }),
|
|
68
|
+
[defaultLinked],
|
|
69
|
+
);
|
|
70
|
+
const { settings, setPageContextLinked } = useChatSettings({
|
|
71
|
+
storageKey,
|
|
72
|
+
defaults: settingsDefaults,
|
|
73
|
+
});
|
|
74
|
+
const isLinked = settings.pageContext.linked;
|
|
75
|
+
const setIsLinked = setPageContextLinked;
|
|
49
76
|
const [isStale, setIsStale] = useState(false);
|
|
50
77
|
const [lastSnapshot, setLastSnapshot] = useState<CaptureResult | null>(null);
|
|
51
78
|
|
|
79
|
+
// The snapshot that actually rode the most recent chat message.
|
|
80
|
+
//
|
|
81
|
+
// CST ref ids (`@eN`) are positional — assigned by DOM-order traversal
|
|
82
|
+
// at capture time — so they are NOT stable across captures: `@e22` of
|
|
83
|
+
// one snapshot need not be `@e22` of another. A `point` directive the
|
|
84
|
+
// assistant returns cites refs from the snapshot it was given. To
|
|
85
|
+
// resolve that directive correctly the overlay must use the registry
|
|
86
|
+
// of *that* snapshot, not a newer one.
|
|
87
|
+
//
|
|
88
|
+
// `lastSnapshot` tracks the live page and is overwritten by every
|
|
89
|
+
// route-change auto-capture, so it cannot be trusted for directive
|
|
90
|
+
// resolution. This state holds the sent snapshot and is only ever
|
|
91
|
+
// replaced on the next send — it survives route changes that happen
|
|
92
|
+
// while a reply (and its directives) is still streaming in.
|
|
93
|
+
const [sentSnapshot, setSentSnapshot] = useState<CaptureResult | null>(null);
|
|
94
|
+
|
|
52
95
|
// Engine is browser-only. Construct it once, lazily, after mount —
|
|
53
96
|
// a ref (not state) since it never needs to trigger a re-render.
|
|
54
97
|
const engineRef = useRef<PageSnapshotEngine | null>(null);
|
|
@@ -112,6 +155,10 @@ export function PageSnapshotProvider({
|
|
|
112
155
|
if (!isLinked) return undefined;
|
|
113
156
|
const result = capture();
|
|
114
157
|
if (!result) return undefined;
|
|
158
|
+
// This is the snapshot the assistant will see — pin it so any
|
|
159
|
+
// `point` directive in the reply resolves against the exact ref
|
|
160
|
+
// registry that produced the refs the assistant cited.
|
|
161
|
+
setSentSnapshot(result);
|
|
115
162
|
return { pageContext: result.payload };
|
|
116
163
|
}, [isLinked, capture]);
|
|
117
164
|
|
|
@@ -132,6 +179,24 @@ export function PageSnapshotProvider({
|
|
|
132
179
|
// `pathname` in deps: a new route re-captures automatically.
|
|
133
180
|
}, [isLinked, pathname, runCapture]);
|
|
134
181
|
|
|
182
|
+
// Install the dev-only `window.__chatBridge` once — manual testing of
|
|
183
|
+
// highlight / focus commands without an AI round-trip.
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
installChatBridge();
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
// Keep the bridge's ref resolver pointed at the latest snapshot, so
|
|
189
|
+
// console-driven `window.__chatBridge.highlight()` commands resolve
|
|
190
|
+
// refs against the current page. This is intentionally the *latest*
|
|
191
|
+
// snapshot, not `sentSnapshot`: manual testing highlights what is on
|
|
192
|
+
// screen now. AI-directive resolution is a separate path — it uses
|
|
193
|
+
// `sentSnapshot` (see `DirectiveOverlay`) so it stays pinned to the
|
|
194
|
+
// registry the assistant actually saw.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
setBridgeResolver(lastSnapshot?.refs ?? null);
|
|
197
|
+
return () => setBridgeResolver(null);
|
|
198
|
+
}, [lastSnapshot]);
|
|
199
|
+
|
|
135
200
|
const value = useMemo<PageSnapshotContextValue>(
|
|
136
201
|
() => ({
|
|
137
202
|
isLinked,
|
|
@@ -140,6 +205,7 @@ export function PageSnapshotProvider({
|
|
|
140
205
|
generatePreview,
|
|
141
206
|
getChatMetadata,
|
|
142
207
|
lastSnapshot,
|
|
208
|
+
sentSnapshot,
|
|
143
209
|
isStale,
|
|
144
210
|
refresh,
|
|
145
211
|
}),
|
|
@@ -149,6 +215,7 @@ export function PageSnapshotProvider({
|
|
|
149
215
|
generatePreview,
|
|
150
216
|
getChatMetadata,
|
|
151
217
|
lastSnapshot,
|
|
218
|
+
sentSnapshot,
|
|
152
219
|
isStale,
|
|
153
220
|
refresh,
|
|
154
221
|
],
|
|
@@ -40,6 +40,16 @@ export interface PageSnapshotContextValue {
|
|
|
40
40
|
/** The most recent captured snapshot, if any. */
|
|
41
41
|
lastSnapshot: CaptureResult | null;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The snapshot that rode the most recent chat message — the one the
|
|
45
|
+
* assistant was given. CST ref ids are positional, so a `point`
|
|
46
|
+
* directive in the reply must be resolved against *this* snapshot's
|
|
47
|
+
* ref registry, never `lastSnapshot` (which the route-change
|
|
48
|
+
* auto-capture overwrites) and never a fresh capture (which re-numbers
|
|
49
|
+
* refs). Null until the first message is sent.
|
|
50
|
+
*/
|
|
51
|
+
sentSnapshot: CaptureResult | null;
|
|
52
|
+
|
|
43
53
|
/** Whether the captured context is stale vs the current page. */
|
|
44
54
|
isStale: boolean;
|
|
45
55
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { computeLocator, resolveLocator } from '../locator';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.body.innerHTML = '';
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('computeLocator', () => {
|
|
11
|
+
it('builds an id selector when the element has a unique id', () => {
|
|
12
|
+
const el = document.createElement('button');
|
|
13
|
+
el.id = 'submit-btn';
|
|
14
|
+
el.textContent = 'Submit';
|
|
15
|
+
document.body.appendChild(el);
|
|
16
|
+
|
|
17
|
+
const locator = computeLocator(el, 'button');
|
|
18
|
+
expect(locator.selector).toBe('#submit-btn');
|
|
19
|
+
expect(locator.role).toBe('button');
|
|
20
|
+
expect(locator.name).toBe('Submit');
|
|
21
|
+
expect(locator.tag).toBe('button');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('prefers data-testid over a structural path', () => {
|
|
25
|
+
const el = document.createElement('a');
|
|
26
|
+
el.setAttribute('data-testid', 'nav-home');
|
|
27
|
+
el.textContent = 'Home';
|
|
28
|
+
document.body.appendChild(el);
|
|
29
|
+
|
|
30
|
+
expect(computeLocator(el, 'link').selector).toBe('a[data-testid="nav-home"]');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('falls back to a structural path when no stable attribute exists', () => {
|
|
34
|
+
document.body.innerHTML = '<section><div></div><a>One</a><a>Two</a></section>';
|
|
35
|
+
const second = document.body.querySelectorAll('a')[1] as HTMLElement;
|
|
36
|
+
|
|
37
|
+
const locator = computeLocator(second, 'link');
|
|
38
|
+
expect(locator.selector).toContain('nth-of-type');
|
|
39
|
+
// The structural path must re-find the same element.
|
|
40
|
+
expect(document.querySelector(locator.selector)).toBe(second);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('resolveLocator', () => {
|
|
45
|
+
it('resolves via selector when structure is intact', () => {
|
|
46
|
+
const el = document.createElement('button');
|
|
47
|
+
el.id = 'go';
|
|
48
|
+
el.textContent = 'Go';
|
|
49
|
+
document.body.appendChild(el);
|
|
50
|
+
|
|
51
|
+
const locator = computeLocator(el, 'button');
|
|
52
|
+
expect(resolveLocator(locator)).toBe(el);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('resolves by role + name when the selector breaks', () => {
|
|
56
|
+
// Capture with a structural path (no stable attribute).
|
|
57
|
+
document.body.innerHTML = '<div><a>Browse Catalog</a></div>';
|
|
58
|
+
const original = document.body.querySelector('a') as HTMLElement;
|
|
59
|
+
const locator = computeLocator(original, 'link');
|
|
60
|
+
|
|
61
|
+
// Re-render shifts the element: extra wrapper + sibling inserted, so
|
|
62
|
+
// the captured nth-of-type path no longer matches it.
|
|
63
|
+
document.body.innerHTML =
|
|
64
|
+
'<header><nav><span>x</span><a>Browse Catalog</a></nav></header>';
|
|
65
|
+
|
|
66
|
+
const resolved = resolveLocator(locator);
|
|
67
|
+
expect(resolved).not.toBeNull();
|
|
68
|
+
expect(resolved?.textContent).toBe('Browse Catalog');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns null when the element is genuinely gone', () => {
|
|
72
|
+
const el = document.createElement('a');
|
|
73
|
+
el.textContent = 'Vanishing Link';
|
|
74
|
+
document.body.appendChild(el);
|
|
75
|
+
const locator = computeLocator(el, 'link');
|
|
76
|
+
|
|
77
|
+
document.body.innerHTML = '';
|
|
78
|
+
expect(resolveLocator(locator)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('matches an ARIA-roled element by role + name', () => {
|
|
82
|
+
document.body.innerHTML =
|
|
83
|
+
'<div role="button" aria-label="Open menu">≡</div>';
|
|
84
|
+
const original = document.body.firstElementChild as HTMLElement;
|
|
85
|
+
const locator = computeLocator(original, 'button');
|
|
86
|
+
|
|
87
|
+
// Recreate the node — same role + accessible name, new identity.
|
|
88
|
+
document.body.innerHTML =
|
|
89
|
+
'<span></span><div role="button" aria-label="Open menu">≡</div>';
|
|
90
|
+
const fresh = document.body.querySelectorAll('[role="button"]')[0];
|
|
91
|
+
|
|
92
|
+
expect(resolveLocator(locator)).toBe(fresh);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
|
+
import { computeLocator } from '../locator';
|
|
4
5
|
import { RefRegistry } from '../registry';
|
|
5
6
|
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
document.body.innerHTML = '';
|
|
9
|
+
});
|
|
10
|
+
|
|
6
11
|
describe('RefRegistry', () => {
|
|
7
12
|
it('reports unknown refs as unresolved', () => {
|
|
8
13
|
const reg = new RefRegistry('snap-1');
|
|
@@ -13,12 +18,63 @@ describe('RefRegistry', () => {
|
|
|
13
18
|
});
|
|
14
19
|
|
|
15
20
|
it('resolves a registered ref to its element', () => {
|
|
16
|
-
const reg = new RefRegistry('snap-1');
|
|
17
21
|
const el = document.createElement('button');
|
|
18
|
-
|
|
22
|
+
el.textContent = 'Save';
|
|
23
|
+
document.body.appendChild(el);
|
|
24
|
+
|
|
25
|
+
const reg = new RefRegistry('snap-1');
|
|
26
|
+
reg.set('@e1', computeLocator(el, 'button'));
|
|
19
27
|
|
|
20
28
|
expect(reg.has('@e1')).toBe(true);
|
|
21
29
|
expect(reg.resolve('@e1')).toBe(el);
|
|
22
30
|
expect(reg.size).toBe(1);
|
|
23
31
|
});
|
|
32
|
+
|
|
33
|
+
it('resolves after the original node is replaced by a re-render', () => {
|
|
34
|
+
// Capture against the original node.
|
|
35
|
+
const original = document.createElement('a');
|
|
36
|
+
original.id = 'browse';
|
|
37
|
+
original.textContent = 'Browse Catalog';
|
|
38
|
+
document.body.appendChild(original);
|
|
39
|
+
|
|
40
|
+
const reg = new RefRegistry('snap-1');
|
|
41
|
+
reg.set('@e1', computeLocator(original, 'link'));
|
|
42
|
+
|
|
43
|
+
// React re-render: the node is recreated as a fresh, equivalent one.
|
|
44
|
+
original.remove();
|
|
45
|
+
const fresh = document.createElement('a');
|
|
46
|
+
fresh.id = 'browse';
|
|
47
|
+
fresh.textContent = 'Browse Catalog';
|
|
48
|
+
document.body.appendChild(fresh);
|
|
49
|
+
|
|
50
|
+
const resolved = reg.resolve('@e1');
|
|
51
|
+
expect(resolved).toBe(fresh);
|
|
52
|
+
expect(resolved).not.toBe(original);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null when the element is genuinely gone', () => {
|
|
56
|
+
const el = document.createElement('button');
|
|
57
|
+
el.textContent = 'Delete';
|
|
58
|
+
document.body.appendChild(el);
|
|
59
|
+
|
|
60
|
+
const reg = new RefRegistry('snap-1');
|
|
61
|
+
reg.set('@e1', computeLocator(el, 'button'));
|
|
62
|
+
|
|
63
|
+
el.remove();
|
|
64
|
+
expect(reg.resolve('@e1')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('exposes the stored locator for diagnostics', () => {
|
|
68
|
+
const el = document.createElement('a');
|
|
69
|
+
el.textContent = 'Home';
|
|
70
|
+
document.body.appendChild(el);
|
|
71
|
+
|
|
72
|
+
const reg = new RefRegistry('snap-1');
|
|
73
|
+
reg.set('@e1', computeLocator(el, 'link'));
|
|
74
|
+
|
|
75
|
+
const locator = reg.getLocator('@e1');
|
|
76
|
+
expect(locator?.role).toBe('link');
|
|
77
|
+
expect(locator?.name).toBe('Home');
|
|
78
|
+
expect(reg.getLocator('@e9')).toBeUndefined();
|
|
79
|
+
});
|
|
24
80
|
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-locatable ref descriptors.
|
|
3
|
+
*
|
|
4
|
+
* A snapshot is captured when a chat message is sent; the AI's
|
|
5
|
+
* `point` directive arrives seconds later. In between, React re-renders
|
|
6
|
+
* the page — the chat panel expands, content streams in, lists update —
|
|
7
|
+
* and React recreates DOM nodes. Any `HTMLElement` reference frozen at
|
|
8
|
+
* capture time is then detached (`isConnected === false`) and useless.
|
|
9
|
+
*
|
|
10
|
+
* So a ref must NOT be stored as a node pointer. It is stored as a
|
|
11
|
+
* `RefLocator`: a description rich enough to FIND the element again in
|
|
12
|
+
* whatever DOM exists at resolve time. `resolveLocator` runs a live
|
|
13
|
+
* query against the current `document` — it never dereferences a stale
|
|
14
|
+
* pointer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { accessibleName } from '../capture/accessible-name';
|
|
18
|
+
import { tagName } from '../capture/dom-utils';
|
|
19
|
+
import type { CSTInteractiveRole } from '../cst/types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A description that can re-find an interactive element in the live DOM.
|
|
23
|
+
*
|
|
24
|
+
* `role` + `name` is the primary locator: the AI cited an element by the
|
|
25
|
+
* accessible name the CST gave it, so "the visible `link` named 'Browse
|
|
26
|
+
* Catalog'" survives node-identity changes and even position shifts.
|
|
27
|
+
* `selector` is a structural tiebreaker used to disambiguate when
|
|
28
|
+
* several elements share the same role + name.
|
|
29
|
+
*/
|
|
30
|
+
export interface RefLocator {
|
|
31
|
+
/** Interactive role, as classified by the CST walk. */
|
|
32
|
+
role: CSTInteractiveRole;
|
|
33
|
+
/** Accessible name — the label the AI was shown for this element. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** lowercased tag name, used to filter candidates. */
|
|
36
|
+
tag: string;
|
|
37
|
+
/**
|
|
38
|
+
* A CSS selector that re-finds the element. Built from a stable
|
|
39
|
+
* attribute (`id` / `data-testid` / `name`) when one exists, else a
|
|
40
|
+
* structural `nth-of-type` path from the nearest id'd ancestor or
|
|
41
|
+
* `<body>`. A tiebreaker, not the source of truth — React can
|
|
42
|
+
* recreate nodes and shift positions, breaking the path.
|
|
43
|
+
*/
|
|
44
|
+
selector: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Attributes that, when present, give a stable unique-ish selector. */
|
|
48
|
+
const STABLE_ATTRS = ['data-testid', 'id', 'name'] as const;
|
|
49
|
+
|
|
50
|
+
/** True when a selector matches exactly one element in `document`. */
|
|
51
|
+
function isUnique(selector: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
return document.querySelectorAll(selector).length === 1;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Escape a value for use inside a CSS attribute selector. */
|
|
60
|
+
function cssEscape(value: string): string {
|
|
61
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
62
|
+
return CSS.escape(value);
|
|
63
|
+
}
|
|
64
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a structural `nth-of-type` path from the nearest id'd ancestor
|
|
69
|
+
* (or `<body>`) down to `el`. Survives re-renders that recreate nodes
|
|
70
|
+
* but keep the same tag structure and ordering — the common React case.
|
|
71
|
+
*/
|
|
72
|
+
function structuralPath(el: HTMLElement): string {
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
let node: Element | null = el;
|
|
75
|
+
while (node && node !== document.body && node !== document.documentElement) {
|
|
76
|
+
const id = node.getAttribute('id');
|
|
77
|
+
if (id) {
|
|
78
|
+
parts.unshift(`#${cssEscape(id)}`);
|
|
79
|
+
return parts.join(' > ');
|
|
80
|
+
}
|
|
81
|
+
const tag = tagName(node);
|
|
82
|
+
const parent: Element | null = node.parentElement;
|
|
83
|
+
if (!parent) {
|
|
84
|
+
parts.unshift(tag);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
let index = 1;
|
|
88
|
+
for (const sib of Array.from(parent.children)) {
|
|
89
|
+
if (sib === node) break;
|
|
90
|
+
if (tagName(sib) === tag) index += 1;
|
|
91
|
+
}
|
|
92
|
+
parts.unshift(`${tag}:nth-of-type(${index})`);
|
|
93
|
+
node = parent;
|
|
94
|
+
}
|
|
95
|
+
return parts.length ? `body > ${parts.join(' > ')}` : 'body';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compute a re-locatable descriptor for an interactive element at
|
|
100
|
+
* capture time. `role` is already known from classification; `name` is
|
|
101
|
+
* derived with the same accessible-name logic the CST walk uses, so the
|
|
102
|
+
* locator's name matches exactly what the AI was shown.
|
|
103
|
+
*/
|
|
104
|
+
export function computeLocator(
|
|
105
|
+
el: HTMLElement,
|
|
106
|
+
role: CSTInteractiveRole,
|
|
107
|
+
): RefLocator {
|
|
108
|
+
let selector = '';
|
|
109
|
+
for (const attr of STABLE_ATTRS) {
|
|
110
|
+
const raw = el.getAttribute(attr);
|
|
111
|
+
if (!raw) continue;
|
|
112
|
+
const candidate =
|
|
113
|
+
attr === 'id'
|
|
114
|
+
? `#${cssEscape(raw)}`
|
|
115
|
+
: `${tagName(el)}[${attr}="${cssEscape(raw)}"]`;
|
|
116
|
+
if (isUnique(candidate)) {
|
|
117
|
+
selector = candidate;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!selector) selector = structuralPath(el);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
role,
|
|
125
|
+
name: accessibleName(el),
|
|
126
|
+
tag: tagName(el),
|
|
127
|
+
selector,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** True when an element is rendered (has layout) — prefers visible hits. */
|
|
132
|
+
function isRendered(el: HTMLElement): boolean {
|
|
133
|
+
if (!el.isConnected) return false;
|
|
134
|
+
// offsetParent is null for display:none; width/height covers fixed
|
|
135
|
+
// and body-level elements that legitimately have no offsetParent.
|
|
136
|
+
return (
|
|
137
|
+
el.offsetParent !== null ||
|
|
138
|
+
el.getClientRects().length > 0
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Find a connected element matching a locator against the live DOM.
|
|
144
|
+
*
|
|
145
|
+
* Resolution order:
|
|
146
|
+
* 1. the structural / attribute selector — if it yields exactly one
|
|
147
|
+
* connected element, trust it;
|
|
148
|
+
* 2. the role + accessible name — scan candidate tags, match the live
|
|
149
|
+
* accessible name, prefer a rendered element. This is the resilient
|
|
150
|
+
* path: it does not depend on node identity or position.
|
|
151
|
+
* 3. give up — the element is genuinely gone (stale ref).
|
|
152
|
+
*/
|
|
153
|
+
export function resolveLocator(locator: RefLocator): HTMLElement | null {
|
|
154
|
+
// 1. Selector — a precise hit when structure held.
|
|
155
|
+
try {
|
|
156
|
+
const hits = document.querySelectorAll<HTMLElement>(locator.selector);
|
|
157
|
+
if (hits.length === 1 && hits[0].isConnected) return hits[0];
|
|
158
|
+
} catch {
|
|
159
|
+
// Malformed selector — fall through to the role+name match.
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. Role + accessible name — robust to node recreation.
|
|
163
|
+
const candidates = collectByRoleName(locator);
|
|
164
|
+
if (candidates.length === 0) return null;
|
|
165
|
+
return candidates.find(isRendered) ?? candidates[0];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Tags worth scanning for a given role when matching by role + name. */
|
|
169
|
+
function tagsForRole(role: CSTInteractiveRole): string[] {
|
|
170
|
+
switch (role) {
|
|
171
|
+
case 'link':
|
|
172
|
+
return ['a'];
|
|
173
|
+
case 'button':
|
|
174
|
+
return ['button', 'a', 'input'];
|
|
175
|
+
case 'textbox':
|
|
176
|
+
case 'searchbox':
|
|
177
|
+
return ['input', 'textarea'];
|
|
178
|
+
case 'checkbox':
|
|
179
|
+
case 'radio':
|
|
180
|
+
case 'slider':
|
|
181
|
+
case 'spinbutton':
|
|
182
|
+
return ['input'];
|
|
183
|
+
case 'select':
|
|
184
|
+
case 'combobox':
|
|
185
|
+
return ['select'];
|
|
186
|
+
case 'textarea':
|
|
187
|
+
return ['textarea'];
|
|
188
|
+
default:
|
|
189
|
+
return ['button', 'a', 'input'];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Collect connected elements whose role + accessible name match the
|
|
195
|
+
* locator. Also scans `[role=...]` so ARIA-roled elements are found.
|
|
196
|
+
*/
|
|
197
|
+
function collectByRoleName(locator: RefLocator): HTMLElement[] {
|
|
198
|
+
const seen = new Set<HTMLElement>();
|
|
199
|
+
const selectors = [
|
|
200
|
+
...tagsForRole(locator.role),
|
|
201
|
+
`[role="${locator.role}"]`,
|
|
202
|
+
];
|
|
203
|
+
const out: HTMLElement[] = [];
|
|
204
|
+
for (const sel of selectors) {
|
|
205
|
+
let nodes: NodeListOf<HTMLElement>;
|
|
206
|
+
try {
|
|
207
|
+
nodes = document.querySelectorAll<HTMLElement>(sel);
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
for (const node of Array.from(nodes)) {
|
|
212
|
+
if (seen.has(node) || !node.isConnected) continue;
|
|
213
|
+
seen.add(node);
|
|
214
|
+
if (accessibleName(node) === locator.name) out.push(node);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
@@ -1,37 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ref registry — maps CST ref ids (`@e4`)
|
|
2
|
+
* Ref registry — maps CST ref ids (`@e4`) to a way of re-finding the
|
|
3
|
+
* element in the live DOM.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The registry deliberately does NOT hold `HTMLElement` references.
|
|
6
|
+
* Between snapshot capture (chat-message send) and directive
|
|
7
|
+
* application (the `directive` SSE event, seconds later), React
|
|
8
|
+
* re-renders the page and recreates DOM nodes — any frozen node pointer
|
|
9
|
+
* is detached by then and resolves to nothing. Instead each ref stores
|
|
10
|
+
* a `RefLocator` and `resolve()` runs a live-DOM query at call time.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import type { CSTRefId } from '../cst/types';
|
|
14
|
+
import { resolveLocator, type RefLocator } from './locator';
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
|
-
* A per-snapshot ref →
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* A per-snapshot `ref → RefLocator` map. Locators are inert data, so
|
|
18
|
+
* the registry stays valid across re-renders — `resolve()` re-queries
|
|
19
|
+
* the current DOM every call rather than dereferencing a stale node.
|
|
16
20
|
*/
|
|
17
21
|
export class RefRegistry {
|
|
18
22
|
/** Unique id of the snapshot these refs belong to. */
|
|
19
23
|
readonly snapshotId: string;
|
|
20
24
|
|
|
21
|
-
private readonly map = new Map<CSTRefId,
|
|
25
|
+
private readonly map = new Map<CSTRefId, RefLocator>();
|
|
22
26
|
|
|
23
27
|
constructor(snapshotId: string) {
|
|
24
28
|
this.snapshotId = snapshotId;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
/** Register a ref →
|
|
28
|
-
set(ref: CSTRefId,
|
|
29
|
-
this.map.set(ref,
|
|
31
|
+
/** Register a ref → locator pair (called during the walk). */
|
|
32
|
+
set(ref: CSTRefId, locator: RefLocator): void {
|
|
33
|
+
this.map.set(ref, locator);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a ref to a live element, or null if it cannot be found in
|
|
38
|
+
* the current DOM (stale). Runs a fresh query every call — safe to
|
|
39
|
+
* call long after capture, across any number of re-renders.
|
|
40
|
+
*/
|
|
33
41
|
resolve(ref: CSTRefId): HTMLElement | null {
|
|
34
|
-
|
|
42
|
+
const locator = this.map.get(ref);
|
|
43
|
+
if (!locator) return null;
|
|
44
|
+
return resolveLocator(locator);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The stored locator for a ref, if known (mainly for diagnostics). */
|
|
48
|
+
getLocator(ref: CSTRefId): RefLocator | undefined {
|
|
49
|
+
return this.map.get(ref);
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
/** Whether a ref is known to this snapshot (regardless of liveness). */
|
package/src/tools/Chat/README.md
CHANGED
|
@@ -57,7 +57,7 @@ export function MyChat() {
|
|
|
57
57
|
- **Language flag button.** `headerSlots.languagePicker: true` slots a 28×28 country flag into the dock header — opens a searchable `<Combobox>` with 66 BCP-47 tags from the Chrome Web Speech catalogue. Selection persists via `useSpeechPrefs`, picked up by every `useSpeechRecognition` downstream. (Raw `<ChatHeaderLanguageButton>` still exported for custom shells.)
|
|
58
58
|
- **Auto-focus on stream end.** `<ChatProvider>` re-focuses the registered composer on the streaming → idle edge — type → send → read → keep typing without reaching for the mouse. Works for **every** usage pattern (`ChatRoot`, hand-rolled `ChatProvider` + `Composer`, headless), not just `ChatRoot`. Opt out with `<ChatProvider autoFocusOnStreamEnd={false}>`. The standalone `useAutoFocusOnStreamEnd()` hook is still exported for advanced cases (focus a non-composer target, drive `isStreaming` from your own store).
|
|
59
59
|
- **Page-context snapshot.** Optional `getDynamicMetadata` contributor on `<ChatProvider>` / `<ChatRoot>` — called fresh at send time, merged into transport `metadata`. Pairs with the `page-snapshot` engine (`src/lib/page-snapshot`) to attach a token-efficient, redacted snapshot of the page the user is looking at, so the assistant can answer in context. The snapshot rides a separate `metadata` field, never the message text.
|
|
60
|
-
- **AI
|
|
60
|
+
- **AI bridge directives.** `bridge/` — when the assistant returns `point` directives, `<HighlightOverlay>` resolves each CST ref to a live element and draws an SVG-mask spotlight (optionally moves focus). The bridge also exposes a command registry the AI drives the page through. Read-only by default: it points at the UI, never changes data. See [`bridge/README.md`](./bridge/README.md).
|
|
61
61
|
- **Centralized colors.** Role-aware className tokens (`BUBBLE_SURFACE` / `ANCHOR` / `TOGGLE` / `DESTRUCTIVE_SURFACE`) + hooks (`useChatBubbleStyles`, `useChatRoleStyles`, `useChatDestructiveStyles`).
|
|
62
62
|
- **Responsive.** FAB `size='responsive'` (default): phone → `sm`, tablet → `md`, desktop → `lg`. Side mode is desktop-only and falls back to popover below `lg`.
|
|
63
63
|
- **Mobile fullscreen.** Dock auto-fills viewport below 768px via `useIsMobile`. Heights use `dvh/svh/lvh` so iOS Safari URL bar doesn't clip the chat.
|
|
@@ -12,7 +12,30 @@ export const CSS_VARS = {
|
|
|
12
12
|
reserve: '--djc-chat-reserve',
|
|
13
13
|
} as const;
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Z-index tier for the floating chat surface.
|
|
17
|
+
*
|
|
18
|
+
* The chat dock is page furniture — a peer of page content, not a modal.
|
|
19
|
+
* It must sit ABOVE the page yet BELOW every `@djangocfg/ui-core` overlay
|
|
20
|
+
* (sheet 200, drawer 500, dialog 600, anchored overlays 700) so that any
|
|
21
|
+
* dialog — including one launched from inside the chat — always wins.
|
|
22
|
+
*
|
|
23
|
+
* dock 100 the chat surface
|
|
24
|
+
* companion 99 FAB / greeting / unread preview (behind the open dock)
|
|
25
|
+
* tooltip 101 chat-header tooltips (just above the dock, still
|
|
26
|
+
* below ui-core's modal/overlay tiers)
|
|
27
|
+
*/
|
|
28
|
+
export const Z_INDEX = {
|
|
29
|
+
/** The chat dock surface. */
|
|
30
|
+
dock: 100,
|
|
31
|
+
/** FAB, greeting bubble and unread preview — sit just below the dock. */
|
|
32
|
+
companion: 99,
|
|
33
|
+
/** Tooltips portaled out of the chat header — just above the dock. */
|
|
34
|
+
tooltip: 101,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** @deprecated Use {@link Z_INDEX.dock}. Kept for the public API surface. */
|
|
38
|
+
export const DEFAULT_Z_INDEX = Z_INDEX.dock;
|
|
16
39
|
|
|
17
40
|
export const LIMITS = {
|
|
18
41
|
/** Max characters per single message. */
|