@mlx-bun/module-chat 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +85 -2
- package/package.json +39 -4
- package/src/app-tools.ts +101 -0
- package/src/backend.ts +108 -0
- package/src/describe.ts +47 -0
- package/src/events.ts +75 -0
- package/src/extensions.ts +44 -0
- package/src/history.ts +188 -0
- package/src/index.ts +74 -0
- package/src/manifest.ts +26 -0
- package/src/model-bridge.ts +75 -0
- package/src/panel/adapters-panel.ts +287 -0
- package/src/panel/assistant.ts +469 -0
- package/src/panel/chat.ts +1076 -0
- package/src/panel/composer.ts +947 -0
- package/src/panel/dom.ts +94 -0
- package/src/panel/host.ts +28 -0
- package/src/panel/index.ts +100 -0
- package/src/panel/markdown.ts +547 -0
- package/src/panel/popovers.ts +17 -0
- package/src/panel/rag.ts +274 -0
- package/src/panel/sessions.ts +470 -0
- package/src/panel/style.ts +532 -0
- package/src/panel/template.ts +189 -0
- package/src/panel/ui-catalog.ts +77 -0
- package/src/panel/voice.ts +141 -0
- package/src/pi-backend.ts +989 -0
- package/src/policy.ts +417 -0
- package/src/protocol.ts +265 -0
- package/src/provider.ts +72 -0
- package/src/routes.ts +49 -0
- package/src/session-files.ts +36 -0
- package/src/session-search.ts +175 -0
- package/src/surface.ts +26 -0
- package/src/tool-approvals.ts +108 -0
- package/src/web-tools.ts +478 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import type { UiSnapshotElement, UiSnapshot, WizardStep } from "../protocol";
|
|
2
|
+
export type { UiSnapshotElement, UiSnapshot, WizardStep } from "../protocol";
|
|
3
|
+
// Part of the chat panel (`@mlx-bun/module-chat`): the app-aware assistant, bundled into the app's browser build.
|
|
4
|
+
//
|
|
5
|
+
// Three pieces, mirroring the proven PortfolioManager reference
|
|
6
|
+
// (client/src/lib/{ui-snapshot,spotlight,resolve-spotlight}.ts, read in
|
|
7
|
+
// full before writing this file):
|
|
8
|
+
//
|
|
9
|
+
// 1. captureUiSnapshot() — a structured DOM snapshot (visible interactive
|
|
10
|
+
// elements + labeled regions), capped at ~120, agent chrome excluded.
|
|
11
|
+
// Sent to the server as a `context` ClientMessage on route/step change
|
|
12
|
+
// (see chat.ts) and returned by the `get_current_app_context` tool.
|
|
13
|
+
// 2. resolveSpotlightTarget() — turns a spotlight_ui tool's loose
|
|
14
|
+
// {ref|label|selector|target} request into a concrete element, in
|
|
15
|
+
// exactly PortfolioManager's resolution order (ref from the last
|
|
16
|
+
// snapshot -> live selector -> label fuzzy-match -> catalog target).
|
|
17
|
+
// 3. showSpotlight()/dismissSpotlight() — the hand-rolled, NEVER-HIJACK
|
|
18
|
+
// overlay (a hard constraint): traps no focus, blocks no
|
|
19
|
+
// clicks (pointer-events: none on the whole overlay — the highlighted
|
|
20
|
+
// control stays clickable THROUGH it), auto-dismisses ~3s, and ANY user
|
|
21
|
+
// input (keydown/mousedown/wheel) dismisses it instantly. transform/
|
|
22
|
+
// opacity animations only, prefers-reduced-motion honored. This is
|
|
23
|
+
// deliberately NOT wired into shell.ts's closeTopOverlay/trapFocus
|
|
24
|
+
// machinery — those are for real modals; this overlay must never behave
|
|
25
|
+
// like one.
|
|
26
|
+
|
|
27
|
+
import { getSpotlightTarget, isViewId, ROUTE_LABELS, type RouteId, type ViewId } from "./ui-catalog";
|
|
28
|
+
|
|
29
|
+
const STEP_CONTAINER_BY_ROUTE: Partial<Record<RouteId, string>> = {
|
|
30
|
+
quantize: "q-steps",
|
|
31
|
+
finetune: "f-steps",
|
|
32
|
+
dataset: "d-steps",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** UI controls may live inside a mounted module's open shadow root. */
|
|
36
|
+
export function queryUiElements(selector: string, root: Document | ShadowRoot = document): Element[] {
|
|
37
|
+
const elements = [...root.querySelectorAll(selector)];
|
|
38
|
+
for (const host of root.querySelectorAll("*")) {
|
|
39
|
+
if (host.shadowRoot) elements.push(...queryUiElements(selector, host.shadowRoot));
|
|
40
|
+
}
|
|
41
|
+
return elements;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Follow the rendered ancestry across a panel's shadow boundary. */
|
|
45
|
+
function uiParent(element: Element): Element | null {
|
|
46
|
+
if (element.parentElement) return element.parentElement;
|
|
47
|
+
const root = element.getRootNode();
|
|
48
|
+
return "host" in root ? (root as ShadowRoot).host : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function currentWizardStep(route: string): WizardStep | null {
|
|
52
|
+
const containerId = STEP_CONTAINER_BY_ROUTE[route as RouteId];
|
|
53
|
+
if (!containerId) return null;
|
|
54
|
+
const container = queryUiElements(`[id="${containerId}"]`)[0];
|
|
55
|
+
if (!container) return null;
|
|
56
|
+
const spans = [...container.querySelectorAll(".s")];
|
|
57
|
+
if (spans.length === 0) return null;
|
|
58
|
+
const curIdx = spans.findIndex((s) => s.classList.contains("cur"));
|
|
59
|
+
const cur = spans[curIdx] ?? spans[0]!;
|
|
60
|
+
const label = cur.querySelector(".n")
|
|
61
|
+
? (cur.textContent || "").replace(/^\d+/, "").trim()
|
|
62
|
+
: (cur.textContent || "").trim();
|
|
63
|
+
return { index: curIdx < 0 ? 0 : curIdx, count: spans.length, label };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const INTERACTIVE_SELECTOR = [
|
|
67
|
+
"button:not([disabled])",
|
|
68
|
+
"a[href]",
|
|
69
|
+
'input:not([type="hidden"]):not([disabled])',
|
|
70
|
+
"select:not([disabled])",
|
|
71
|
+
"textarea:not([disabled])",
|
|
72
|
+
'[role="button"]',
|
|
73
|
+
"[data-spotlight]",
|
|
74
|
+
].join(", ");
|
|
75
|
+
|
|
76
|
+
const MAX_ELEMENTS = 120;
|
|
77
|
+
|
|
78
|
+
/** Chrome/content the snapshot must never include: toasts, the assistant's
|
|
79
|
+
* own spotlight overlay (`data-ui-chrome="assistant"`), rendered
|
|
80
|
+
* conversation content (`data-ui-chrome="content"` — chat-thread and the
|
|
81
|
+
* memory/adapters/hub panel bodies, see the marker sites in chat.ts/
|
|
82
|
+
* memory-panel.ts/adapters-panel.ts/hub.ts), and any overlay currently
|
|
83
|
+
* closed (display:none via its .open class gate — see isVisible's
|
|
84
|
+
* ancestor walk below).
|
|
85
|
+
*
|
|
86
|
+
* The `data-ui-chrome="content"` exclusion matters beyond tidiness: a
|
|
87
|
+
* rendered assistant/user message can contain a real `<a href>` (a
|
|
88
|
+
* citation link, a markdown link from RAG'd/tool/web content, or just an
|
|
89
|
+
* ordinary reply). INTERACTIVE_SELECTOR matches `a[href]`, and this
|
|
90
|
+
* snapshot's whole point is to be re-served to the model verbatim as
|
|
91
|
+
* trusted "current UI state" via get_current_app_context — without this
|
|
92
|
+
* exclusion, an attacker-influenced link's visible text becomes a second,
|
|
93
|
+
* less-obvious prompt-injection channel alongside the visible transcript
|
|
94
|
+
* the model already sees. Any element inside a `data-ui-chrome="content"`
|
|
95
|
+
* root is excluded regardless of nesting depth (`.closest`), matching how
|
|
96
|
+
* the "assistant" chrome exclusion already works. */
|
|
97
|
+
function isAgentChrome(el: Element): boolean {
|
|
98
|
+
for (let node: Element | null = el; node; node = uiParent(node)) {
|
|
99
|
+
if (node.matches("[data-ui-chrome], #toasts")) return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True when `el` itself is hidden by inline style or the `hidden`
|
|
105
|
+
* attribute — deliberately NOT full computed-style resolution (a real
|
|
106
|
+
* browser's CSS-class-driven `display:none` — e.g. `#mem-overlay{display:
|
|
107
|
+
* none} .open{display:flex}` — only resolves through getComputedStyle,
|
|
108
|
+
* which happy-dom's test environment can't lay out or cascade through
|
|
109
|
+
* <style> rules). Real browsers get the accurate signal from
|
|
110
|
+
* offsetParent below; this inline-only check is the fallback that also
|
|
111
|
+
* makes the ancestor walk deterministically testable with plain
|
|
112
|
+
* `style="display:none"` fixtures. */
|
|
113
|
+
function isHiddenSelf(el: Element): boolean {
|
|
114
|
+
const html = el as HTMLElement;
|
|
115
|
+
if (html.hidden) return true;
|
|
116
|
+
const inline = html.style?.display;
|
|
117
|
+
if (inline === "none") return true;
|
|
118
|
+
const inlineVis = html.style?.visibility;
|
|
119
|
+
if (inlineVis === "hidden" || inlineVis === "collapse") return true;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isVisible(el: Element): boolean {
|
|
124
|
+
const html = el as HTMLElement;
|
|
125
|
+
// offsetParent is the accurate, layout-derived signal in a real browser
|
|
126
|
+
// (null for display:none, including via a CSS class rule) — the same
|
|
127
|
+
// test shell.ts's trapFocus already uses elsewhere in this codebase. Only
|
|
128
|
+
// trust it when it's null: some engines (and fixed-position elements)
|
|
129
|
+
// report null even when visible, and test environments without real
|
|
130
|
+
// layout (happy-dom) report `undefined` for every element regardless of
|
|
131
|
+
// visibility — in both of those cases fall through to the ancestor walk
|
|
132
|
+
// below rather than either false-negative or false-positive on
|
|
133
|
+
// offsetParent alone.
|
|
134
|
+
if (html.offsetParent === null) {
|
|
135
|
+
const style = (globalThis as { getComputedStyle?: (e: Element) => CSSStyleDeclaration }).getComputedStyle?.(html);
|
|
136
|
+
if (style && style.position === "fixed") return true;
|
|
137
|
+
}
|
|
138
|
+
// Ancestor walk: catches inline/attribute hidden state on `el` itself or
|
|
139
|
+
// any ancestor (a closed overlay's contents, a display:none wrapper),
|
|
140
|
+
// independent of whether offsetParent resolved anything above.
|
|
141
|
+
for (let node: Element | null = el; node; node = uiParent(node)) {
|
|
142
|
+
if (isHiddenSelf(node)) return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function elementLabel(el: Element): string {
|
|
148
|
+
const html = el as HTMLElement;
|
|
149
|
+
|
|
150
|
+
const uiLabel = html.getAttribute("data-ui-label")?.trim();
|
|
151
|
+
if (uiLabel) return uiLabel;
|
|
152
|
+
|
|
153
|
+
const aria = html.getAttribute("aria-label")?.trim();
|
|
154
|
+
if (aria) return aria;
|
|
155
|
+
|
|
156
|
+
const labelledBy = html.getAttribute("aria-labelledby");
|
|
157
|
+
if (labelledBy) {
|
|
158
|
+
const labelEl = (html.getRootNode() as Document | ShadowRoot).getElementById(labelledBy);
|
|
159
|
+
if (labelEl?.textContent?.trim()) return labelEl.textContent.trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const spotlight = html.getAttribute("data-spotlight")?.trim();
|
|
163
|
+
const text = html.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
164
|
+
if (spotlight) return text.length > 0 && text.length <= 100 ? text : spotlight;
|
|
165
|
+
|
|
166
|
+
if (text.length > 0 && text.length <= 80) return text;
|
|
167
|
+
|
|
168
|
+
const placeholder = (html as HTMLInputElement).placeholder?.trim();
|
|
169
|
+
if (placeholder) return placeholder;
|
|
170
|
+
|
|
171
|
+
const title = html.getAttribute("title")?.trim();
|
|
172
|
+
if (title) return title;
|
|
173
|
+
|
|
174
|
+
const name = html.getAttribute("name")?.trim();
|
|
175
|
+
if (name) return name;
|
|
176
|
+
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Build the ~120-cap snapshot of the CURRENT DOM. `route` is whatever
|
|
181
|
+
* chat.ts's currentRoute()/overlay-name is at capture time — stamped onto
|
|
182
|
+
* the snapshot and used to derive stable per-route refs. */
|
|
183
|
+
export function captureUiSnapshot(route: string): UiSnapshot {
|
|
184
|
+
const nodes = queryUiElements(INTERACTIVE_SELECTOR);
|
|
185
|
+
const elements: UiSnapshotElement[] = [];
|
|
186
|
+
const seenRefs = new Set<string>();
|
|
187
|
+
let index = 0;
|
|
188
|
+
|
|
189
|
+
for (const el of nodes) {
|
|
190
|
+
if (isAgentChrome(el) || !isVisible(el)) continue;
|
|
191
|
+
const label = elementLabel(el);
|
|
192
|
+
if (!label) continue;
|
|
193
|
+
|
|
194
|
+
const html = el as HTMLElement;
|
|
195
|
+
const spotlightId = html.getAttribute("data-spotlight")?.trim() || undefined;
|
|
196
|
+
const existingRef = html.getAttribute("data-ui-ref");
|
|
197
|
+
const ref = existingRef ?? `ui-${route.replace(/[^a-zA-Z0-9]/g, "_") || "root"}-${index}`;
|
|
198
|
+
if (!existingRef) html.setAttribute("data-ui-ref", ref);
|
|
199
|
+
if (seenRefs.has(ref)) continue;
|
|
200
|
+
seenRefs.add(ref);
|
|
201
|
+
|
|
202
|
+
elements.push({
|
|
203
|
+
ref,
|
|
204
|
+
tag: html.tagName.toLowerCase(),
|
|
205
|
+
label,
|
|
206
|
+
kind: spotlightId ? "region" : "interactive",
|
|
207
|
+
role: html.getAttribute("role") ?? undefined,
|
|
208
|
+
selector: `[data-ui-ref="${ref}"]`,
|
|
209
|
+
spotlightId,
|
|
210
|
+
});
|
|
211
|
+
index += 1;
|
|
212
|
+
if (elements.length >= MAX_ELEMENTS) break;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { route, capturedAt: new Date().toISOString(), elements };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
219
|
+
2. POINT — resolving a spotlight_ui request to a concrete element
|
|
220
|
+
──────────────────────────────────────────────────────────────────── */
|
|
221
|
+
|
|
222
|
+
export interface SpotlightRequest {
|
|
223
|
+
ref?: string;
|
|
224
|
+
label?: string;
|
|
225
|
+
selector?: string;
|
|
226
|
+
target?: string;
|
|
227
|
+
message?: string;
|
|
228
|
+
route?: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface ResolvedSpotlight {
|
|
232
|
+
selector: string;
|
|
233
|
+
title: string;
|
|
234
|
+
message?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function matchScore(haystack: string, query: string): number {
|
|
238
|
+
const h = haystack.toLowerCase();
|
|
239
|
+
const q = query.toLowerCase().trim();
|
|
240
|
+
if (!q) return 0;
|
|
241
|
+
if (h === q) return 100;
|
|
242
|
+
if (h.includes(q)) return 85;
|
|
243
|
+
const tokens = q.split(/\s+/).filter(Boolean);
|
|
244
|
+
if (tokens.length === 0) return 0;
|
|
245
|
+
const matched = tokens.filter((t) => h.includes(t)).length;
|
|
246
|
+
return matched === 0 ? 0 : 50 + (matched / tokens.length) * 35;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Fuzzy label match against the last-captured snapshot's elements — same
|
|
250
|
+
* scoring PortfolioManager's ui-snapshot.ts findElementByLabel uses. */
|
|
251
|
+
function findByLabel(snapshot: UiSnapshot | null, label: string): UiSnapshotElement | undefined {
|
|
252
|
+
if (!snapshot) return undefined;
|
|
253
|
+
let best: UiSnapshotElement | undefined;
|
|
254
|
+
let bestScore = 0;
|
|
255
|
+
for (const el of snapshot.elements) {
|
|
256
|
+
const hay = [el.label, el.spotlightId].filter(Boolean).join(" ");
|
|
257
|
+
const score = matchScore(hay, label);
|
|
258
|
+
if (score > bestScore) { bestScore = score; best = el; }
|
|
259
|
+
}
|
|
260
|
+
return bestScore >= 50 ? best : undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Attribute-value escape for selectors built from model-provided (or
|
|
264
|
+
* DOM-read) strings — `[data-ui-ref="…"]` etc. Backslash-escapes `\` and
|
|
265
|
+
* `"` so a quote in the value can't break out of the attribute string
|
|
266
|
+
* and turn the selector into a SyntaxError (or something else entirely). */
|
|
267
|
+
function attrEscape(v: string): string {
|
|
268
|
+
return v.replace(/[\\"]/g, "\\$&");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** querySelector that treats an INVALID selector as a miss, not a throw.
|
|
272
|
+
* request.selector is model-invented; a malformed one must resolve to
|
|
273
|
+
* null so chat.ts's ui_spotlight case reaches the "couldn't find that on
|
|
274
|
+
* screen" toast — a SyntaxError here would escape through handle() and
|
|
275
|
+
* kill the ws frame instead (2026-07-07 review finding). */
|
|
276
|
+
function safeQuery(selector: string): Element | null {
|
|
277
|
+
try { return queryUiElements(selector)[0] ?? null; } catch { return null; }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Resolve a spotlight request in PortfolioManager's exact precedence: ref
|
|
281
|
+
* from the last snapshot -> live selector -> label fuzzy-match (against
|
|
282
|
+
* the snapshot, falling back to any live data-ui-label/data-spotlight in
|
|
283
|
+
* the DOM) -> curated catalog target. Returns null when nothing resolves
|
|
284
|
+
* (unknown ref/label/selector/target — spotlight_ui's tool handler turns
|
|
285
|
+
* that into an error result, never a silent no-op overlay). Pure —
|
|
286
|
+
* `snapshot` is passed in rather than re-captured, so tests exercise it
|
|
287
|
+
* without a live route. */
|
|
288
|
+
export function resolveSpotlightTarget(
|
|
289
|
+
request: SpotlightRequest,
|
|
290
|
+
snapshot: UiSnapshot | null,
|
|
291
|
+
): ResolvedSpotlight | null {
|
|
292
|
+
if (request.ref) {
|
|
293
|
+
const el = snapshot?.elements.find((e) => e.ref === request.ref);
|
|
294
|
+
if (el) return { selector: el.selector, title: el.label, message: request.message };
|
|
295
|
+
const selector = `[data-ui-ref="${attrEscape(request.ref)}"]`;
|
|
296
|
+
if (safeQuery(selector)) {
|
|
297
|
+
return { selector, title: request.label ?? request.ref, message: request.message };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (request.selector && safeQuery(request.selector)) {
|
|
302
|
+
return { selector: request.selector, title: request.label ?? "Here", message: request.message };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (request.label) {
|
|
306
|
+
const el = findByLabel(snapshot, request.label);
|
|
307
|
+
if (el) return { selector: el.selector, title: el.label, message: request.message };
|
|
308
|
+
|
|
309
|
+
let bestEl: Element | null = null;
|
|
310
|
+
let bestScore = 0;
|
|
311
|
+
for (const node of queryUiElements("[data-ui-label]")) {
|
|
312
|
+
const score = matchScore(node.getAttribute("data-ui-label") ?? "", request.label);
|
|
313
|
+
if (score > bestScore) { bestScore = score; bestEl = node; }
|
|
314
|
+
}
|
|
315
|
+
if (bestEl && bestScore >= 50) {
|
|
316
|
+
const ref = bestEl.getAttribute("data-ui-ref");
|
|
317
|
+
return {
|
|
318
|
+
selector: ref
|
|
319
|
+
? `[data-ui-ref="${attrEscape(ref)}"]`
|
|
320
|
+
: `[data-ui-label="${attrEscape(bestEl.getAttribute("data-ui-label") ?? "")}"]`,
|
|
321
|
+
title: bestEl.getAttribute("data-ui-label") ?? request.label,
|
|
322
|
+
message: request.message,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (request.target) {
|
|
328
|
+
const meta = getSpotlightTarget(request.target);
|
|
329
|
+
if (meta && safeQuery(meta.selector)) {
|
|
330
|
+
return { selector: meta.selector, title: meta.label, message: request.message };
|
|
331
|
+
}
|
|
332
|
+
const targetSelector = `[data-spotlight="${attrEscape(request.target)}"]`;
|
|
333
|
+
const bySpotlightAttr = safeQuery(targetSelector);
|
|
334
|
+
if (bySpotlightAttr) {
|
|
335
|
+
const ref = bySpotlightAttr.getAttribute("data-ui-ref");
|
|
336
|
+
return {
|
|
337
|
+
selector: ref ? `[data-ui-ref="${attrEscape(ref)}"]` : targetSelector,
|
|
338
|
+
title: bySpotlightAttr.getAttribute("data-ui-label") ?? request.label ?? request.target,
|
|
339
|
+
message: request.message,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
348
|
+
3. THE OVERLAY — never-hijack
|
|
349
|
+
──────────────────────────────────────────────────────────────────── */
|
|
350
|
+
|
|
351
|
+
const AUTO_DISMISS_MS = 3000;
|
|
352
|
+
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
|
353
|
+
let dismissListeners: (() => void) | null = null;
|
|
354
|
+
|
|
355
|
+
function ensureOverlayEl(): HTMLElement {
|
|
356
|
+
let el = document.getElementById("assistant-spotlight");
|
|
357
|
+
if (!el) {
|
|
358
|
+
el = document.createElement("div");
|
|
359
|
+
el.id = "assistant-spotlight";
|
|
360
|
+
// data-ui-chrome="assistant" — excluded from the DOM snapshot (the
|
|
361
|
+
// assistant must never spotlight or narrate its own spotlight).
|
|
362
|
+
el.setAttribute("data-ui-chrome", "assistant");
|
|
363
|
+
el.setAttribute("aria-hidden", "true");
|
|
364
|
+
el.innerHTML = '<div class="asr-ring"></div><div class="asr-pop"><div class="asr-pop-text"></div></div>';
|
|
365
|
+
document.body.appendChild(el);
|
|
366
|
+
}
|
|
367
|
+
return el;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Tear down the overlay: clear the timer, remove the dismiss-on-any-input
|
|
371
|
+
* listeners, and hide it. Safe to call when nothing is showing. */
|
|
372
|
+
export function dismissSpotlight(): void {
|
|
373
|
+
if (dismissTimer) { clearTimeout(dismissTimer); dismissTimer = null; }
|
|
374
|
+
if (dismissListeners) { dismissListeners(); dismissListeners = null; }
|
|
375
|
+
const el = document.getElementById("assistant-spotlight");
|
|
376
|
+
if (el) el.classList.remove("show");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Show the spotlight overlay around `selector` with a short popover
|
|
380
|
+
* message. Returns false (no-op) if the element doesn't exist — the
|
|
381
|
+
* caller (chat.ts's ui_spotlight handler) can toast a quiet failure
|
|
382
|
+
* instead of showing a ring around nothing.
|
|
383
|
+
*
|
|
384
|
+
* NEVER-HIJACK: this overlay traps no focus (never
|
|
385
|
+
* added to shell.ts's trapFocus/closeTopOverlay set), blocks no clicks
|
|
386
|
+
* (pointer-events:none in CSS — the highlighted control stays clickable
|
|
387
|
+
* through it), auto-dismisses after ~3s, and ANY user input — keydown,
|
|
388
|
+
* mousedown, or wheel, anywhere on the page — dismisses it instantly.
|
|
389
|
+
* transform/opacity are the only animated properties, and the CSS
|
|
390
|
+
* respects prefers-reduced-motion (app.html). */
|
|
391
|
+
export function showSpotlight(resolved: ResolvedSpotlight): boolean {
|
|
392
|
+
// safeQuery: resolved.selector can be the raw model-provided passthrough;
|
|
393
|
+
// it was valid at resolve time, but "return false → toast" is the right
|
|
394
|
+
// failure mode here too, never a throw.
|
|
395
|
+
const target = safeQuery(resolved.selector) as HTMLElement | null;
|
|
396
|
+
if (!target) return false;
|
|
397
|
+
|
|
398
|
+
dismissSpotlight();
|
|
399
|
+
const overlay = ensureOverlayEl();
|
|
400
|
+
const rect = target.getBoundingClientRect();
|
|
401
|
+
const ring = overlay.querySelector(".asr-ring") as HTMLElement;
|
|
402
|
+
const pop = overlay.querySelector(".asr-pop") as HTMLElement;
|
|
403
|
+
const popText = overlay.querySelector(".asr-pop-text") as HTMLElement;
|
|
404
|
+
|
|
405
|
+
const pad = 6;
|
|
406
|
+
ring.style.left = `${rect.left - pad}px`;
|
|
407
|
+
ring.style.top = `${rect.top - pad}px`;
|
|
408
|
+
ring.style.width = `${rect.width + pad * 2}px`;
|
|
409
|
+
ring.style.height = `${rect.height + pad * 2}px`;
|
|
410
|
+
|
|
411
|
+
popText.textContent = resolved.message ? `${resolved.title} — ${resolved.message}` : resolved.title;
|
|
412
|
+
const popTop = rect.bottom + 12;
|
|
413
|
+
pop.style.left = `${Math.max(8, rect.left)}px`;
|
|
414
|
+
pop.style.top = `${popTop}px`;
|
|
415
|
+
|
|
416
|
+
overlay.classList.add("show");
|
|
417
|
+
|
|
418
|
+
const onDismiss = () => dismissSpotlight();
|
|
419
|
+
document.addEventListener("keydown", onDismiss, { capture: true });
|
|
420
|
+
document.addEventListener("mousedown", onDismiss, { capture: true });
|
|
421
|
+
document.addEventListener("wheel", onDismiss, { capture: true, passive: true });
|
|
422
|
+
dismissListeners = () => {
|
|
423
|
+
document.removeEventListener("keydown", onDismiss, { capture: true });
|
|
424
|
+
document.removeEventListener("mousedown", onDismiss, { capture: true });
|
|
425
|
+
document.removeEventListener("wheel", onDismiss, { capture: true });
|
|
426
|
+
};
|
|
427
|
+
dismissTimer = setTimeout(dismissSpotlight, AUTO_DISMISS_MS);
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
432
|
+
Ambient context frame builder (chat.ts calls this on route/step change)
|
|
433
|
+
──────────────────────────────────────────────────────────────────── */
|
|
434
|
+
|
|
435
|
+
export interface AppContext {
|
|
436
|
+
route: string;
|
|
437
|
+
view?: ViewId;
|
|
438
|
+
step?: WizardStep;
|
|
439
|
+
snapshot: UiSnapshot;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Build the full context payload chat.ts pushes as a `context` frame.
|
|
443
|
+
* `viewOverride` names an open overlay (memory/hub/adapters panel) when one
|
|
444
|
+
* is open on top of the current route — routes and views are otherwise
|
|
445
|
+
* the same "where is the user" concept from the tool's point of view. */
|
|
446
|
+
export function buildAppContext(route: string, viewOverride?: string | null): AppContext {
|
|
447
|
+
const view = viewOverride && isViewId(viewOverride) ? viewOverride : undefined;
|
|
448
|
+
const step = currentWizardStep(route) ?? undefined;
|
|
449
|
+
return {
|
|
450
|
+
route,
|
|
451
|
+
...(view ? { view } : {}),
|
|
452
|
+
...(step ? { step } : {}),
|
|
453
|
+
snapshot: captureUiSnapshot(route),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The compact one-line ambient context PortfolioManager-style "never
|
|
458
|
+
* answer blind" auto-prepend — NOT a snapshot dump. Mirrors the shape in
|
|
459
|
+
* the task brief's example: "[user is on: Quantize · step 2/4]". Pure
|
|
460
|
+
* string builder so packages/module-chat/tests/chat-policy.test.ts can exercise the exact wire text
|
|
461
|
+
* the assistant would see; the browser never calls this directly (the
|
|
462
|
+
* server derives its own copy from the stored context — see
|
|
463
|
+
* packages/module-chat/src/policy.ts's ambientContextLine). Exported for symmetry/documentation even though
|
|
464
|
+
* chat.ts doesn't call it (kept here so the format has one home). */
|
|
465
|
+
export function ambientLine(ctx: { route: string; view?: string; step?: WizardStep }): string {
|
|
466
|
+
const place = ctx.view ? ctx.view : (ROUTE_LABELS as Record<string, string>)[ctx.route] ?? ctx.route;
|
|
467
|
+
const stepPart = ctx.step ? ` · step ${ctx.step.index + 1}/${ctx.step.count}` : "";
|
|
468
|
+
return `[user is on: ${place}${stepPart}]`;
|
|
469
|
+
}
|