@nxisai/agent-harness 0.4.1 → 0.5.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.
Files changed (103) hide show
  1. package/README.md +3 -1
  2. package/dist/harness/harness.d.ts.map +1 -1
  3. package/dist/harness/harness.js +31 -9
  4. package/dist/harness/harness.js.map +1 -1
  5. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  6. package/dist/harness/tools/action-tools.js +7 -5
  7. package/dist/harness/tools/action-tools.js.map +1 -1
  8. package/dist/harness/tools/bridge.d.ts.map +1 -1
  9. package/dist/harness/tools/bridge.js +33 -12
  10. package/dist/harness/tools/bridge.js.map +1 -1
  11. package/dist/harness/tools/local-context.d.ts +49 -9
  12. package/dist/harness/tools/local-context.d.ts.map +1 -1
  13. package/dist/harness/tools/local-context.js +377 -22
  14. package/dist/harness/tools/local-context.js.map +1 -1
  15. package/dist/harness/tools/open-page.d.ts +16 -0
  16. package/dist/harness/tools/open-page.d.ts.map +1 -1
  17. package/dist/harness/tools/open-page.js +45 -9
  18. package/dist/harness/tools/open-page.js.map +1 -1
  19. package/dist/harness/tools/retry.d.ts +53 -0
  20. package/dist/harness/tools/retry.d.ts.map +1 -0
  21. package/dist/harness/tools/retry.js +96 -0
  22. package/dist/harness/tools/retry.js.map +1 -0
  23. package/dist/harness/tools/schema.d.ts +18 -0
  24. package/dist/harness/tools/schema.d.ts.map +1 -1
  25. package/dist/harness/tools/schema.js +38 -0
  26. package/dist/harness/tools/schema.js.map +1 -1
  27. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  28. package/dist/harness/tools/site-actions.js +3 -2
  29. package/dist/harness/tools/site-actions.js.map +1 -1
  30. package/dist/harness/tools/site-nav.d.ts +168 -0
  31. package/dist/harness/tools/site-nav.d.ts.map +1 -0
  32. package/dist/harness/tools/site-nav.js +316 -0
  33. package/dist/harness/tools/site-nav.js.map +1 -0
  34. package/dist/harness/tools/site-pages.d.ts +4 -0
  35. package/dist/harness/tools/site-pages.d.ts.map +1 -1
  36. package/dist/harness/tools/site-pages.js +19 -3
  37. package/dist/harness/tools/site-pages.js.map +1 -1
  38. package/dist/harness/tools/source.d.ts +9 -0
  39. package/dist/harness/tools/source.d.ts.map +1 -1
  40. package/dist/harness/tools/source.js +54 -14
  41. package/dist/harness/tools/source.js.map +1 -1
  42. package/dist/harness/tools/webmcp.d.ts +39 -3
  43. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  44. package/dist/harness/tools/webmcp.js +93 -14
  45. package/dist/harness/tools/webmcp.js.map +1 -1
  46. package/dist/index.d.ts +5 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/types.d.ts +2 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  53. package/dist/ui/agent-fullscreen.js +12 -1
  54. package/dist/ui/agent-fullscreen.js.map +1 -1
  55. package/dist/ui/tailwind.generated.d.ts +1 -1
  56. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  57. package/dist/ui/tailwind.generated.js +1 -1
  58. package/dist/ui/tailwind.generated.js.map +1 -1
  59. package/package.json +2 -1
  60. package/src/a2ui/cards.ts +177 -0
  61. package/src/a2ui/emit.ts +147 -0
  62. package/src/a2ui/form.ts +117 -0
  63. package/src/a2ui/host.ts +73 -0
  64. package/src/a2ui/index.ts +22 -0
  65. package/src/a2ui/stepper.ts +211 -0
  66. package/src/a2ui/theme.ts +134 -0
  67. package/src/a2ui/to-v09.ts +118 -0
  68. package/src/engine.ts +701 -0
  69. package/src/harness/harness.ts +956 -0
  70. package/src/harness/plan.ts +91 -0
  71. package/src/harness/policy.ts +59 -0
  72. package/src/harness/renderer.ts +208 -0
  73. package/src/harness/router/coordinator.ts +220 -0
  74. package/src/harness/router/rules.ts +65 -0
  75. package/src/harness/slots.ts +117 -0
  76. package/src/harness/steps.ts +156 -0
  77. package/src/harness/tools/action-tools.ts +870 -0
  78. package/src/harness/tools/bridge.ts +393 -0
  79. package/src/harness/tools/client.ts +26 -0
  80. package/src/harness/tools/discovery.ts +55 -0
  81. package/src/harness/tools/local-context.ts +480 -0
  82. package/src/harness/tools/open-page.ts +273 -0
  83. package/src/harness/tools/pending-action.ts +232 -0
  84. package/src/harness/tools/resume.ts +134 -0
  85. package/src/harness/tools/retry.ts +117 -0
  86. package/src/harness/tools/schema.ts +245 -0
  87. package/src/harness/tools/site-actions.ts +356 -0
  88. package/src/harness/tools/site-nav.ts +432 -0
  89. package/src/harness/tools/site-pages.ts +288 -0
  90. package/src/harness/tools/source.ts +353 -0
  91. package/src/harness/tools/webmcp.ts +200 -0
  92. package/src/index.ts +193 -0
  93. package/src/providers/capability.ts +167 -0
  94. package/src/providers/proxy.ts +191 -0
  95. package/src/providers/types.ts +150 -0
  96. package/src/types.ts +258 -0
  97. package/src/ui/agent-fullscreen.ts +3984 -0
  98. package/src/ui/agent.tw.css +49 -0
  99. package/src/ui/config.ts +138 -0
  100. package/src/ui/index.ts +32 -0
  101. package/src/ui/journey.ts +57 -0
  102. package/src/ui/tailwind.generated.ts +2 -0
  103. package/src/webmcp-contract.ts +65 -0
@@ -0,0 +1,3984 @@
1
+ /**
2
+ * The `<nxis-agent-fullscreen>` custom element: a full-screen chat interface
3
+ * over an {@link AgentHarness}.
4
+ *
5
+ * The layout is a conversation drawer beside a thread. Messages, generated tool
6
+ * forms and submitted detail cards all render inline in that thread, so a form
7
+ * appears where it was asked for rather than in a separate dialog.
8
+ *
9
+ * The model proposes a tool; a form generated from the tool's JSON Schema and
10
+ * prefilled from stored preferences appears inline; submitting it is the
11
+ * confirmation step for a write, after which the form becomes a read-only card.
12
+ *
13
+ * Configurable through properties and attributes: see the property accessors
14
+ * below, and `README.md` for worked examples.
15
+ *
16
+ * @example
17
+ * ```html
18
+ * <nxis-agent-fullscreen preload="idle"></nxis-agent-fullscreen>
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ import { mountSurface, type MountedSurface } from '../a2ui/host.js';
24
+ import { emitCardsSurface, type ResultCardData } from '../a2ui/cards.js';
25
+ import { emitToolForm } from '../a2ui/form.js';
26
+ import { A2UI_THEME_CSS } from '../a2ui/theme.js';
27
+ // Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
28
+ // string rather than a stylesheet import because the UI renders into a shadow
29
+ // root (a <link> in the host page's head never crosses that boundary) and
30
+ // because a published package cannot assume the consumer runs Tailwind.
31
+ import { TAILWIND_CSS } from './tailwind.generated.js';
32
+ import { emitAsk, emitStep, emitConfirm } from '../a2ui/stepper.js';
33
+ import { AgentHarness, isAbort } from '../harness/harness.js';
34
+ import { WebMcpTools } from '../harness/tools/webmcp.js';
35
+ import {
36
+ extractResult,
37
+ proseAnswer,
38
+ missingRequired,
39
+ toolSchema,
40
+ } from '../harness/tools/schema.js';
41
+ import type { ModelProvider } from '../providers/types.js';
42
+ import { connectTools } from '../harness/tools/source.js';
43
+ import {
44
+ handoffFor,
45
+ isSiteTool,
46
+ withoutSiteSuffix,
47
+ HANDOFF_RESULT,
48
+ } from '../harness/tools/action-tools.js';
49
+ import { resumePendingAction } from '../harness/tools/resume.js';
50
+ import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
51
+ import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
52
+ import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
53
+ import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
54
+ import { viewJourney } from './journey.js';
55
+ import { AgentCoordinator } from '../harness/router/coordinator.js';
56
+ import type { RouteRule } from '../harness/router/rules.js';
57
+ import type { AskRequest, ConfirmRequest, HarnessRenderer, StreamingBubble as HStreamingBubble } from '../harness/renderer.js';
58
+ import {
59
+ LocalGemmaProvider,
60
+ parseToolCall,
61
+ stripMarkers,
62
+ humanize,
63
+ escapeHtml,
64
+ NXIS_LOGO_SVG,
65
+ NXIS_SITE_URL,
66
+ ICON_CHECK,
67
+ ICON_WARN,
68
+ ICON_ARROW,
69
+ ICON_PLUS,
70
+ ICON_MENU,
71
+ ICON_TRASH,
72
+ ICON_STOP,
73
+ ICON_COPY,
74
+ ICON_PENCIL,
75
+ ICON_REDO,
76
+ ICON_CHECK_MARK,
77
+ type ChatMessage,
78
+ type ProgressDetail,
79
+ type PreloadMode,
80
+ } from '../engine.js';
81
+ import {
82
+ DEFAULT_SYSTEM_PROMPT,
83
+ DEFAULT_SUGGESTIONS,
84
+ DEFAULT_EMPTY_STATE,
85
+ DEFAULT_AGENT_NAME,
86
+ DEFAULT_LOGO,
87
+ THEME_TOKENS,
88
+ PROFILE_ALIASES,
89
+ type AgentTheme,
90
+ } from './config.js';
91
+ import type { JsonSchema, ModelContextToolInfo, ModelContextToolResponse } from '../types.js';
92
+
93
+ // Prefixed keys so state never collides with other Nxis examples on the same origin.
94
+ const PROFILE_KEY = 'nxis-listings-agent-profile';
95
+ const CONV_KEY = 'nxis-listings-agent-conversations';
96
+
97
+ // ── Metrics (Typed Agent vs Traditional RAG) ─────────────────────────────────
98
+ // RAG retrieval assumptions, ported from the original demo's MetricsPanel.
99
+ const AVG_HTML_CHUNK_TOKENS = 650; // avg tokens per chunked HTML segment (nav/footer/CSS included)
100
+ const CHUNKS_PER_RESULT = 3; // header + services + reviews chunks to match one JSON-LD record
101
+ const TOOL_DEF_OVERHEAD = 600; // ~tokens for the MCP tool schemas in the prompt
102
+
103
+ interface MetricsRound {
104
+ round: number;
105
+ promptTokens: number;
106
+ outputTokens: number;
107
+ toolCalls: number;
108
+ toolResultTokens: number;
109
+ results: number;
110
+ }
111
+
112
+ function estimateRagTokens(rounds: MetricsRound[]): {
113
+ ragInput: number;
114
+ ragOutput: number;
115
+ numResults: number;
116
+ ragChunks: number;
117
+ } {
118
+ const toolCalls = rounds.reduce((s, m) => s + m.toolCalls, 0);
119
+ // Floor: each retrieval call fetches at least 3 results in a typical RAG setup.
120
+ const numResults = Math.max(
121
+ rounds.reduce((s, m) => s + m.results, 0),
122
+ toolCalls * 3,
123
+ );
124
+ // Round 1 input = system prompt + tool definitions + user message. RAG keeps
125
+ // the same prompt + user but swaps tool definitions for retrieved chunks.
126
+ const round1Input = rounds.length ? rounds[0].promptTokens : 3000;
127
+ const ragBaseContext = Math.max(round1Input - TOOL_DEF_OVERHEAD, 0);
128
+ const ragChunks = numResults * CHUNKS_PER_RESULT;
129
+ return {
130
+ ragInput: ragBaseContext + ragChunks * AVG_HTML_CHUNK_TOKENS,
131
+ ragOutput: rounds.reduce((s, m) => s + m.outputTokens, 0),
132
+ numResults,
133
+ ragChunks,
134
+ };
135
+ }
136
+
137
+ const fmt = (n: number) => n.toLocaleString();
138
+
139
+ /**
140
+ * The contents of a pending bubble: three dots, a phase label and a byte count.
141
+ *
142
+ * The dots are separate spans rather than an animated glyph, so the motion can
143
+ * be disabled under `prefers-reduced-motion` while the bubble still reads as
144
+ * pending. The label and byte spans are filled per phase and hidden while empty.
145
+ */
146
+ const THINKING_MARKUP =
147
+ '<span class="think-row">' +
148
+ '<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
149
+ '<span class="think-label"></span>' +
150
+ '<span class="think-bytes"></span>' +
151
+ '</span>';
152
+
153
+ /** Format a byte count for display, as `"412 MB"` or `"1.6 GB"`. */
154
+ function formatBytes(n: number): string {
155
+ const mb = n / (1024 * 1024);
156
+ if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
157
+ return `${Math.round(mb)} MB`;
158
+ }
159
+
160
+ const uid = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
161
+
162
+ /**
163
+ * Names for the retrieval tools the package itself bridges in.
164
+ *
165
+ * `nxis_query` is a fine name for a model and a poor one for a visitor, and a
166
+ * native registry may strip any title we attach, so the few tools this package
167
+ * registers are named here.
168
+ */
169
+ const FRIENDLY_TOOL_NAMES: Record<string, string> = {
170
+ nxis_query: 'Search records',
171
+ nxis_get: 'Get a record',
172
+ nxis_list_types: 'List record types',
173
+ };
174
+
175
+ const svg = (body: string) =>
176
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
177
+ /** A plug: the tools the agent can reach. */
178
+ const ICON_TOOLS = svg('<path d="M7 3v4M13 3v4M5 7h10v3a5 5 0 0 1-10 0V7ZM10 15v3"/>');
179
+ const ICON_LOCK = svg('<rect x="4.5" y="9" width="11" height="8" rx="2"/><path d="M7 9V6.5a3 3 0 0 1 6 0V9"/>');
180
+ const ICON_FORM = svg('<rect x="4" y="3.5" width="12" height="13" rx="2"/><path d="M7 8h6M7 11h6M7 14h3"/>');
181
+ const ICON_SEARCH = svg('<circle cx="9" cy="9" r="4.8"/><path d="m13 13 3.5 3.5"/>');
182
+ const ICON_GLOBE = svg('<circle cx="10" cy="10" r="6.5"/><path d="M3.5 10h13M10 3.5c2 2 2 11 0 13M10 3.5c-2 2-2 11 0 13"/>');
183
+
184
+ /**
185
+ * Split a message into prose and code.
186
+ *
187
+ * Two things read as code: a fenced block, and a message that is nothing but
188
+ * JSON — which is what a tool result looks like when something hands one back
189
+ * raw. Wrapped as prose it becomes a wall of text that breaks at the wrong
190
+ * places, taking any URL inside it with it.
191
+ *
192
+ * @param text - The message.
193
+ * @returns The parts in order, each marked as code or not.
194
+ */
195
+ function splitCode(text: string): { text: string; code: boolean }[] {
196
+ const fenced = text.split(/```[a-zA-Z]*\n?/);
197
+ // An opening and a closing fence produce at least three parts; the odd ones
198
+ // are what sat between them.
199
+ if (fenced.length > 2) {
200
+ return fenced.map((part, i) => ({ text: i % 2 ? part.replace(/\n$/, '') : part, code: i % 2 === 1 }));
201
+ }
202
+ const trimmed = text.trim();
203
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
204
+ try {
205
+ return [{ text: JSON.stringify(JSON.parse(trimmed), null, 2), code: true }];
206
+ } catch {
207
+ // Starts like JSON without being it — ordinary prose.
208
+ }
209
+ }
210
+ return [{ text, code: false }];
211
+ }
212
+
213
+ /** Machine output rather than something someone wrote: JSON, most often raw. */
214
+ function looksMachine(text: string): boolean {
215
+ const trimmed = text.trim();
216
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
217
+ try {
218
+ JSON.parse(trimmed);
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /** Longer than this, a block is folded away rather than filling the thread. */
226
+ const FOLD_LINES = 10;
227
+
228
+ /**
229
+ * Write a message into its bubble, keeping code as code.
230
+ *
231
+ * A long block is folded behind a summary: output worth keeping, but not
232
+ * worth scrolling past every time. Everything is written as text rather than
233
+ * markup — a message may carry a tool's output, which is not ours to trust
234
+ * as HTML.
235
+ */
236
+ function fillMessage(el: HTMLElement, text: string): void {
237
+ for (const part of splitCode(text)) {
238
+ if (part.code) {
239
+ const pre = document.createElement('pre');
240
+ pre.className = 'codeblock';
241
+ const code = document.createElement('code');
242
+ code.textContent = part.text;
243
+ pre.appendChild(code);
244
+
245
+ const lines = part.text.split('\n').length;
246
+ if (lines > FOLD_LINES) {
247
+ const fold = document.createElement('details');
248
+ fold.className = 'codefold';
249
+ const summary = document.createElement('summary');
250
+ summary.textContent = `${lines} lines`;
251
+ fold.append(summary, pre);
252
+ el.appendChild(fold);
253
+ } else {
254
+ el.appendChild(pre);
255
+ }
256
+ } else if (part.text) {
257
+ el.appendChild(document.createTextNode(part.text));
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Stored values the agent reuses across turns, exposed as `element.preferences`.
264
+ *
265
+ * The host page supplies what it already knows about the visitor, and the agent
266
+ * uses it to fill forms and to answer in context.
267
+ */
268
+ export interface AgentPreferences {
269
+ /** All values, or one by key. */
270
+ get(): Record<string, string>;
271
+ get(key: string): string | undefined;
272
+ /** Set one key, or merge an object. An empty value clears the key. */
273
+ set(key: string, value: string): void;
274
+ set(values: Record<string, string>): void;
275
+ /** Forget one key, or everything. */
276
+ clear(key?: string): void;
277
+ /** Keys the agent can map onto form fields. */
278
+ keys(): string[];
279
+ /** Survive reloads. Off by default — see the `preferences` getter. */
280
+ persist: boolean;
281
+ }
282
+
283
+ interface DetailRow {
284
+ label: string;
285
+ value: string;
286
+ /** Optional link target (e.g. tel: for phone numbers). */
287
+ href?: string;
288
+ }
289
+
290
+ interface ResultCard {
291
+ title: string;
292
+ /** schema.org @type — rendered as a blue tag. */
293
+ type: string;
294
+ /** Aggregate rating summary — rendered as a green (positive-outcome) tag. */
295
+ rating?: string;
296
+ /** Open 24/7 (00:00–23:59 opening hours) — rendered as a green tag. */
297
+ emergency?: boolean;
298
+ /** One supporting line under the headline (feature-card pattern). */
299
+ sub?: string;
300
+ rows: DetailRow[];
301
+ id?: string;
302
+ /** The page on this site the record came from, when it can be told. */
303
+ page?: { url: string; label: string };
304
+ }
305
+
306
+ type ThreadItem =
307
+ | { kind: 'msg'; role: string; text: string }
308
+ | { kind: 'tool'; name: string; text: string; isError: boolean }
309
+ | { kind: 'trace'; name: string; args: Record<string, unknown>; text: string; isError: boolean; count?: number }
310
+ | { kind: 'route'; agent: string; via: string }
311
+ | { kind: 'detail'; title: string; note: string; rows: DetailRow[] }
312
+ | { kind: 'results'; title: string; note: string; cards: ResultCard[] }
313
+ | { kind: 'metrics'; rounds: MetricsRound[] }
314
+ | { kind: 'form' }
315
+ /** A turn the person called off. Kept so the thread explains its own gap. */
316
+ | { kind: 'stopped' }
317
+ /**
318
+ * What the agent did about a request: its steps, kept with the conversation
319
+ * so a second request does not erase the first, and so a card survives the
320
+ * page change a cross-page action makes.
321
+ */
322
+ | { kind: 'journey'; id: string; steps: AgentStep[] };
323
+
324
+ interface Conversation {
325
+ id: string;
326
+ title: string;
327
+ messages: ChatMessage[];
328
+ items: ThreadItem[];
329
+ /** Real Gemma tokenizer counts per generation round (drives the metrics panel). */
330
+ metrics?: MetricsRound[];
331
+ }
332
+
333
+ interface FullscreenEls {
334
+ launcher: HTMLElement;
335
+ overlay: HTMLElement;
336
+ close: HTMLElement;
337
+ menu: HTMLElement;
338
+ /** Dismisses the conversation drawer where it overlaps the panel. */
339
+ scrim: HTMLElement;
340
+ newchat: HTMLElement;
341
+ history: HTMLElement;
342
+ thread: HTMLElement;
343
+ status: HTMLElement;
344
+ bar: HTMLElement;
345
+ /** Names the running step while its card is scrolled out of view. */
346
+ jstatus: HTMLButtonElement;
347
+ input: HTMLTextAreaElement;
348
+ go: HTMLButtonElement;
349
+ /** Opens the list of tools the agent can use. */
350
+ toolsBtn: HTMLButtonElement;
351
+ toolsPop: HTMLElement;
352
+ toolsCount: HTMLElement;
353
+ /** Which model is answering, beside the input. */
354
+ model: HTMLElement;
355
+ }
356
+
357
+ /**
358
+ * One specialist agent, as declared by the host page.
359
+ *
360
+ * The prompt and the tool filter are what specialise it. The description is
361
+ * read only when the model is asked to route a turn.
362
+ */
363
+ export interface AgentDefinition {
364
+ systemPrompt?: string;
365
+ tools?(tool: ModelContextToolInfo): boolean;
366
+ description?: string;
367
+ /**
368
+ * What this agent says when its tools are not on the page.
369
+ *
370
+ * Worth writing: the useful sentence names what cannot be done and where it
371
+ * can be, and it is the difference between an agent that admits a limit and
372
+ * one that answers a booking request by searching the site.
373
+ */
374
+ unavailable?: string;
375
+ }
376
+
377
+ type StreamingBubble = HStreamingBubble;
378
+
379
+ /** Progress is pushed at the bubble from outside the turn loop — see `_liveBubble`. */
380
+ type SetProgress = (pct: number, detail?: ProgressDetail) => void;
381
+
382
+ class NxisAgentFullscreen extends HTMLElement {
383
+ private _turnActive: boolean;
384
+ /** True while the turn in flight can still be called off. */
385
+ private _stoppable = false;
386
+ /**
387
+ * Settles when the turn in flight is finished, however it finished.
388
+ *
389
+ * Edit and send-again stop the current turn before replacing the question it
390
+ * was answering, and a stop resolves a tick later. Without something to wait
391
+ * on, the re-run would race `_onSend`'s own guard and be dropped.
392
+ */
393
+ private _turnDone: Promise<void> | null = null;
394
+ private _tool: ModelContextToolInfo | null;
395
+ private _formEl: HTMLElement | null;
396
+ /** Render thread components through A2UI. Off with a2ui="false". */
397
+ private _useA2ui!: boolean;
398
+ /** Live A2UI surfaces, disposed when the thread is re-rendered. */
399
+ private _surfaces!: MountedSurface[];
400
+ /** Stops listening to the journal. */
401
+ private _offJourney: (() => void) | null = null;
402
+ /** Whether the tools menu is showing. */
403
+ private _toolsOpen = false;
404
+ /** Closes the tools menu on a press outside it. An arrow, so it can be removed. */
405
+ private _onOutsidePress = (e: PointerEvent): void => {
406
+ if (!this._toolsOpen) return;
407
+ const path = e.composedPath();
408
+ if (path.includes(this._els.toolsPop) || path.includes(this._els.toolsBtn)) return;
409
+ this._toggleTools(false);
410
+ };
411
+ /** Watches whether the running journey's card is on screen. */
412
+ private _journeyObserver: IntersectionObserver | null = null;
413
+ /** The turn loop. This element is only its renderer. */
414
+ private _harness!: AgentHarness;
415
+ /** Built only when the host defines agents — otherwise there is one harness. */
416
+ private _coordinator: AgentCoordinator | null = null;
417
+ private _agentDefs: Record<string, AgentDefinition> | null = null;
418
+ private _routes: RouteRule[] = [];
419
+ private _fallbackAgent = '';
420
+ /** The live ask/confirm surface, if one is open. */
421
+ private _prompt: MountedSurface | null = null;
422
+ private _profile: Record<string, string>;
423
+ private _persistPrefs = false;
424
+ private _systemPrompt: string = DEFAULT_SYSTEM_PROMPT;
425
+ private _suggestions: string[] = DEFAULT_SUGGESTIONS;
426
+ private _emptyState: string = DEFAULT_EMPTY_STATE;
427
+ private _name: string = DEFAULT_AGENT_NAME;
428
+ private _logo: string = DEFAULT_LOGO;
429
+ private _theme: AgentTheme = {};
430
+ /** The pending bubble currently on screen, if any — the target for progress. */
431
+ private _liveBubble: (StreamingBubble & { setProgress: SetProgress }) | null = null;
432
+ /** The engine's current phase name, shown in the empty state while loading. */
433
+ private _stage = '';
434
+ /** The one discovery run, kicked off on first open and awaited before a turn. */
435
+ private _connect: Promise<void> | null = null;
436
+ /** The site's pages, for linking results to where they came from. */
437
+ private _sitePages: SitePage[] = [];
438
+ private _loadPct = 0;
439
+ private _loadDetail: ProgressDetail | undefined;
440
+ /**
441
+ * Which mode the empty state is currently drawn in, or null when a
442
+ * conversation has replaced it.
443
+ *
444
+ * The load almost never starts while the empty state is being built, since
445
+ * opening the panel is what starts it, so the view has to notice that it moved
446
+ * underneath it and redraw. Without this the chips stay up through a load
447
+ * that was supposed to withhold them, and never come back when it ends.
448
+ */
449
+ private _emptyLoading: boolean | null = null;
450
+ private _prefsApi!: AgentPreferences;
451
+ private _conversations: Conversation[];
452
+ private _activeId: string | null;
453
+ private _els!: FullscreenEls;
454
+ private provider!: ModelProvider;
455
+ private toolSource!: WebMcpTools;
456
+ /** A provider the host supplied, used instead of the local model. */
457
+ private _provider: ModelProvider | null = null;
458
+
459
+ constructor() {
460
+ super();
461
+ this.attachShadow({ mode: 'open' });
462
+ this._turnActive = false;
463
+ this._tool = null; // tool of the live inline form (if any)
464
+ this._formEl = null; // the live inline form element (transient, not persisted)
465
+ this._profile = {}; // device-local profile
466
+ this._conversations = []; // [{ id, title, messages, items }]
467
+ this._activeId = null;
468
+ this._surfaces = [];
469
+ // Opt out with a2ui="false" to compare against the inline renderers, which
470
+ // are kept rather than deleted, since the basic catalog cannot express the tag
471
+ // colours, links and logo chip they use.
472
+ this._useA2ui = this.getAttribute('a2ui') !== 'false';
473
+ }
474
+
475
+ connectedCallback(): void {
476
+ this.shadowRoot!.innerHTML = this._template();
477
+ this._applyIdentity();
478
+ this._applyTheme();
479
+ const root = this.shadowRoot!;
480
+ this._els = {
481
+ launcher: root.querySelector('.launcher')!,
482
+ overlay: root.querySelector('.overlay')!,
483
+ close: root.querySelector('.close')!,
484
+ menu: root.querySelector('.menu')!,
485
+ scrim: root.querySelector('.scrim')! as HTMLElement,
486
+ newchat: root.querySelector('.newchat')!,
487
+ history: root.querySelector('.history')!,
488
+ thread: root.querySelector('.thread')!,
489
+ status: root.querySelector('.statusline')!,
490
+ bar: root.querySelector('.prog > i')!,
491
+ jstatus: root.querySelector('.jstatus')!,
492
+ input: root.querySelector('.composer textarea')!,
493
+ go: root.querySelector('.composer .go')!,
494
+ toolsBtn: root.querySelector('.tools-btn')!,
495
+ toolsPop: root.querySelector('.tools-pop')!,
496
+ toolsCount: root.querySelector('.tools-count')!,
497
+ model: root.querySelector('.composer .model')!,
498
+ };
499
+
500
+ this._applyMode();
501
+
502
+ // A host-supplied provider wins; otherwise the local model, configured from
503
+ // the attributes that only mean anything to it.
504
+ this.provider =
505
+ this._provider ??
506
+ new LocalGemmaProvider({
507
+ modelHost: this.getAttribute('model-host') || undefined,
508
+ modelPathTemplate: this.getAttribute('model-path-template') || undefined,
509
+ // cache="false" skips Cache Storage: the escape hatch for a browser
510
+ // whose cache backend fails on entries this size.
511
+ useBrowserCache: this.getAttribute('cache') !== 'false',
512
+ systemPrompt: this._systemPrompt,
513
+ onStatus: (t, e) => this._setStatus(t, e),
514
+ onProgress: (pct, detail) => {
515
+ this._loadPct = pct;
516
+ this._loadDetail = detail;
517
+ this._setBar(pct);
518
+ this._liveBubble?.setProgress(pct, detail);
519
+ this._paintLoad();
520
+ },
521
+ });
522
+ this.toolSource = new WebMcpTools();
523
+ this._paintProvenance();
524
+
525
+ this._buildAgents();
526
+
527
+ // The element renders; the harness decides. Everything from choosing a
528
+ // tool to running it happens behind harness.turn().
529
+ this._harness = new AgentHarness({
530
+ provider: this.provider,
531
+ toolSource: this.toolSource,
532
+ renderer: this._renderer(),
533
+ profile: (schema) => this._profileForSchema(schema as JsonSchema),
534
+ onMessages: () => this._saveConversations(),
535
+ });
536
+
537
+ // Preferences are supplied by the host page through `element.preferences`.
538
+ // Anything a previous build persisted is picked up so upgrading does not
539
+ // silently drop a user's saved details; `persist` then stays true so they
540
+ // are not silently dropped on the next write either.
541
+ this._prefsApi = this._buildPreferencesApi();
542
+ this._profile = this._loadProfile();
543
+ this._persistPrefs = Object.keys(this._profile).length > 0;
544
+
545
+ // Conversation history.
546
+ this._loadConversations();
547
+ this._renderHistory();
548
+ this._renderThread();
549
+
550
+ // Journey cards follow the journal rather than the renderer, because the
551
+ // steps that finish a cross-page action are recorded by the resume below,
552
+ // before any turn — and so before any renderer call — exists on this page.
553
+ this._offJourney?.();
554
+ this._offJourney = onJourneyChange((_step, all) => this._onJourney(all));
555
+ this._els.toolsBtn.addEventListener('click', () => this._toggleTools());
556
+ this._els.toolsPop.addEventListener('keydown', (e) => {
557
+ if (e.key !== 'Escape') return;
558
+ e.stopPropagation();
559
+ this._toggleTools(false);
560
+ this._els.toolsBtn.focus();
561
+ });
562
+ // Outside the menu closes it. On the document, with the composed path,
563
+ // so a press anywhere on the host page counts as outside too.
564
+ document.removeEventListener('pointerdown', this._onOutsidePress);
565
+ document.addEventListener('pointerdown', this._onOutsidePress);
566
+
567
+ this._els.jstatus.addEventListener('click', () => {
568
+ const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
569
+ cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
570
+ });
571
+
572
+ // Whoever builds the tools, a call that leaves this page reports back to
573
+ // the thread it was made in.
574
+ setConversationSource(() => this._activeId ?? undefined);
575
+
576
+ // A form confirmed on another page submits here, now that the page it
577
+ // belongs to is the one open.
578
+ void this._resumePendingAction();
579
+ this._resumePendingVisit();
580
+
581
+ this._els.launcher.addEventListener('click', () => this._open());
582
+ this._els.close.addEventListener('click', () => {
583
+ this._els.overlay.classList.remove('show');
584
+ // A remote turn can outlive the panel, and its answer would go unread.
585
+ this._setStoppable(false);
586
+ (this._coordinator ?? this._harness).stop();
587
+ });
588
+ this._els.menu.addEventListener('click', () => this._setNav(!this._navOpen()));
589
+ // Tapping the conversation is how a drawer is dismissed everywhere else.
590
+ // The drawer is a fixed 280px, so on any phone a strip of the thread stays
591
+ // visible beside it, and that strip is the target.
592
+ this._els.scrim.addEventListener('click', () => this._setNav(false));
593
+ this._els.newchat.addEventListener('click', () => this._newConversation());
594
+ this._els.go.addEventListener('click', () => {
595
+ // One control, two jobs. A separate stop button would be inactive for
596
+ // most of a conversation, and appears where attention is not.
597
+ if (this._stoppable) this._stopTurn();
598
+ else this._onSend();
599
+ });
600
+ this._els.input.addEventListener('input', () => this._autosize());
601
+ this._els.input.addEventListener('keydown', (e) => {
602
+ if (e.key === 'Enter' && !e.shiftKey) {
603
+ e.preventDefault();
604
+ this._onSend();
605
+ }
606
+ });
607
+
608
+ // Escape stops the turn, and only that. Closing the panel on the same key
609
+ // would mean the reflex for "wait, no" also throws away the thread it was
610
+ // aimed at, and the two mistakes look identical until it is too late.
611
+ this._els.overlay.addEventListener('keydown', (e) => {
612
+ if (e.key !== 'Escape') return;
613
+ // The drawer is on top of everything, so it is what Escape is aimed at
614
+ // while it is open. Only once it is gone does the key reach the turn.
615
+ if (this._navOpen()) {
616
+ e.preventDefault();
617
+ e.stopPropagation();
618
+ this._setNav(false);
619
+ return;
620
+ }
621
+ if (!this._stoppable) return;
622
+ e.preventDefault();
623
+ e.stopPropagation();
624
+ this._stopTurn();
625
+ });
626
+
627
+ const preload = this.preload;
628
+ if (preload !== 'none') this.provider.preload?.(preload);
629
+ if (this.hasAttribute('auto-open')) this._open();
630
+ }
631
+
632
+ private _navOpen(): boolean {
633
+ return this._els.overlay.classList.contains('nav');
634
+ }
635
+
636
+ /**
637
+ * Show or hide the conversation drawer.
638
+ *
639
+ * On a narrow screen the drawer covers the panel, including the button that
640
+ * opened it, so every way out has to be somewhere else: the strip of
641
+ * conversation beside it, Escape, or picking a conversation.
642
+ */
643
+ private _setNav(open: boolean): void {
644
+ this._els.overlay.classList.toggle('nav', open);
645
+ this._els.menu.setAttribute('aria-expanded', String(open));
646
+ // Kept out of the accessibility tree and out of the tab order when closed:
647
+ // a full-bleed element that swallows taps is not something to leave lying
648
+ // over the conversation.
649
+ this._els.scrim.hidden = !open;
650
+ // Focus follows the drawer, returning to the control it came from.
651
+ if (!open) this._els.menu.focus();
652
+ }
653
+
654
+ disconnectedCallback(): void {
655
+ document.removeEventListener('pointerdown', this._onOutsidePress);
656
+ // Moved or removed: nothing left on screen to keep in step with the journal.
657
+ this._offJourney?.();
658
+ this._offJourney = null;
659
+ this._journeyObserver?.disconnect();
660
+ this._journeyObserver = null;
661
+ }
662
+
663
+ /** Programmatic open (used by pages that dock the agent full-page). */
664
+ open(): void {
665
+ this._open();
666
+ }
667
+
668
+ private _open(): void {
669
+ this._els.overlay.classList.add('show');
670
+ this._els.input.focus();
671
+ void this._connectTools();
672
+ (this.provider.load?.() ?? Promise.resolve())
673
+ // A finished load has to redraw too: the chips were withheld while it ran.
674
+ .then(() => this._paintLoad())
675
+ .catch((err) => this._reportEngineError(err));
676
+ // ensureEngine flips `loading` synchronously, so the empty state can switch
677
+ // to the stage view now rather than waiting for the first status callback.
678
+ this._paintLoad();
679
+ }
680
+
681
+ // ── Conversations ──────────────────────────────────────────────────────────
682
+ private _loadConversations(): void {
683
+ try {
684
+ const arr = JSON.parse(localStorage.getItem(CONV_KEY) || 'null');
685
+ if (Array.isArray(arr) && arr.length) {
686
+ this._conversations = arr;
687
+ this._activeId = arr[0].id;
688
+ return;
689
+ }
690
+ } catch {
691
+ /* fall through to a fresh one */
692
+ }
693
+ this._newConversation();
694
+ }
695
+
696
+ private _saveConversations(): void {
697
+ try {
698
+ // Live forms are transient: persist only finalized thread items.
699
+ const slim = this._conversations.map((c) => ({
700
+ id: c.id,
701
+ title: c.title,
702
+ messages: c.messages,
703
+ items: c.items.filter((it) => it.kind !== 'form'),
704
+ }));
705
+ localStorage.setItem(CONV_KEY, JSON.stringify(slim));
706
+ } catch {
707
+ /* storage blocked/full */
708
+ }
709
+ }
710
+
711
+ private _active(): Conversation | null {
712
+ return this._conversations.find((c) => c.id === this._activeId) || null;
713
+ }
714
+
715
+ private _newConversation(): void {
716
+ const conv: Conversation = {
717
+ id: uid(),
718
+ title: 'New conversation',
719
+ messages: [{ role: 'system', content: this._systemPrompt }],
720
+ items: [],
721
+ };
722
+ this._conversations.unshift(conv);
723
+ this._activeId = conv.id;
724
+ this._tool = null;
725
+ this._formEl = null;
726
+ this._syncProfileContext();
727
+ this._setNav(false);
728
+ if (this._els.history) {
729
+ this._renderHistory();
730
+ this._renderThread();
731
+ this._saveConversations();
732
+ }
733
+ if (this._els.input) this._els.input.focus();
734
+ }
735
+
736
+ private _switchConversation(id: string): void {
737
+ if (id === this._activeId) return;
738
+ this._activeId = id;
739
+ this._tool = null;
740
+ this._formEl = null;
741
+ this._syncProfileContext();
742
+ this._setNav(false);
743
+ this._renderHistory();
744
+ this._renderThread();
745
+ }
746
+
747
+ private _renderHistory(): void {
748
+ this._els.history.innerHTML = this._conversations
749
+ .map(
750
+ (c) =>
751
+ `<div class="hist-row${c.id === this._activeId ? ' active' : ''}">
752
+ <button class="hist-item" data-id="${c.id}">${escapeHtml(c.title || 'New conversation')}</button>
753
+ <button class="hist-del" data-id="${c.id}" title="Delete conversation" aria-label="Delete conversation">${ICON_TRASH}</button>
754
+ </div>`,
755
+ )
756
+ .join('');
757
+ this._els.history.querySelectorAll('.hist-item').forEach((b) =>
758
+ b.addEventListener('click', () => this._switchConversation((b as HTMLElement).dataset.id!)),
759
+ );
760
+ this._els.history.querySelectorAll('.hist-del').forEach((b) =>
761
+ b.addEventListener('click', (e) => {
762
+ e.stopPropagation();
763
+ this._deleteConversation((b as HTMLElement).dataset.id!);
764
+ }),
765
+ );
766
+ }
767
+
768
+ private _deleteConversation(id: string): void {
769
+ const idx = this._conversations.findIndex((c) => c.id === id);
770
+ if (idx === -1) return;
771
+ const wasActive = id === this._activeId;
772
+ this._conversations.splice(idx, 1);
773
+
774
+ if (!this._conversations.length) {
775
+ this._newConversation(); // creates a fresh one + re-renders + saves
776
+ return;
777
+ }
778
+ if (wasActive) {
779
+ this._activeId = this._conversations[Math.min(idx, this._conversations.length - 1)].id;
780
+ this._tool = null;
781
+ this._formEl = null;
782
+ this._syncProfileContext();
783
+ this._renderThread();
784
+ }
785
+ this._renderHistory();
786
+ this._saveConversations();
787
+ }
788
+
789
+ // ── Thread rendering ───────────────────────────────────────────────────────
790
+ private _renderThread(): void {
791
+ const t = this._els.thread;
792
+ // innerHTML = '' detaches the surface elements but leaves their processors
793
+ // subscribed; dispose explicitly before dropping the references.
794
+ for (const s of this._surfaces) {
795
+ try { s.dispose(); } catch { /* already disposed */ }
796
+ }
797
+ this._surfaces = [];
798
+ t.innerHTML = '';
799
+ this._emptyLoading = null;
800
+ const conv = this._active();
801
+ if (!conv || !conv.items.length) {
802
+ // The mark doubles as the loading indicator, with a ring filling around
803
+ // it while the weights download, rather than adding a second widget.
804
+ //
805
+ // The suggestions are withheld until the model exists, since tapping one
806
+ // before then would do nothing.
807
+ const loading = (this.provider?.load ? this.provider.loading : false) ?? false;
808
+ this._emptyLoading = loading;
809
+ const body = loading
810
+ ? `<p class="stage">${escapeHtml(this._stage || 'Loading model…')}</p>` +
811
+ `<p class="stage-bytes"></p>`
812
+ : `<p>${escapeHtml(this._emptyState)}</p>` +
813
+ `<div class="chips appear">` +
814
+ this._suggestions.map((c) => `<button class="chip">${escapeHtml(c)}</button>`).join('') +
815
+ `</div>`;
816
+ t.innerHTML =
817
+ `<div class="empty">` +
818
+ `<span class="empty-mark"><span class="ring" hidden></span>${this._logoMarkup()}</span>` +
819
+ body +
820
+ `</div>`;
821
+ this._paintLoad();
822
+ t.querySelectorAll('.chip').forEach((b) =>
823
+ b.addEventListener('click', () => {
824
+ this._els.input.value = (b as HTMLElement).textContent || '';
825
+ this._onSend();
826
+ }),
827
+ );
828
+ return;
829
+ }
830
+ for (let i = 0; i < conv.items.length; i++) {
831
+ const it = conv.items[i];
832
+ if (it.kind === 'msg') this._paintMsg(it.role, it.text, i);
833
+ else if (it.kind === 'tool') this._paintToolResult(it.name, it.text, it.isError);
834
+ else if (it.kind === 'trace') {
835
+ if (this.debug) this._els.thread.appendChild(this._traceEl(it));
836
+ } else if (it.kind === 'stopped') {
837
+ this._paintStopped();
838
+ } else if (it.kind === 'route') {
839
+ if (this.debug) this._els.thread.appendChild(this._routeEl(it));
840
+ }
841
+ else if (it.kind === 'detail') this._els.thread.appendChild(this._detailEl(it));
842
+ else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
843
+ else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
844
+ else if (it.kind === 'journey') this._els.thread.appendChild(this._journeyEl(it));
845
+ }
846
+ this._scroll();
847
+ this._watchRunningJourney();
848
+ }
849
+
850
+ private _clearEmpty(): void {
851
+ const e = this._els.thread.querySelector('.empty');
852
+ if (e) e.remove();
853
+ }
854
+
855
+ /**
856
+ * A chat bubble.
857
+ *
858
+ * `.msg` styles the bubble and also wraps non-bubble content, so the form,
859
+ * detail and results wrappers each had to undo it (`background: none;
860
+ * border: none; padding: 0`. Splitting the two roles removes that undo:
861
+ * bubbles get bubble utilities, wrappers get layout utilities, and neither
862
+ * has to know about the other.
863
+ *
864
+ * The squared corner nearest the sender is the one carrying information: it
865
+ * is how a glance tells user from assistant before any colour is read.
866
+ */
867
+ private _msgEl(role: string, text: string): HTMLElement {
868
+ const el = document.createElement('div');
869
+ const base =
870
+ 'max-w-[76%] whitespace-pre-wrap break-words px-3.5 py-2.5 text-sm leading-relaxed';
871
+ const byRole =
872
+ role === 'user'
873
+ ? 'self-end bg-blue text-white rounded-2xl rounded-br-md'
874
+ : 'self-start bg-surface text-ink border border-border-hair rounded-2xl rounded-bl-md shadow-resting';
875
+ el.className = `msg ${role} ${base} ${byRole}`;
876
+ fillMessage(el, text);
877
+ return el;
878
+ }
879
+
880
+ /**
881
+ * A tool outcome.
882
+ *
883
+ * Not a bubble, since nobody said it. Styled as a system line: full width, mono
884
+ * eyebrow for the tool name, and the result beneath in body text. Colour
885
+ * carries the outcome (design.md §2: green is a positive result, and error
886
+ * red is reserved for actual failure).
887
+ */
888
+ private _toolEl(name: string, text: string, isError: boolean): HTMLElement {
889
+ const el = document.createElement('div');
890
+ el.className =
891
+ 'msg tool self-stretch w-full rounded-card border px-3.5 py-2.5 ' +
892
+ (isError
893
+ ? 'bg-surface border-red/25 text-red'
894
+ : 'bg-surface border-border-hair text-green-strong');
895
+ const head = document.createElement('b');
896
+ head.className =
897
+ 'flex items-center gap-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em]';
898
+ head.innerHTML = `${isError ? ICON_WARN : ICON_CHECK}${escapeHtml(name)}`;
899
+
900
+ const body = document.createElement('div');
901
+ body.className = 'mt-1 font-sans text-[13px] leading-normal text-ink-2';
902
+ // A tool's own sentence is worth showing; its raw payload is for whoever
903
+ // is building the agent, and lives in the trace, which `debug` governs.
904
+ const shown =
905
+ this.debug || !looksMachine(text)
906
+ ? text
907
+ : `${humanize(name)} could not be completed.`;
908
+ fillMessage(body, shown);
909
+
910
+ el.append(head, body);
911
+ return el;
912
+ }
913
+
914
+ private _detailEl(item: { title: string; note: string; rows?: DetailRow[] }): HTMLElement {
915
+ const rows = (item.rows || [])
916
+ .map(
917
+ (r) =>
918
+ // Focusable, so a keyboard can open a truncated row the way a pointer does.
919
+ `<div class="dc-row" tabindex="0"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
920
+ `<dd title="${escapeHtml(r.value)}">${escapeHtml(r.value)}</dd></div>`,
921
+ )
922
+ .join('');
923
+ const el = document.createElement('div');
924
+ el.className = 'msg detail';
925
+ el.innerHTML = `
926
+ <div class="detailcard">
927
+ <div class="dc-badge">${ICON_CHECK}</div>
928
+ <div class="dc-eyebrow">Submitted</div>
929
+ <div class="dc-title">${escapeHtml(item.title)}</div>
930
+ <div class="dc-sub">${escapeHtml(item.note)}</div>
931
+ <dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
932
+ </div>`;
933
+ return el;
934
+ }
935
+
936
+ /**
937
+ * Draw one message.
938
+ *
939
+ * A message the user sent carries a row of actions, so `index` identifies
940
+ * which item it is: copy needs the text, and edit and send-again need to know
941
+ * how much of the conversation they will replace.
942
+ *
943
+ * @param role - Who sent the message.
944
+ * @param text - The message text.
945
+ * @param index - Its position in the conversation's items. Required for the
946
+ * action bar; omit it for messages that should not carry one.
947
+ * @returns The bubble element.
948
+ */
949
+ private _paintMsg(role: string, text: string, index?: number): HTMLElement {
950
+ this._clearEmpty();
951
+ const el = this._msgEl(role, text);
952
+ if (role !== 'user' || index === undefined) {
953
+ this._els.thread.appendChild(el);
954
+ return el;
955
+ }
956
+ const turn = document.createElement('div');
957
+ turn.className = 'turn';
958
+ // Focusable so the row can be reached without a pointer: `:focus-within`
959
+ // is what reveals it, and a bubble that cannot take focus never fires it.
960
+ turn.tabIndex = -1;
961
+ turn.dataset.item = String(index);
962
+ turn.appendChild(el);
963
+ turn.appendChild(this._actionBar(index, text));
964
+ this._els.thread.appendChild(turn);
965
+ return el;
966
+ }
967
+
968
+ /** Copy, edit, send again, under the person's own message. */
969
+ private _actionBar(index: number, text: string): HTMLElement {
970
+ const bar = document.createElement('div');
971
+ bar.className = 'actions';
972
+ bar.innerHTML =
973
+ `<button class="act" data-act="copy" type="button" aria-label="Copy message" title="Copy">${ICON_COPY}</button>` +
974
+ `<button class="act" data-act="edit" type="button" aria-label="Edit message" title="Edit">${ICON_PENCIL}</button>` +
975
+ `<button class="act" data-act="resend" type="button" aria-label="Send again" title="Send again">${ICON_REDO}</button>`;
976
+
977
+ const copy = bar.querySelector<HTMLButtonElement>('[data-act="copy"]')!;
978
+ copy.addEventListener('click', () => {
979
+ // The tick is the confirmation; a toast would be redundant.
980
+ navigator.clipboard?.writeText(text).catch(() => {});
981
+ copy.innerHTML = ICON_CHECK_MARK;
982
+ copy.classList.add('done');
983
+ copy.setAttribute('aria-label', 'Copied');
984
+ setTimeout(() => {
985
+ copy.innerHTML = ICON_COPY;
986
+ copy.classList.remove('done');
987
+ copy.setAttribute('aria-label', 'Copy message');
988
+ }, 1400);
989
+ });
990
+
991
+ bar
992
+ .querySelector('[data-act="resend"]')!
993
+ .addEventListener('click', () => void this._rerunFrom(index, text));
994
+ bar
995
+ .querySelector('[data-act="edit"]')!
996
+ .addEventListener('click', () => this._editMessage(index, text));
997
+ return bar;
998
+ }
999
+
1000
+ /**
1001
+ * Turn the bubble into a field where it already sits.
1002
+ *
1003
+ * Not the composer. Editing in the composer means looking at one copy of the
1004
+ * sentence while typing into another, and the one being changed is the one
1005
+ * further from the eye.
1006
+ */
1007
+ private _editMessage(index: number, text: string): void {
1008
+ const turn = this._els.thread.querySelector<HTMLElement>(`.turn[data-item="${index}"]`);
1009
+ if (!turn) return;
1010
+ const box = document.createElement('div');
1011
+ box.className = 'editing';
1012
+ box.innerHTML =
1013
+ `<textarea rows="2"></textarea>` +
1014
+ `<div class="edit-actions">` +
1015
+ `<button class="ebtn" data-e="cancel" type="button">Cancel</button>` +
1016
+ `<button class="ebtn primary" data-e="save" type="button">Send</button>` +
1017
+ `</div>`;
1018
+ const hint = document.createElement('div');
1019
+ hint.className = 'edit-hint';
1020
+ // Said before the commit, not after. Editing the first of five turns throws
1021
+ // away the four below it, because they answered a question that no longer
1022
+ // exists, and that is not something to discover by watching it happen.
1023
+ hint.textContent = 'everything below is replaced when this is sent';
1024
+
1025
+ const ta = box.querySelector('textarea')!;
1026
+ ta.value = text;
1027
+ turn.replaceWith(box);
1028
+ box.after(hint);
1029
+ ta.focus();
1030
+ ta.setSelectionRange(ta.value.length, ta.value.length);
1031
+
1032
+ const close = () => {
1033
+ hint.remove();
1034
+ box.remove();
1035
+ this._renderThread();
1036
+ };
1037
+ const send = () => {
1038
+ const next = ta.value.trim();
1039
+ if (!next) return;
1040
+ void this._rerunFrom(index, next);
1041
+ };
1042
+ box.querySelector('[data-e="cancel"]')!.addEventListener('click', close);
1043
+ box.querySelector('[data-e="save"]')!.addEventListener('click', send);
1044
+ ta.addEventListener('keydown', (e) => {
1045
+ if (e.key === 'Escape') {
1046
+ e.stopPropagation();
1047
+ close();
1048
+ }
1049
+ if (e.key === 'Enter' && !e.shiftKey) {
1050
+ e.preventDefault();
1051
+ send();
1052
+ }
1053
+ });
1054
+ }
1055
+
1056
+ /**
1057
+ * Send a message again from this point, replacing everything below it.
1058
+ *
1059
+ * Discards the following items and messages from both the thread and the
1060
+ * transcript, since they answered a question that no longer exists.
1061
+ *
1062
+ * @param index - The item index of the message to resend.
1063
+ * @param text - The message to send, edited or unchanged.
1064
+ */
1065
+ private async _rerunFrom(index: number, text: string): Promise<void> {
1066
+ if (!this._active()) return;
1067
+ // The turn in flight is answering the question being replaced.
1068
+ if (this._turnActive) {
1069
+ this._stopTurn();
1070
+ await this._turnDone;
1071
+ }
1072
+ this._truncateFrom(index);
1073
+ this._renderThread();
1074
+ this._els.input.value = text;
1075
+ this._autosize();
1076
+ await this._onSend();
1077
+ }
1078
+
1079
+ /**
1080
+ * Drop every item and every message from `index` down, the message itself
1081
+ * included: it is about to be sent again.
1082
+ */
1083
+ private _truncateFrom(index: number): void {
1084
+ const conv = this._active();
1085
+ if (!conv) return;
1086
+ let turns = 0;
1087
+ for (let i = 0; i < index; i++) {
1088
+ const it = conv.items[i];
1089
+ if (it.kind === 'msg' && it.role === 'user') turns++;
1090
+ }
1091
+ // Tool results are written into the transcript as user turns too, so the
1092
+ // person's own messages are the unbracketed ones.
1093
+ let seen = 0;
1094
+ let cut = conv.messages.length;
1095
+ for (let i = 0; i < conv.messages.length; i++) {
1096
+ const m = conv.messages[i];
1097
+ if (m.role !== 'user' || m.content.startsWith('[')) continue;
1098
+ if (seen++ === turns) {
1099
+ cut = i;
1100
+ break;
1101
+ }
1102
+ }
1103
+ conv.messages.length = cut;
1104
+ conv.items.length = index;
1105
+ this._saveConversations();
1106
+ }
1107
+ /**
1108
+ * The receipt for a tool call: one muted line, collapsed.
1109
+ *
1110
+ * `<details>` rather than a button and a class, because the summary is a
1111
+ * real disclosure control, so keyboard, screen reader and find-in-page all
1112
+ * work without anything being wired up. Closed by default: this is the row
1113
+ * a person scrolls past, and opens only when the answer above it looks
1114
+ * wrong.
1115
+ */
1116
+ private _traceEl(item: Extract<ThreadItem, { kind: 'trace' }>): HTMLElement {
1117
+ const el = document.createElement('details');
1118
+ el.className = `trace${item.isError ? ' err' : ''}`;
1119
+
1120
+ const summary = document.createElement('summary');
1121
+ const verb = item.isError ? 'failed' : 'ran';
1122
+ const outcome = item.isError
1123
+ ? 'error'
1124
+ : typeof item.count === 'number'
1125
+ ? `${item.count} result${item.count === 1 ? '' : 's'}`
1126
+ : 'ok';
1127
+ summary.innerHTML =
1128
+ `<span class="caret" aria-hidden="true"></span>` +
1129
+ `<span>${escapeHtml(humanize(item.name))} ${verb} · ${escapeHtml(outcome)}</span>`;
1130
+ el.appendChild(summary);
1131
+
1132
+ const body = document.createElement('div');
1133
+ body.className = 'trace-body';
1134
+ const args = JSON.stringify(item.args ?? {}, null, 2);
1135
+ body.innerHTML =
1136
+ `<span class="trace-label">Call</span>` +
1137
+ `<pre>${escapeHtml(`${item.name}(${args === '{}' ? '' : args})`)}</pre>` +
1138
+ `<span class="trace-label">Response</span>` +
1139
+ `<pre class="raw">${escapeHtml(item.text || '(empty)')}</pre>`;
1140
+ el.appendChild(body);
1141
+ return el;
1142
+ }
1143
+
1144
+ /**
1145
+ * A journey as a card in the thread.
1146
+ *
1147
+ * Open while it runs, so the step being taken is on screen; folded to one
1148
+ * line once it finishes, so the thread reads as a record rather than a wall
1149
+ * of finished checklists. A `<details>` either way, so a folded journey can
1150
+ * still be opened.
1151
+ */
1152
+ private _journeyEl(item: Extract<ThreadItem, { kind: 'journey' }>): HTMLDetailsElement {
1153
+ const view = viewJourney(item.steps);
1154
+ const el = document.createElement('details');
1155
+ el.className = `msg journey is-${view?.outcome ?? 'running'}`;
1156
+ el.dataset.journey = item.id;
1157
+ el.open = view?.outcome === 'running';
1158
+ if (view?.active) el.dataset.active = view.active.label;
1159
+
1160
+ const summary = document.createElement('summary');
1161
+ const mark = document.createElement('span');
1162
+ mark.className = 'j-sum-mark';
1163
+ mark.setAttribute('aria-hidden', 'true');
1164
+ if (view?.outcome === 'done') mark.textContent = '✓';
1165
+ if (view?.outcome === 'failed') mark.textContent = '!';
1166
+ const title = document.createElement('span');
1167
+ title.className = 'j-title';
1168
+ title.textContent = view?.title ?? 'Working on it';
1169
+ const state = document.createElement('span');
1170
+ state.className = 'j-state';
1171
+ // Running, the header says what is happening now; finished, it says how
1172
+ // it ended, which is the one thing a folded card has to carry.
1173
+ state.textContent =
1174
+ view?.outcome === 'running' ? (view.active?.label ?? '') : (view?.summary.split(' · ')[1] ?? '');
1175
+ summary.append(mark, title, state);
1176
+ el.appendChild(summary);
1177
+
1178
+ const list = document.createElement('ol');
1179
+ list.className = 'j-steps';
1180
+ (view?.steps ?? []).forEach((step, i) => {
1181
+ const li = document.createElement('li');
1182
+ li.className = `j-step is-${step.status}`;
1183
+ const m = document.createElement('span');
1184
+ m.className = 'j-mark';
1185
+ m.textContent = step.status === 'done' ? '✓' : step.status === 'failed' ? '!' : String(i + 1);
1186
+ const label = document.createElement('span');
1187
+ label.className = 'j-label';
1188
+ // Text throughout: a label can carry a business name from the site's data.
1189
+ label.textContent = step.label;
1190
+ label.title = step.detail ? `${step.label} — ${step.detail}` : step.label;
1191
+ if (step.detail) {
1192
+ const small = document.createElement('small');
1193
+ small.textContent = step.detail;
1194
+ label.appendChild(small);
1195
+ }
1196
+ li.append(m, label);
1197
+ list.appendChild(li);
1198
+ });
1199
+ el.appendChild(list);
1200
+ return el;
1201
+ }
1202
+
1203
+ /**
1204
+ * Keep a journey's card, and its record in the conversation, up to date.
1205
+ *
1206
+ * Looks for the journey in every conversation before assuming it is new:
1207
+ * on arrival after a page change the resume records steps before the
1208
+ * conversation they came from has been reopened, and those steps belong
1209
+ * with the card saved on the page before, not in whichever thread is open.
1210
+ */
1211
+ private _onJourney(all: AgentStep[]): void {
1212
+ const view = viewJourney(all);
1213
+ if (!view?.id) return;
1214
+
1215
+ type JourneyItem = Extract<ThreadItem, { kind: 'journey' }>;
1216
+ const isThis = (it: ThreadItem): it is JourneyItem => it.kind === 'journey' && it.id === view.id;
1217
+ let conv = this._conversations.find((c) => c.items.some(isThis));
1218
+ let item = conv?.items.find(isThis);
1219
+ if (conv && item) {
1220
+ item.steps = all;
1221
+ } else {
1222
+ conv = this._active() ?? undefined;
1223
+ if (!conv) return;
1224
+ item = { kind: 'journey', id: view.id, steps: all };
1225
+ conv.items.push(item);
1226
+ }
1227
+
1228
+ if (conv.id === this._activeId && this._els?.thread) {
1229
+ const fresh = this._journeyEl(item);
1230
+ const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
1231
+ if (old) {
1232
+ // Keep a folded card open if someone opened it to read.
1233
+ if ((old as HTMLDetailsElement).open && view.outcome !== 'running') fresh.open = true;
1234
+ old.replaceWith(fresh);
1235
+ } else {
1236
+ this._clearEmpty();
1237
+ this._els.thread.appendChild(fresh);
1238
+ this._scroll();
1239
+ }
1240
+ this._watchRunningJourney();
1241
+ }
1242
+ this._saveConversations();
1243
+ }
1244
+
1245
+ /**
1246
+ * Show the status line only while the running journey is out of view.
1247
+ *
1248
+ * The card is where the steps are; the line exists for the moment a long
1249
+ * reply pushes it off the top of the thread mid-action, and hides again as
1250
+ * soon as the card is back.
1251
+ */
1252
+ private _watchRunningJourney(): void {
1253
+ this._journeyObserver?.disconnect();
1254
+ this._journeyObserver = null;
1255
+ const bar = this._els?.jstatus;
1256
+ if (!bar) return;
1257
+ const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
1258
+ const card = cards[cards.length - 1];
1259
+ if (!card || typeof IntersectionObserver === 'undefined') {
1260
+ bar.hidden = true;
1261
+ return;
1262
+ }
1263
+ (bar.querySelector('.js-label') as HTMLElement).textContent = card.dataset.active ?? '';
1264
+ this._journeyObserver = new IntersectionObserver(
1265
+ (entries) => {
1266
+ bar.hidden = entries[entries.length - 1].isIntersecting;
1267
+ },
1268
+ { root: this._els.thread, threshold: 0.2 },
1269
+ );
1270
+ this._journeyObserver.observe(card);
1271
+ }
1272
+
1273
+ private _paintToolResult(name: string, text: string, isError: boolean): HTMLElement {
1274
+ this._clearEmpty();
1275
+ const el = this._toolEl(name, text, isError);
1276
+ this._els.thread.appendChild(el);
1277
+ return el;
1278
+ }
1279
+
1280
+ /** Append a bubble both to the persisted thread and the DOM. */
1281
+ /**
1282
+ * Call off the turn in flight.
1283
+ *
1284
+ * The note left in the thread is presented neutrally: a stop is neither an
1285
+ * error nor a refusal, so it is not styled as a warning.
1286
+ */
1287
+ private _stopTurn(): void {
1288
+ if (!this._stoppable) return;
1289
+ this._setStoppable(false);
1290
+ (this._coordinator ?? this._harness).stop();
1291
+ const conv = this._active();
1292
+ if (conv) {
1293
+ conv.items.push({ kind: 'stopped' });
1294
+ this._saveConversations();
1295
+ }
1296
+ this._paintStopped();
1297
+ this._setStatus('');
1298
+ this._scroll();
1299
+ this._els.input.focus();
1300
+ }
1301
+
1302
+ private _paintStopped(): HTMLElement {
1303
+ const el = document.createElement('div');
1304
+ el.className = 'stopped-note';
1305
+ el.textContent = 'Stopped';
1306
+ this._els.thread.appendChild(el);
1307
+ return el;
1308
+ }
1309
+
1310
+ /** Swap the composer button between sending and stopping. */
1311
+ private _setStoppable(canStop: boolean): void {
1312
+ if (this._stoppable === canStop) return;
1313
+ this._stoppable = canStop;
1314
+ const go = this._els.go;
1315
+ go.classList.toggle('stop', canStop);
1316
+ go.innerHTML = canStop ? ICON_STOP : ICON_ARROW;
1317
+ go.setAttribute('aria-label', canStop ? 'Stop' : 'Send');
1318
+ // The model's dot pulses while it is working, so the two controls agree.
1319
+ this._els.model.classList.toggle('busy', canStop);
1320
+ // Enabled either way: disabling it during a turn would disable the very
1321
+ // control needed to stop that turn.
1322
+ go.disabled = false;
1323
+ }
1324
+
1325
+ private _appendMsg(role: string, text: string): HTMLElement {
1326
+ const conv = this._active()!;
1327
+ conv.items.push({ kind: 'msg', role, text });
1328
+ const el = this._paintMsg(role, text, conv.items.length - 1);
1329
+ this._saveConversations();
1330
+ this._scroll();
1331
+ return el;
1332
+ }
1333
+
1334
+ /**
1335
+ * The assistant's bubble for one phase of a turn.
1336
+ *
1337
+ * Starts as an animated thinking indicator rather than a static ellipsis: a
1338
+ * turn can be silent for tens of seconds while weights download, and a
1339
+ * motionless dot is indistinguishable from a hung page. The first token
1340
+ * replaces the indicator with text, and only a non-empty `finalize` persists
1341
+ * the bubble into the stored conversation.
1342
+ */
1343
+ private _appendStreaming(): StreamingBubble {
1344
+ const el = this._paintMsg('assistant', '');
1345
+ el.classList.add('thinking');
1346
+ el.innerHTML = THINKING_MARKUP;
1347
+ // The bubble is a live region so a screen reader hears the phase change
1348
+ // without the focus moving to it.
1349
+ el.setAttribute('aria-live', 'polite');
1350
+ this._scroll();
1351
+
1352
+ const setLabel = (label?: string) => {
1353
+ const node = el.querySelector('.think-label');
1354
+ if (node) node.textContent = label ?? '';
1355
+ };
1356
+
1357
+ /**
1358
+ * Show byte counts for the current phase, or nothing.
1359
+ *
1360
+ * Only the weight download reports totals. The runtime fetch and the warm-up
1361
+ * do not, and those phases show the dots alone rather than an empty readout.
1362
+ */
1363
+ const setProgress = (_pct: number, detail?: ProgressDetail) => {
1364
+ const bytes = el.querySelector('.think-bytes');
1365
+ if (!bytes) return;
1366
+ // Figures rather than a bar: the ring on the header mark already carries
1367
+ // the proportion, and what it cannot show is how much is left in bytes.
1368
+ bytes.textContent = detail?.total
1369
+ ? `${formatBytes(detail.loaded)} / ${formatBytes(detail.total)}`
1370
+ : '';
1371
+ };
1372
+
1373
+ const bubble: StreamingBubble & { setProgress: typeof setProgress } = {
1374
+ // Naming a new phase reuses the same bubble, so a turn that loads, then
1375
+ // discovers, then generates remains one element in the thread rather than
1376
+ // three that appear and vanish.
1377
+ thinking: (label) => {
1378
+ if (!el.classList.contains('thinking')) {
1379
+ el.classList.add('thinking');
1380
+ el.innerHTML = THINKING_MARKUP;
1381
+ }
1382
+ setLabel(label);
1383
+ this._scroll();
1384
+ },
1385
+ // `t` is the whole answer so far, not the newest fragment; assigning it
1386
+ // is correct; appending would repeat the answer on every token.
1387
+ update: (t) => {
1388
+ if (!t) return;
1389
+ // First token: drop the indicator and become an ordinary bubble. The
1390
+ // live region goes with it, since announcing a streaming answer word by
1391
+ // word would talk over the reader.
1392
+ if (el.classList.contains('thinking')) {
1393
+ el.classList.remove('thinking');
1394
+ el.removeAttribute('aria-live');
1395
+ el.innerHTML = '';
1396
+ if (this._liveBubble === bubble) this._liveBubble = null;
1397
+ }
1398
+ el.textContent = t;
1399
+ this._scroll();
1400
+ },
1401
+ // Empty text means the phase had nothing to leave behind (a finished
1402
+ // tool call, a failed load), so the bubble goes rather than lingering as
1403
+ // a blank one. Anything else is persisted; a pending indicator never is.
1404
+ finalize: (t) => {
1405
+ el.classList.remove('thinking');
1406
+ el.removeAttribute('aria-live');
1407
+ if (this._liveBubble === bubble) this._liveBubble = null;
1408
+ if (!t) {
1409
+ el.remove();
1410
+ return;
1411
+ }
1412
+ el.textContent = t;
1413
+ this._active()!.items.push({ kind: 'msg', role: 'assistant', text: t });
1414
+ this._saveConversations();
1415
+ this._scroll();
1416
+ },
1417
+ setProgress,
1418
+ };
1419
+
1420
+ // The engine reports progress to the element, not through the turn loop,
1421
+ // so the element has to know which bubble is currently on screen.
1422
+ this._liveBubble = bubble;
1423
+ return bubble;
1424
+ }
1425
+
1426
+ // ── Turn loop ──────────────────────────────────────────────────────────────
1427
+ private async _onSend(): Promise<void> {
1428
+ const goal = this._els.input.value.trim();
1429
+ if (!goal || this._turnActive) return;
1430
+ this._els.input.value = '';
1431
+ this._autosize();
1432
+ this._appendMsg('user', goal);
1433
+
1434
+ const conv = this._active();
1435
+ if (conv && (!conv.title || conv.title === 'New conversation')) {
1436
+ conv.title = goal.length > 40 ? goal.slice(0, 40) + '…' : goal;
1437
+ this._renderHistory();
1438
+ this._saveConversations();
1439
+ }
1440
+
1441
+ this._turnActive = true;
1442
+ this._setStoppable(true);
1443
+ let settle!: () => void;
1444
+ this._turnDone = new Promise<void>((r) => (settle = r));
1445
+ try {
1446
+ // The toolset has to be complete before the model is asked to choose from
1447
+ // it. Discovery normally finished while the weights were downloading; this
1448
+ // only waits when a message is sent the instant the panel opens.
1449
+ await this._connectTools();
1450
+ this._syncProfileContext();
1451
+ // One agent, or several with a router in front: the signatures match.
1452
+ await (this._coordinator ?? this._harness).turn(goal, conv!.messages);
1453
+ // A remote endpoint reports which model answered; the name beside the
1454
+ // input follows it rather than keeping whatever was configured.
1455
+ this._paintProvenance();
1456
+ } catch (err) {
1457
+ // A cancelled turn has already reported itself in the thread, and is not
1458
+ // a failure to report a second time.
1459
+ if (!isAbort(err)) this._reportEngineError(err);
1460
+ } finally {
1461
+ this._turnActive = false;
1462
+ this._setStoppable(false);
1463
+ this._turnDone = null;
1464
+ settle();
1465
+ }
1466
+ }
1467
+
1468
+ // ── HarnessRenderer ────────────────────────────────────────────────────────
1469
+ /**
1470
+ * The element's implementation of {@link HarnessRenderer}.
1471
+ *
1472
+ * Drawing and reporting back only: whether to ask, what to ask for, and when
1473
+ * to run a tool are all decided by the harness.
1474
+ */
1475
+ private _renderer(): HarnessRenderer {
1476
+ return {
1477
+ message: (role, text) => this._appendMsg(role, text),
1478
+ streaming: (): HStreamingBubble => this._appendStreaming(),
1479
+ status: (text, isError) => this._setStatus(text, isError),
1480
+ ask: (req) => this._renderAsk(req),
1481
+ confirm: (req) => this._renderConfirm(req),
1482
+ results: (tool, res) => {
1483
+ const conv = this._active();
1484
+ const item = this._buildResultsItem(tool, res);
1485
+ // A read that answers in words — "you are on the home page", "already open" — has no records
1486
+ // to draw, and an empty result card reading "No results found." contradicts the answer it
1487
+ // just gave. Say what it said.
1488
+ const prose = item.cards.length ? '' : proseAnswer(res);
1489
+ if (prose) {
1490
+ this._appendMsg('assistant', prose);
1491
+ this._upsertMetrics();
1492
+ this._saveConversations();
1493
+ this._scroll();
1494
+ return;
1495
+ }
1496
+ conv?.items.push(item);
1497
+ this._els.thread.appendChild(this._resultsEl(item));
1498
+ this._upsertMetrics();
1499
+ this._saveConversations();
1500
+ this._scroll();
1501
+ },
1502
+ detail: (tool, values, text) => {
1503
+ const conv = this._active();
1504
+ const item = this._buildDetailItem(tool, values, text);
1505
+ conv?.items.push(item);
1506
+ this._els.thread.appendChild(this._detailEl(item));
1507
+ this._saveConversations();
1508
+ this._scroll();
1509
+ },
1510
+ trace: ({ tool, args, text, isError, count }) => {
1511
+ // Not recorded at all when debugging is off. See the `debug` getter.
1512
+ if (!this.debug) return;
1513
+ const item: Extract<ThreadItem, { kind: 'trace' }> = {
1514
+ kind: 'trace',
1515
+ name: tool.name,
1516
+ args,
1517
+ text,
1518
+ isError,
1519
+ count,
1520
+ };
1521
+ this._active()?.items.push(item);
1522
+ this._els.thread.appendChild(this._traceEl(item));
1523
+ this._saveConversations();
1524
+ this._scroll();
1525
+ },
1526
+ toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
1527
+ dismissPrompt: () => this._dismissPrompt(),
1528
+ // The harness says when stopping stops being possible, because it is the
1529
+ // part that knows a tool has been invoked. Once a call is out, offering
1530
+ // to stop would be offering to un-send a booking.
1531
+ stoppable: (canStop) => this._setStoppable(canStop),
1532
+ };
1533
+ }
1534
+
1535
+ private _dismissPrompt(): void {
1536
+ if (this._prompt) {
1537
+ try { this._prompt.dispose(); } catch { /* already gone */ }
1538
+ this._prompt = null;
1539
+ }
1540
+ if (this._formEl && this._formEl.isConnected) this._formEl.remove();
1541
+ this._formEl = null;
1542
+ }
1543
+
1544
+ /** Mount an A2UI prompt surface into the thread. */
1545
+ private _mountPrompt(
1546
+ messages: ReturnType<typeof emitAsk>,
1547
+ onAction: (name: string, ctx: Record<string, unknown>) => void,
1548
+ ): MountedSurface | null {
1549
+ this._clearEmpty();
1550
+ this._dismissPrompt();
1551
+
1552
+ const wrap = document.createElement('div');
1553
+ wrap.className = 'form a2ui a2ui-scope self-start w-full max-w-[420px]';
1554
+ const mounted = mountSurface(wrap, messages, (event: any) => {
1555
+ const name = event?.event?.name ?? event?.name ?? '';
1556
+ const ctx = (event?.event?.context ?? event?.context ?? {}) as Record<string, unknown>;
1557
+ onAction(String(name), ctx);
1558
+ });
1559
+ if (!mounted) return null;
1560
+
1561
+ this._els.thread.appendChild(wrap);
1562
+ this._formEl = wrap;
1563
+ this._prompt = mounted;
1564
+ this._surfaces.push(mounted);
1565
+ this._scroll();
1566
+ return mounted;
1567
+ }
1568
+
1569
+ /** Gaps to fill — one question, or a wizard when the plan has several. */
1570
+ private _renderAsk(req: AskRequest): void {
1571
+ const { tool, plan } = req;
1572
+ if (!plan.steps.length) {
1573
+ req.onComplete({});
1574
+ return;
1575
+ }
1576
+
1577
+ let step = 0;
1578
+ const mounted = this._mountPrompt(
1579
+ emitAsk(tool.name, plan, req.resolution.values, 'ask'),
1580
+ (name, ctx) => {
1581
+ if (name === 'nxis.cancel') { req.onCancel(); return; }
1582
+
1583
+ if (name === 'nxis.back') {
1584
+ step = Math.max(0, step - 1);
1585
+ this._prompt?.send(emitStep('ask', tool.name, plan, step));
1586
+ return;
1587
+ }
1588
+
1589
+ if (name === 'nxis.next') {
1590
+ // Do not advance past an unanswered question: the step exists
1591
+ // precisely because this value is unknown.
1592
+ const form = (this._prompt?.read('form') ?? {}) as Record<string, unknown>;
1593
+ const unanswered = plan.steps[step].slots.filter(
1594
+ (sl) => { const v = form[sl.name]; return v === undefined || v === null || String(v).trim() === ''; },
1595
+ );
1596
+ if (unanswered.length) {
1597
+ this._setStatus(`Please answer: ${unanswered.map((sl) => humanize(sl.name)).join(', ')}`, true);
1598
+ return;
1599
+ }
1600
+ this._setStatus('');
1601
+ step = Math.min(plan.steps.length - 1, step + 1);
1602
+ this._prompt?.send(emitStep('ask', tool.name, plan, step));
1603
+ return;
1604
+ }
1605
+
1606
+ if (name === 'nxis.submit') {
1607
+ const form = (this._prompt?.read('form') ?? {}) as Record<string, unknown>;
1608
+ req.onComplete(form);
1609
+ }
1610
+ void ctx;
1611
+ },
1612
+ );
1613
+
1614
+ // A renderer failure must not strand the turn; fall back to the DOM form.
1615
+ if (!mounted) this._renderFormLegacy(tool, req.resolution.values);
1616
+ }
1617
+
1618
+ /**
1619
+ * Show what a write will send, and wait for the user to submit it.
1620
+ *
1621
+ * Drawn as DOM rather than an A2UI surface so the confirmation matches the
1622
+ * detail card it becomes once submitted. A2UI's basic catalog cannot express
1623
+ * that styling: each component nests its own shadow root, and the `--a2ui-*`
1624
+ * properties that reach inside are per component type rather than per
1625
+ * instance. `emitConfirm` remains available for renderers that prefer the
1626
+ * surface.
1627
+ */
1628
+ private _renderConfirm(req: ConfirmRequest): void {
1629
+ this._clearEmpty();
1630
+ this._dismissPrompt();
1631
+
1632
+ const rows = req.slots
1633
+ .map((sl) => {
1634
+ const label = escapeHtml(sl.schema.description || humanize(sl.name));
1635
+ const value = escapeHtml(String(sl.value ?? ''));
1636
+ return `<div class="dc-row" tabindex="0"><dt title="${label}">${label}</dt><dd title="${value}">${value}</dd></div>`;
1637
+ })
1638
+ .join('');
1639
+ // Where pressing submit leads, when that is another page. Said here in a
1640
+ // sentence so the button itself can stay short enough for a narrow card.
1641
+ const sub = req.destination
1642
+ ? `Submitting opens the page for ${escapeHtml(req.destination)} and sends it from there.`
1643
+ : 'Review these details, then submit.';
1644
+
1645
+ const wrap = document.createElement('div');
1646
+ wrap.className = 'msg detail';
1647
+ wrap.innerHTML = `
1648
+ <div class="detailcard">
1649
+ <div class="dc-badge review">${ICON_ARROW}</div>
1650
+ <div class="dc-eyebrow review">Ready to send</div>
1651
+ <div class="dc-title">${escapeHtml(humanize(req.tool.name))}</div>
1652
+ <div class="dc-sub">${sub}</div>
1653
+ <dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
1654
+ <div class="dc-actions">
1655
+ <button type="button" class="btn ghost" data-act="cancel">Cancel</button>
1656
+ <button type="button" class="btn ghost" data-act="edit">Edit</button>
1657
+ <button type="button" class="btn primary" data-act="submit">${escapeHtml(
1658
+ req.submitLabel ?? 'Submit',
1659
+ )} ${ICON_ARROW}</button>
1660
+ </div>
1661
+ </div>`;
1662
+
1663
+ wrap.querySelector('[data-act="cancel"]')!.addEventListener('click', () => req.onCancel());
1664
+ wrap.querySelector('[data-act="edit"]')!.addEventListener('click', () => req.onEdit());
1665
+ wrap.querySelector('[data-act="submit"]')!.addEventListener('click', () => {
1666
+ const btn = wrap.querySelector('[data-act="submit"]') as HTMLButtonElement;
1667
+ btn.disabled = true;
1668
+ req.onSubmit(req.values);
1669
+ });
1670
+
1671
+ this._els.thread.appendChild(wrap);
1672
+ this._formEl = wrap;
1673
+ this._scroll();
1674
+ }
1675
+
1676
+ // ── Inline generated form (from JSON-Schema) ───────────────────────────────
1677
+ private _renderForm(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
1678
+ if (this._useA2ui) {
1679
+ try {
1680
+ this._renderFormA2ui(tool, args);
1681
+ return;
1682
+ } catch (err) {
1683
+ // Never strand the user without a form; fall through to the DOM one.
1684
+ console.warn('[a2ui] form surface failed, using inline form', err);
1685
+ }
1686
+ }
1687
+ this._renderFormLegacy(tool, args);
1688
+ }
1689
+
1690
+ /** The tool form as an A2UI surface rather than hand-built DOM. */
1691
+ private _renderFormA2ui(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
1692
+ this._clearEmpty();
1693
+ this._tool = tool;
1694
+
1695
+ const wrap = document.createElement('div');
1696
+ wrap.className = 'form a2ui a2ui-scope self-start w-full max-w-[420px]';
1697
+ const surfaceHost = document.createElement('div');
1698
+ // The status line stays outside the surface: reporting progress and errors
1699
+ // is the host's job, and the basic catalog has no component for it.
1700
+ const result = document.createElement('div');
1701
+ result.className = 'genform-result';
1702
+ wrap.append(surfaceHost, result);
1703
+
1704
+ const messages = emitToolForm(
1705
+ tool.name,
1706
+ (tool.description || '').trim(),
1707
+ toolSchema(tool),
1708
+ args,
1709
+ );
1710
+
1711
+ const mounted = mountSurface(surfaceHost, messages, (event: any) => {
1712
+ const name = event?.event?.name ?? event?.name;
1713
+ if (name === 'nxis.cancel') {
1714
+ this._cancelForm();
1715
+ return;
1716
+ }
1717
+ if (name !== 'nxis.submit') return;
1718
+
1719
+ const values = (mounted?.read('form') ?? {}) as Record<string, unknown>;
1720
+ const missing = missingRequired(tool, values);
1721
+ if (missing.length) {
1722
+ this._showFormError(result, `Please fill in: ${missing.map((m) => humanize(m)).join(', ')}.`);
1723
+ this._setStatus('');
1724
+ return;
1725
+ }
1726
+ void this._submitValues(tool, values, result, () => { /* button state lives in the surface */ });
1727
+ });
1728
+
1729
+ if (!mounted) throw new Error('no form surface produced');
1730
+ this._surfaces.push(mounted);
1731
+
1732
+ if (this._formEl && this._formEl.isConnected) this._formEl.remove();
1733
+ this._els.thread.appendChild(wrap);
1734
+ this._formEl = wrap;
1735
+ this._scroll();
1736
+ }
1737
+
1738
+ private _renderFormLegacy(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
1739
+ this._clearEmpty();
1740
+ this._tool = tool;
1741
+ const schema = toolSchema(tool);
1742
+ const props = schema.properties || {};
1743
+ const required = new Set(schema.required || []);
1744
+
1745
+ const fields = Object.entries(props)
1746
+ .map(([key, spec]) => {
1747
+ const id = `f_${key}`;
1748
+ const label = `${humanize(key)}${required.has(key) ? ' <span class="req">*</span>' : ''}`;
1749
+ return `<div class="field"><label for="${id}">${label}</label>${this._control(id, key, spec || {}, args[key])}</div>`;
1750
+ })
1751
+ .join('');
1752
+
1753
+ const wrap = document.createElement('div');
1754
+ wrap.className = 'msg form';
1755
+ wrap.innerHTML = `
1756
+ <form class="genform" novalidate>
1757
+ <div class="genform-head">
1758
+ <div class="gf-tool">${escapeHtml(humanize(tool.name))}</div>
1759
+ <div class="gf-desc">${escapeHtml((tool.description || '').trim())}</div>
1760
+ </div>
1761
+ ${fields || '<p class="gf-empty">This tool takes no inputs.</p>'}
1762
+ <div class="genform-actions">
1763
+ <button type="button" class="btn ghost" data-act="cancel">Cancel</button>
1764
+ <button type="submit" class="btn primary">Submit ${ICON_ARROW}</button>
1765
+ </div>
1766
+ <div class="genform-result"></div>
1767
+ </form>`;
1768
+
1769
+ if (this._formEl && this._formEl.isConnected) this._formEl.remove();
1770
+ this._els.thread.appendChild(wrap);
1771
+ this._formEl = wrap;
1772
+
1773
+ const form = wrap.querySelector('.genform') as HTMLFormElement;
1774
+ form.querySelector('[data-act="cancel"]')!.addEventListener('click', () => this._cancelForm());
1775
+ form.addEventListener('submit', (e) => {
1776
+ e.preventDefault();
1777
+ this._submitForm(tool, form);
1778
+ });
1779
+ const clearInvalid = (e: Event) =>
1780
+ (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.remove('invalid');
1781
+ form.addEventListener('input', clearInvalid);
1782
+ form.addEventListener('change', clearInvalid);
1783
+ this._scroll();
1784
+ }
1785
+
1786
+ private _cancelForm(): void {
1787
+ if (this._formEl) this._formEl.remove();
1788
+ this._formEl = null;
1789
+ this._tool = null;
1790
+ this._appendMsg('assistant', 'Okay — cancelled. Tell me what you’d like to do.');
1791
+ }
1792
+
1793
+ /** Build one input control from a property schema. */
1794
+ private _control(id: string, name: string, spec: JsonSchema, value: unknown): string {
1795
+ const enumVals = spec.enum || (spec.items && spec.items.enum);
1796
+ if (enumVals) {
1797
+ const opts = enumVals
1798
+ .map(
1799
+ (v) =>
1800
+ `<option value="${escapeHtml(String(v))}"${String(v) === String(value) ? ' selected' : ''}>${escapeHtml(humanize(String(v)))}</option>`,
1801
+ )
1802
+ .join('');
1803
+ return `<select id="${id}" data-name="${escapeHtml(name)}"><option value="">Select…</option>${opts}</select>`;
1804
+ }
1805
+ if (spec.type === 'boolean') {
1806
+ return `<label class="check"><input id="${id}" data-name="${escapeHtml(name)}" type="checkbox"${value ? ' checked' : ''}> Yes</label>`;
1807
+ }
1808
+ let type = 'text';
1809
+ if (spec.type === 'integer' || spec.type === 'number') type = 'number';
1810
+ else if (spec.format === 'date' || /\bdate\b/.test(name)) type = 'date';
1811
+ else if (/email/.test(name)) type = 'email';
1812
+
1813
+ let val = value == null ? '' : String(value);
1814
+ if (type === 'date' && val) {
1815
+ const parts = val.split(/[\/\-]/);
1816
+ if (parts.length === 3 && parts[0].length <= 2) {
1817
+ let y = parts[2];
1818
+ if (y.length === 2) y = (parseInt(y) > 50 ? '19' : '20') + y;
1819
+ val = `${y}-${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`;
1820
+ }
1821
+ }
1822
+ val = escapeHtml(val);
1823
+
1824
+ return `<input id="${id}" data-name="${escapeHtml(name)}" type="${type}" value="${val}">`;
1825
+ }
1826
+
1827
+ private _collect(form: HTMLElement): Record<string, unknown> {
1828
+ const out: Record<string, unknown> = {};
1829
+ form.querySelectorAll('[data-name]').forEach((node) => {
1830
+ const el = node as HTMLInputElement | HTMLSelectElement;
1831
+ const name = (el as HTMLElement).dataset.name!;
1832
+ if ((el as HTMLInputElement).type === 'checkbox') {
1833
+ out[name] = (el as HTMLInputElement).checked;
1834
+ return;
1835
+ }
1836
+ const v = el.value;
1837
+ if (v !== '' && v != null) out[name] = (el as HTMLInputElement).type === 'number' ? Number(v) : v;
1838
+ });
1839
+ return out;
1840
+ }
1841
+
1842
+ private async _submitForm(tool: ModelContextToolInfo, form: HTMLFormElement): Promise<void> {
1843
+ const result = form.querySelector('.genform-result') as HTMLElement;
1844
+ const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
1845
+ const values = this._collect(form);
1846
+
1847
+ // Highlighting the offending controls is specific to the DOM form; the
1848
+ // A2UI surface has no equivalent, so it stays here rather than in the
1849
+ // shared path below.
1850
+ form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
1851
+ const missing = missingRequired(tool, values);
1852
+ if (missing.length) {
1853
+ missing.forEach((m) => {
1854
+ let ctrl: Element | null = null;
1855
+ try {
1856
+ ctrl = form.querySelector(`[data-name="${CSS.escape(m)}"]`);
1857
+ } catch {
1858
+ /* ignore */
1859
+ }
1860
+ if (ctrl) ctrl.classList.add('invalid');
1861
+ });
1862
+ this._showFormError(result, `Please fill in: ${missing.map((m) => humanize(m)).join(', ')}.`);
1863
+ this._setStatus('');
1864
+ return;
1865
+ }
1866
+
1867
+ await this._submitValues(tool, values, result, (b) => (submitBtn.disabled = b));
1868
+ }
1869
+
1870
+ private _showFormError(result: HTMLElement, msg: string): void {
1871
+ result.className = 'genform-result error';
1872
+ result.innerHTML = `${ICON_WARN}<span>${escapeHtml(msg)}</span>`;
1873
+ }
1874
+
1875
+ /**
1876
+ * Everything after "we have valid values": run the tool, then swap the form
1877
+ * for a detail card or result cards.
1878
+ *
1879
+ * Shared by the DOM form and the A2UI surface, which differ only in how
1880
+ * values are collected and how invalid fields are flagged, so only that part
1881
+ * stays renderer-specific.
1882
+ */
1883
+ private async _submitValues(
1884
+ tool: ModelContextToolInfo,
1885
+ values: Record<string, unknown>,
1886
+ result: HTMLElement,
1887
+ setBusy: (busy: boolean) => void,
1888
+ ): Promise<void> {
1889
+ const submitBtn = { set disabled(b: boolean) { setBusy(b); } } as { disabled: boolean };
1890
+ submitBtn.disabled = true;
1891
+ result.className = 'genform-result';
1892
+ result.textContent = 'Submitting…';
1893
+ // Read before the call: a registry may return only the response's text,
1894
+ // and the annotations already say whether this call leaves the page.
1895
+ const leaving = handoffFor(tool, values);
1896
+ try {
1897
+ this._setStatus(`Running ${tool.name}…`);
1898
+ const res = await this.toolSource.execTool(tool, tool.name, values);
1899
+ const isError = !!(res && res.isError);
1900
+ const text = extractResult(res);
1901
+ this._setStatus('');
1902
+ if (isError) {
1903
+ result.className = 'genform-result error';
1904
+ result.innerHTML = `${ICON_WARN}<span>${escapeHtml(text || 'Failed')}</span>`;
1905
+ submitBtn.disabled = false;
1906
+ return;
1907
+ }
1908
+ // A call that left for another page has filled in nothing yet, so it
1909
+ // must not become a "Submitted" card. The page it is opening finishes
1910
+ // the job and draws the card there.
1911
+ if (leaving || res?.structuredContent?.[HANDOFF_RESULT]) {
1912
+ this._dismissPrompt();
1913
+ // From what we know, not from the response: a registry may hand back
1914
+ // the whole response object as JSON.
1915
+ this._appendMsg(
1916
+ 'assistant',
1917
+ leaving
1918
+ ? `Opening ${leaving.label} to submit this…`
1919
+ : 'Opening the page that can submit this…',
1920
+ );
1921
+ return;
1922
+ }
1923
+
1924
+ // Success → the form transitions inline. A write tool (form/action)
1925
+ // becomes a "Submitted" detail card; a read-only tool (typed retrieval)
1926
+ // becomes result cards for the records it returned.
1927
+ const conv = this._active()!;
1928
+ const readOnly = !!(tool.annotations && tool.annotations.readOnlyHint);
1929
+ let item: ThreadItem;
1930
+ let card: HTMLElement;
1931
+ if (readOnly) {
1932
+ const results = this._buildResultsItem(tool, res);
1933
+ const summary = results.cards
1934
+ .map((c) => c.title)
1935
+ .filter(Boolean)
1936
+ .slice(0, 8)
1937
+ .join(', ');
1938
+ conv.messages.push({
1939
+ role: 'user',
1940
+ content: `[${tool.name} returned ${results.note}${summary ? ': ' + summary : ''}]`,
1941
+ });
1942
+ // Attribute the typed-retrieval cost to the round that proposed the call.
1943
+ const round = conv.metrics && conv.metrics[conv.metrics.length - 1];
1944
+ if (round) {
1945
+ round.toolCalls += 1;
1946
+ round.results += results.cards.length;
1947
+ round.toolResultTokens += await this.countTokens(text);
1948
+ }
1949
+ item = results;
1950
+ card = this._resultsEl(results);
1951
+ } else {
1952
+ conv.messages.push({
1953
+ role: 'user',
1954
+ content: `[${tool.name} submitted with: ${JSON.stringify(values)}]. Keep these unless I ask to change them.`,
1955
+ });
1956
+ const detail = this._buildDetailItem(tool, values, text);
1957
+ item = detail;
1958
+ card = this._detailEl(detail);
1959
+ }
1960
+ conv.items.push(item);
1961
+ if (this._formEl && this._formEl.isConnected) this._formEl.replaceWith(card);
1962
+ else this._els.thread.appendChild(card);
1963
+ this._formEl = null;
1964
+ this._tool = null;
1965
+ if (readOnly) this._upsertMetrics();
1966
+ this._saveConversations();
1967
+ this._scroll();
1968
+ } catch (e) {
1969
+ result.className = 'genform-result error';
1970
+ result.innerHTML = `${ICON_WARN}<span>${escapeHtml(String((e as Error).message || e))}</span>`;
1971
+ submitBtn.disabled = false;
1972
+ }
1973
+ }
1974
+
1975
+ private _buildDetailItem(
1976
+ tool: ModelContextToolInfo,
1977
+ values: Record<string, unknown>,
1978
+ resultText: string,
1979
+ ): Extract<ThreadItem, { kind: 'detail' }> {
1980
+ const schema = toolSchema(tool);
1981
+ const props = schema.properties || {};
1982
+ const order = [...Object.keys(props), ...Object.keys(values).filter((k) => !(k in props))];
1983
+ const rows: DetailRow[] = order
1984
+ .filter((k) => values[k] !== undefined && values[k] !== null && values[k] !== '')
1985
+ .map((k) => {
1986
+ const spec = props[k] || {};
1987
+ let v: unknown = values[k];
1988
+ if (spec.type === 'boolean' || typeof v === 'boolean') v = v ? 'Yes' : 'No';
1989
+ else if (spec.enum || (spec.items && spec.items.enum)) v = humanize(String(v));
1990
+ return { label: humanize(k), value: String(v) };
1991
+ });
1992
+ const note =
1993
+ resultText && !/^\s*[[{]/.test(resultText) ? resultText : 'Your details were submitted successfully.';
1994
+ return { kind: 'detail', title: humanize(tool.name), note, rows };
1995
+ }
1996
+
1997
+ // ── Read-tool results → cards ──────────────────────────────────────────────
1998
+ /** Pull the array of records out of a tool response. The WebMCP polyfill's
1999
+ * result normalizer can drop `structuredContent`, so every source is tried:
2000
+ * structuredContent (object or JSON string) and each content text part, and
2001
+ * return the first that yields records. Shapes covered: query → results[],
2002
+ * list_types → types[], get → result{}, or a bare entity/array. */
2003
+ private _extractItems(res: ModelContextToolResponse | string | null): Record<string, unknown>[] {
2004
+ const sources: unknown[] = [];
2005
+ // Collect every candidate payload, recursively unwrapping tool-response
2006
+ // envelopes. `res` can be an object, a JSON string, or (with a WebMCP browser
2007
+ // extension in the loop) the whole `{content, structuredContent}` envelope
2008
+ // serialized to a string, possibly more than once. Diving into both
2009
+ // `structuredContent` and each `content[].text` covers all of them.
2010
+ const visit = (raw: unknown, depth = 0): void => {
2011
+ if (raw == null || depth > 5) return;
2012
+ const val: unknown = raw;
2013
+ if (typeof val === 'string') {
2014
+ const t = val.trim();
2015
+ if (!t) return;
2016
+ let parsed: unknown;
2017
+ try {
2018
+ parsed = JSON.parse(t);
2019
+ } catch {
2020
+ return; // plain (non-JSON) text — nothing to extract
2021
+ }
2022
+ // Parsed value may itself be an object/array OR another JSON string
2023
+ // (double-encoded); recurse either way.
2024
+ return visit(parsed, depth + 1);
2025
+ }
2026
+ if (!val || typeof val !== 'object') return;
2027
+ sources.push(val);
2028
+ const v = val as Record<string, unknown>;
2029
+ if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
2030
+ if (Array.isArray(v.content)) {
2031
+ for (const c of v.content) {
2032
+ if (c && typeof c === 'object') visit((c as Record<string, unknown>).text, depth + 1);
2033
+ }
2034
+ }
2035
+ };
2036
+ visit(res);
2037
+
2038
+ for (const data of sources) {
2039
+ const items = this._itemsFrom(data);
2040
+ if (items.length) return items;
2041
+ }
2042
+ console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
2043
+ return [];
2044
+ }
2045
+
2046
+ /** Find the record array within one parsed payload. */
2047
+ private _itemsFrom(data: unknown): Record<string, unknown>[] {
2048
+ const objs = (arr: unknown[]) =>
2049
+ arr.filter((x) => x && typeof x === 'object') as Record<string, unknown>[];
2050
+ if (Array.isArray(data)) return objs(data);
2051
+ if (!data || typeof data !== 'object') return [];
2052
+ const d = data as Record<string, unknown>;
2053
+ for (const key of ['results', 'types', 'items', 'entities', 'data']) {
2054
+ if (Array.isArray(d[key])) return objs(d[key] as unknown[]);
2055
+ }
2056
+ if (d.result && typeof d.result === 'object') return [d.result as Record<string, unknown>];
2057
+ if (d['@id'] != null || d['@type'] != null || d.name != null) return [d];
2058
+ return [];
2059
+ }
2060
+
2061
+ /** Build a display card from a single record (a JSON-LD entity or a type row). */
2062
+ private _entityCard(entity: Record<string, unknown>): ResultCard {
2063
+ const str = (v: unknown) => (v == null ? '' : String(Array.isArray(v) ? v[0] : v));
2064
+ const rawType = entity['@type'];
2065
+ const title = str(entity.name ?? entity.title ?? entity.headline ?? rawType ?? entity['@id'] ?? 'Result');
2066
+ const type = rawType ? humanize(str(rawType)) : '';
2067
+
2068
+ // Positive outcome (rating) → green tag, per the color rules.
2069
+ let rating: string | undefined;
2070
+ const ar = entity.aggregateRating as Record<string, unknown> | undefined;
2071
+ if (ar && typeof ar === 'object' && ar.ratingValue != null) {
2072
+ const stars = '★'.repeat(Math.max(1, Math.min(5, Math.round(Number(ar.ratingValue) || 0))));
2073
+ rating = ar.reviewCount != null ? `${stars} ${str(ar.ratingValue)} · ${str(ar.reviewCount)}` : `${stars} ${str(ar.ratingValue)}`;
2074
+ }
2075
+
2076
+ // 24/7 emergency service is only implied by round-the-clock opening hours.
2077
+ const hours = entity.openingHoursSpecification;
2078
+ const emergency =
2079
+ Array.isArray(hours) &&
2080
+ hours.some(
2081
+ (h) =>
2082
+ h &&
2083
+ typeof h === 'object' &&
2084
+ String((h as Record<string, unknown>).opens) === '00:00' &&
2085
+ String((h as Record<string, unknown>).closes) === '23:59',
2086
+ );
2087
+
2088
+ // One supporting line under the headline (feature-card pattern).
2089
+ const desc = str(entity.description).trim();
2090
+ const sub = desc ? (desc.length > 150 ? desc.slice(0, 147) + '…' : desc) : undefined;
2091
+
2092
+ const rows: DetailRow[] = [];
2093
+ const push = (label: string, value: unknown, href?: string) => {
2094
+ const v = str(value).trim();
2095
+ if (v && rows.length < 4 && !rows.some((r) => r.label === label)) {
2096
+ rows.push({ label, value: v.length > 90 ? v.slice(0, 87) + '…' : v, ...(href ? { href } : {}) });
2097
+ }
2098
+ };
2099
+
2100
+ const addr = entity.address as Record<string, unknown> | undefined;
2101
+ if (addr && typeof addr === 'object') {
2102
+ const parts = [addr.streetAddress, addr.addressLocality, addr.addressRegion].filter(Boolean).map(str);
2103
+ if (parts.length) push('Address', parts.join(', '));
2104
+ }
2105
+
2106
+ // Preferred scalar fields, then any remaining scalars as a fallback.
2107
+ const preferred = ['telephone', 'email', 'url', 'count'];
2108
+ const seen = new Set(['name', 'title', 'headline', 'image', 'description', ...preferred]);
2109
+ for (const key of preferred) {
2110
+ const v = entity[key];
2111
+ if (typeof v === 'string' || typeof v === 'number') {
2112
+ const href = key === 'telephone' ? `tel:${String(v).replace(/[^\d+]/g, '')}` : undefined;
2113
+ push(humanize(key === 'url' ? 'website' : key), v, href);
2114
+ }
2115
+ }
2116
+ if (rows.length < 3) {
2117
+ for (const [k, v] of Object.entries(entity)) {
2118
+ if (k.startsWith('@') || seen.has(k)) continue;
2119
+ if (typeof v === 'string' || typeof v === 'number') push(humanize(k), v);
2120
+ }
2121
+ }
2122
+
2123
+ const id = entity['@id'] != null ? str(entity['@id']) : undefined;
2124
+ const found = pageForEntity(entity, this._sitePages, location.origin);
2125
+ const page = found ? { url: found.url, label: found.name } : undefined;
2126
+ return { title, type, rating, emergency, sub, rows, id, ...(page ? { page } : {}) };
2127
+ }
2128
+
2129
+ private _buildResultsItem(
2130
+ tool: ModelContextToolInfo,
2131
+ res: ModelContextToolResponse,
2132
+ ): Extract<ThreadItem, { kind: 'results' }> {
2133
+ const items = this._extractItems(res);
2134
+ const cards = items.slice(0, 12).map((e) => this._entityCard(e));
2135
+ const note = !cards.length
2136
+ ? 'No results found.'
2137
+ : `${cards.length} result${cards.length === 1 ? '' : 's'}`;
2138
+ return { kind: 'results', title: humanize(tool.name), note, cards };
2139
+ }
2140
+
2141
+ /** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
2142
+ * supporting line, then mono-labelled detail rows. */
2143
+ /**
2144
+ * ResultCard → A2UI card data.
2145
+ *
2146
+ * Three things do not survive the trip, all catalog limits rather than
2147
+ * mapping choices: the tag colours (basic Text has no tone), the tel: links
2148
+ * on rows (no link component), and the @id line. They are dropped here
2149
+ * rather than faked so the difference is visible when comparing renderers.
2150
+ */
2151
+ private _toA2uiCards(cards: ResultCard[]): ResultCardData[] {
2152
+ return cards.map((c) => ({
2153
+ title: c.title,
2154
+ sub: c.sub ?? '',
2155
+ tags: [
2156
+ ...(c.type ? [{ label: c.type }] : []),
2157
+ ...(c.rating ? [{ label: c.rating }] : []),
2158
+ ...(c.emergency ? [{ label: '24/7' }] : []),
2159
+ ],
2160
+ rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
2161
+ }));
2162
+ }
2163
+
2164
+ /** Results rendered through A2UI instead of the bespoke card markup. */
2165
+ private _resultsElA2ui(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
2166
+ const wrap = document.createElement('div');
2167
+ wrap.className = 'results a2ui a2ui-scope self-stretch w-full';
2168
+ if (!item.cards.length) {
2169
+ wrap.innerHTML = `<div class="rc-empty">${escapeHtml(item.note)}</div>`;
2170
+ return wrap;
2171
+ }
2172
+ try {
2173
+ const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
2174
+ const mounted = mountSurface(wrap, messages);
2175
+ if (!mounted) throw new Error('no surface produced');
2176
+ this._surfaces.push(mounted);
2177
+ } catch (err) {
2178
+ // A renderer failure must not take the conversation with it.
2179
+ console.warn('[a2ui] results surface failed, using inline cards', err);
2180
+ return this._resultsElLegacy(item);
2181
+ }
2182
+ return wrap;
2183
+ }
2184
+
2185
+ /**
2186
+ * Typed-retrieval results.
2187
+ *
2188
+ * Drawn as DOM, for the same reason the review card is: the house result card
2189
+ * has a tinted logo chip, blue and green pill tags, a Manrope headline and
2190
+ * tel: links on its rows, and none of that is expressible in A2UI's basic
2191
+ * catalog. Each component nests its own shadow root, so only the global
2192
+ * --a2ui-* properties reach inside, and they are per component type, so there
2193
+ * is no way to say "this tag is a rating" from out here.
2194
+ *
2195
+ * emitCardsSurface remains in a2ui/cards.ts for renderers that want the
2196
+ * surface; a2ui="a2ui-results" opts this element back into it for comparison.
2197
+ */
2198
+ private _resultsEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
2199
+ return this.getAttribute('a2ui') === 'a2ui-results'
2200
+ ? this._resultsElA2ui(item)
2201
+ : this._resultsElLegacy(item);
2202
+ }
2203
+
2204
+ private _resultsElLegacy(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
2205
+ const wrap = document.createElement('div');
2206
+ wrap.className = 'msg results';
2207
+ if (!item.cards.length) {
2208
+ wrap.innerHTML = `<div class="rc-empty">${escapeHtml(item.note)}</div>`;
2209
+ return wrap;
2210
+ }
2211
+ const cardsHtml = item.cards
2212
+ .map((c) => {
2213
+ const rows = c.rows
2214
+ .map(
2215
+ (r) =>
2216
+ `<div class="rc-row"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
2217
+ `<dd title="${escapeHtml(r.value)}">${
2218
+ r.href ? `<a href="${escapeHtml(r.href)}">${escapeHtml(r.value)}</a>` : escapeHtml(r.value)
2219
+ }</dd></div>`,
2220
+ )
2221
+ .join('');
2222
+ const tags =
2223
+ (c.type ? `<span class="tag blue"><i class="dot"></i>${escapeHtml(c.type)}</span>` : '') +
2224
+ (c.rating ? `<span class="tag green"><i class="dot"></i>${escapeHtml(c.rating)}</span>` : '') +
2225
+ (c.emergency ? `<span class="tag green"><i class="dot"></i>24/7</span>` : '');
2226
+ return `<div class="resultcard">
2227
+ <div class="rc-top">
2228
+ <div class="rc-chip">${this._logoMarkup()}</div>
2229
+ <div class="rc-tags">${tags}</div>
2230
+ </div>
2231
+ <div class="rc-title" title="${escapeHtml(c.title)}">${escapeHtml(c.title)}</div>
2232
+ ${c.sub ? `<div class="rc-sub" title="${escapeHtml(c.sub)}">${escapeHtml(c.sub)}</div>` : ''}
2233
+ ${rows ? `<dl class="rc-list">${rows}</dl>` : ''}
2234
+ ${c.id ? `<div class="rc-id"><code>${escapeHtml(c.id)}</code></div>` : ''}
2235
+ ${
2236
+ c.page
2237
+ ? `<a class="rc-open" href="${escapeHtml(c.page.url)}" data-label="${escapeHtml(c.page.label)}" ` +
2238
+ `title="${escapeHtml(c.page.url)}">Open page<span aria-hidden="true">→</span></a>`
2239
+ : ''
2240
+ }
2241
+ </div>`;
2242
+ })
2243
+ .join('');
2244
+ wrap.innerHTML = `<div class="rc-head">${escapeHtml(item.note)}</div><div class="rc-cards">${cardsHtml}</div>`;
2245
+ // An ordinary link, so it opens in a new tab when asked to. Followed in
2246
+ // this one, the conversation goes with it.
2247
+ wrap.addEventListener('click', (e) => {
2248
+ const link = (e.target as Element).closest<HTMLAnchorElement>('a.rc-open');
2249
+ if (!link || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
2250
+ savePendingVisit({ pageUrl: link.href, label: link.dataset.label ?? '', conversationId: this._activeId ?? undefined });
2251
+ });
2252
+ return wrap;
2253
+ }
2254
+
2255
+ // ── Metrics panel (real Gemma tokenizer counts vs a RAG estimate) ──────────
2256
+ /** Keep one 'metrics' thread item, always at the end of the thread. */
2257
+ private _upsertMetrics(): void {
2258
+ // The comparison needs a tokenizer. A provider that cannot count would
2259
+ // render every figure as zero, which would read as a measurement.
2260
+ if (!this.provider?.countTokens) return;
2261
+ const conv = this._active();
2262
+ if (!conv || !conv.metrics || !conv.metrics.length) return;
2263
+ const idx = conv.items.findIndex((it) => it.kind === 'metrics');
2264
+ if (idx !== -1) conv.items.splice(idx, 1);
2265
+ const item: Extract<ThreadItem, { kind: 'metrics' }> = { kind: 'metrics', rounds: conv.metrics };
2266
+ conv.items.push(item);
2267
+ const existing = this._els.thread.querySelector('.msg.metrics');
2268
+ if (existing) existing.remove();
2269
+ this._els.thread.appendChild(this._metricsEl(item));
2270
+ }
2271
+
2272
+ private _metricsEl(item: Extract<ThreadItem, { kind: 'metrics' }>): HTMLElement {
2273
+ const rounds = item.rounds;
2274
+ const totalInput = rounds.reduce((s, m) => s + m.promptTokens, 0);
2275
+ const totalOutput = rounds.reduce((s, m) => s + m.outputTokens, 0);
2276
+ const totalToolCalls = rounds.reduce((s, m) => s + m.toolCalls, 0);
2277
+ const totalToolTokens = rounds.reduce((s, m) => s + m.toolResultTokens, 0);
2278
+ const mcpTotal = totalInput + totalOutput;
2279
+
2280
+ const { ragInput, ragOutput, numResults } = estimateRagTokens(rounds);
2281
+ const ragTotal = ragInput + ragOutput;
2282
+ const maxTotal = Math.max(mcpTotal, ragTotal, 1);
2283
+ const savings = ragTotal > 0 ? ((ragTotal - mcpTotal) / ragTotal) * 100 : 0;
2284
+
2285
+ const rows = rounds
2286
+ .map(
2287
+ (m) =>
2288
+ `<tr><td>Round ${m.round}</td><td class="num">${fmt(m.promptTokens)}</td><td class="num">${fmt(m.outputTokens)}</td><td class="num">${m.toolCalls}</td><td class="num">${fmt(m.toolResultTokens)}</td></tr>`,
2289
+ )
2290
+ .join('');
2291
+
2292
+ const bar = (label: string, value: number, cls: string) =>
2293
+ `<div class="mx-bar-row"><span class="mx-bar-label">${label}</span><div class="mx-bar-track"><i class="${cls}" style="width:${((value / maxTotal) * 100).toFixed(1)}%"></i></div><span class="mx-bar-value">${fmt(value)}</span></div>`;
2294
+
2295
+ const badge =
2296
+ Math.abs(savings) > 1
2297
+ ? `<div class="mx-savings ${savings > 0 ? 'pos' : 'neg'}">${
2298
+ savings > 0
2299
+ ? `↓ ${savings.toFixed(0)}% fewer tokens with typed retrieval`
2300
+ : `↑ ${Math.abs(savings).toFixed(0)}% more — multi-round context accumulation adds overhead`
2301
+ }</div>`
2302
+ : '';
2303
+
2304
+ const el = document.createElement('div');
2305
+ el.className = 'msg metrics';
2306
+ el.innerHTML = `
2307
+ <details class="metricscard">
2308
+ <summary>⚡ ${fmt(mcpTotal)} tokens · ${rounds.length} round${rounds.length === 1 ? '' : 's'} · ${totalToolCalls} tool call${totalToolCalls === 1 ? '' : 's'}</summary>
2309
+ <table class="mx-table">
2310
+ <thead><tr><th>Round</th><th class="num">Input</th><th class="num">Output</th><th class="num">Tools</th><th class="num">Tool tokens</th></tr></thead>
2311
+ <tbody>${rows}<tr class="total"><td>Total</td><td class="num">${fmt(totalInput)}</td><td class="num">${fmt(totalOutput)}</td><td class="num">${totalToolCalls}</td><td class="num">${fmt(totalToolTokens)}</td></tr></tbody>
2312
+ </table>
2313
+ <div class="mx-compare">
2314
+ <div class="mx-compare-title">Typed Agent vs Traditional RAG</div>
2315
+ ${bar('Typed', mcpTotal, 'mcp')}
2316
+ ${bar('RAG est.', ragTotal, 'rag')}
2317
+ ${badge}
2318
+ <div class="mx-note">RAG estimate: ${numResults} result${numResults === 1 ? '' : 's'} × ${CHUNKS_PER_RESULT} HTML chunks × ${AVG_HTML_CHUNK_TOKENS} tokens, on the same base prompt. Token counts are real, measured by the on-device Gemma tokenizer.</div>
2319
+ </div>
2320
+ </details>`;
2321
+ return el;
2322
+ }
2323
+
2324
+ // ── Preferences (programmatic) ─────────────────────────────────────────────
2325
+ /**
2326
+ * Stored values the agent may use to fill forms.
2327
+ *
2328
+ * Supplied by the host page, which usually knows them already, so a signed-in
2329
+ * visitor is not asked to re-enter their own details.
2330
+ *
2331
+ * Session-scoped by default. Persistence is opt-in, since these values are
2332
+ * personal data and the element offers no UI for a visitor to review or clear
2333
+ * what a page has stored. Set `persist = true` to keep them across reloads.
2334
+ *
2335
+ * @example
2336
+ * ```ts
2337
+ * const agent = document.querySelector('nxis-agent-fullscreen');
2338
+ * agent.preferences.set({ name: 'Jane Doe', email: 'jane@example.com' });
2339
+ * agent.preferences.set('phone', '555-0100');
2340
+ * agent.preferences.persist = true; // survive reloads
2341
+ * agent.preferences.clear(); // forget everything
2342
+ * ```
2343
+ */
2344
+ get preferences(): AgentPreferences {
2345
+ return this._prefsApi;
2346
+ }
2347
+
2348
+ /**
2349
+ * The system prompt.
2350
+ *
2351
+ * Set it before the element connects, or call `reset()` afterwards. Two
2352
+ * instructions in {@link DEFAULT_SYSTEM_PROMPT} change how the harness
2353
+ * behaves rather than only how the model reads; see its documentation before
2354
+ * replacing them.
2355
+ */
2356
+ get systemPrompt(): string {
2357
+ return this._systemPrompt;
2358
+ }
2359
+ set systemPrompt(prompt: string) {
2360
+ this._systemPrompt = prompt;
2361
+ // The engine holds the prompt, so a change after connect needs it rebuilt.
2362
+ if (this.provider) this._systemPrompt = prompt;
2363
+ }
2364
+
2365
+ /**
2366
+ * The line shown above the suggestion chips before a conversation starts.
2367
+ *
2368
+ * Only the host knows what this agent can actually do, so the default
2369
+ * describes the mechanism rather than the subject. Set it to say what is
2370
+ * available here.
2371
+ */
2372
+ get emptyState(): string {
2373
+ return this._emptyState;
2374
+ }
2375
+ set emptyState(text: string) {
2376
+ this._emptyState = text;
2377
+ if (this._els?.thread) this._renderThread();
2378
+ }
2379
+
2380
+ /**
2381
+ * The agent's name, used in the header, the launcher's tooltip and the accessible
2382
+ * name of both the launcher and the dialog.
2383
+ *
2384
+ * "Nxis Agent" is a placeholder standing in for a name the host has not
2385
+ * chosen yet. On a customer's own site the agent is theirs, and it should
2386
+ * introduce itself as theirs.
2387
+ */
2388
+ get name(): string {
2389
+ return this._name;
2390
+ }
2391
+ set name(value: string) {
2392
+ this._name = value || DEFAULT_AGENT_NAME;
2393
+ this._applyIdentity();
2394
+ }
2395
+
2396
+ /**
2397
+ * The mark on the launcher, in the headers, above the empty state and on
2398
+ * result cards. Inline SVG markup, or the URL of an image.
2399
+ *
2400
+ * Markup is injected as written, so pass only your own, never anything a
2401
+ * visitor can influence. The footer's "Powered by NxisAI" badge is not
2402
+ * affected by this.
2403
+ */
2404
+ get logo(): string {
2405
+ return this._logo;
2406
+ }
2407
+ set logo(value: string) {
2408
+ this._logo = value || DEFAULT_LOGO;
2409
+ this._applyIdentity();
2410
+ if (this._els?.thread) this._renderThread();
2411
+ }
2412
+
2413
+ /**
2414
+ * Brand overrides, as custom properties on the host element.
2415
+ *
2416
+ * Merged over the defaults, not replacing them, so a theme can set one
2417
+ * colour and leave the rest of the system alone. Assigning writes inline
2418
+ * styles, which outrank a stylesheet rule targeting this element, so theme
2419
+ * here or in CSS, but not both.
2420
+ */
2421
+ get theme(): AgentTheme {
2422
+ return { ...this._theme };
2423
+ }
2424
+ set theme(next: AgentTheme) {
2425
+ this._theme = { ...next };
2426
+ this._applyTheme();
2427
+ }
2428
+
2429
+ /** Empty-state chips. Set to [] to show none. */
2430
+ get suggestions(): string[] {
2431
+ return [...this._suggestions];
2432
+ }
2433
+ set suggestions(next: string[]) {
2434
+ this._suggestions = [...next];
2435
+ if (this._els?.thread) this._renderThread();
2436
+ }
2437
+
2438
+ private _loadProfile(): Record<string, string> {
2439
+ try {
2440
+ return JSON.parse(localStorage.getItem(PROFILE_KEY) || 'null') || {};
2441
+ } catch {
2442
+ return {};
2443
+ }
2444
+ }
2445
+
2446
+ /** Persist only when asked; otherwise leave storage untouched. */
2447
+ private _persistProfile(): void {
2448
+ if (!this._persistPrefs) return;
2449
+ try {
2450
+ localStorage.setItem(PROFILE_KEY, JSON.stringify(this._profile));
2451
+ } catch {
2452
+ /* storage blocked/full */
2453
+ }
2454
+ }
2455
+
2456
+ /**
2457
+ * Take new values and let the rest of the agent notice.
2458
+ *
2459
+ * Two things depend on this: the system message carries the values so the
2460
+ * model can use them in prose, and the harness reads them as a slot source
2461
+ * so a field the host already supplied is never asked for.
2462
+ */
2463
+ private _applyPreferences(next: Record<string, string>): void {
2464
+ this._profile = next;
2465
+ this._persistProfile();
2466
+ this._syncProfileContext();
2467
+ this._saveConversations();
2468
+ this.dispatchEvent(
2469
+ new CustomEvent('nxis-preferences-change', {
2470
+ detail: { preferences: { ...next } },
2471
+ bubbles: true,
2472
+ composed: true,
2473
+ }),
2474
+ );
2475
+ }
2476
+
2477
+ private _buildPreferencesApi(): AgentPreferences {
2478
+ const self = this;
2479
+ return {
2480
+ get persist() {
2481
+ return self._persistPrefs;
2482
+ },
2483
+ set persist(on: boolean) {
2484
+ self._persistPrefs = on;
2485
+ if (on) self._persistProfile();
2486
+ else {
2487
+ try {
2488
+ localStorage.removeItem(PROFILE_KEY);
2489
+ } catch {
2490
+ /* storage blocked */
2491
+ }
2492
+ }
2493
+ },
2494
+ // Overloaded in the interface; implemented as one function, so the
2495
+ // implementation signature is widened and the cast narrows it back.
2496
+ get: ((key?: string) =>
2497
+ key === undefined ? { ...self._profile } : self._profile[key]) as AgentPreferences['get'],
2498
+ set: ((keyOrValues: string | Record<string, string>, value?: string) => {
2499
+ const patch =
2500
+ typeof keyOrValues === 'string' ? { [keyOrValues]: value ?? '' } : keyOrValues;
2501
+ const next = { ...self._profile };
2502
+ for (const [k, v] of Object.entries(patch)) {
2503
+ const trimmed = String(v ?? '').trim();
2504
+ // An empty value clears rather than storing "", so set(k, '') and
2505
+ // clear(k) mean the same thing instead of quietly differing.
2506
+ if (trimmed) next[k] = trimmed;
2507
+ else delete next[k];
2508
+ }
2509
+ self._applyPreferences(next);
2510
+ }) as AgentPreferences['set'],
2511
+ clear(key?: string) {
2512
+ if (key === undefined) {
2513
+ self._applyPreferences({});
2514
+ try {
2515
+ localStorage.removeItem(PROFILE_KEY);
2516
+ } catch {
2517
+ /* storage blocked */
2518
+ }
2519
+ return;
2520
+ }
2521
+ const next = { ...self._profile };
2522
+ delete next[key];
2523
+ self._applyPreferences(next);
2524
+ },
2525
+ /** The keys the agent knows how to map onto form fields. */
2526
+ keys(): string[] {
2527
+ return Object.keys(PROFILE_ALIASES);
2528
+ },
2529
+ };
2530
+ }
2531
+
2532
+ /** Map the saved profile onto a tool's schema property names. */
2533
+ private _profileForSchema(schema: JsonSchema): Record<string, string> {
2534
+ const props = schema && schema.properties ? Object.keys(schema.properties) : [];
2535
+ if (!props.length) return {};
2536
+ const norm = (s: string) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
2537
+ const out: Record<string, string> = {};
2538
+ for (const [pf, val] of Object.entries(this._profile)) {
2539
+ if (!val || !String(val).trim()) continue;
2540
+ const cands = (PROFILE_ALIASES[pf] || [pf]).map(norm);
2541
+ const match =
2542
+ props.find((p) => cands.includes(norm(p))) ||
2543
+ props.find((p) => cands.some((c) => norm(p).includes(c) || c.includes(norm(p))));
2544
+ if (match && out[match] === undefined) out[match] = val;
2545
+ }
2546
+ return out;
2547
+ }
2548
+
2549
+ /** Fold the current profile into the active conversation's system message. */
2550
+ private _syncProfileContext(): void {
2551
+ const conv = this._active();
2552
+ if (!conv || !conv.messages[0]) return;
2553
+ const entries = Object.entries(this._profile).filter(([, v]) => v && String(v).trim());
2554
+ const note = entries.length
2555
+ ? '\n\nSaved user profile — use these to fill matching fields unless the user gives different values: ' +
2556
+ entries.map(([k, v]) => `${humanize(k)}: ${v}`).join('; ') +
2557
+ '.'
2558
+ : '';
2559
+ const formatReq =
2560
+ '\n\nFor fields that require a specific format (such as dates), explicitly inform the user of the required format (e.g., mm/dd/yyyy).';
2561
+ conv.messages[0] = { role: 'system', content: this._systemPrompt + note + formatReq };
2562
+ }
2563
+
2564
+ // ── Misc UI ────────────────────────────────────────────────────────────────
2565
+ private _setStatus(text: string, isError = false): void {
2566
+ // Errors only.
2567
+ //
2568
+ // The empty state names the phase while loading and the pending bubble
2569
+ // names it during a turn, so echoing the same words above the composer was
2570
+ // the same sentence twice on one screen. An error has nowhere else to go.
2571
+ this._els.status.textContent = isError ? text || '' : '';
2572
+ this._els.status.classList.toggle('err', !!isError);
2573
+ // The engine names its phases through onStatus, and the empty state shows
2574
+ // that name in place of the invitation, so a phase change has to reach it.
2575
+ if (!isError && text && text !== this._stage) {
2576
+ this._stage = text;
2577
+ const stage = this._els?.thread?.querySelector('.stage');
2578
+ if (stage) stage.textContent = text;
2579
+ // A phase with no numbers still changes what the ring should be doing,
2580
+ // and the phase that ends the load is what brings the chips back, so
2581
+ // this repaints either way rather than only while loading.
2582
+ if (!this.provider?.ready) this._loadDetail = undefined;
2583
+ this._paintLoad();
2584
+ }
2585
+ }
2586
+ private _setBar(pct: number): void {
2587
+ this._els.bar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
2588
+ }
2589
+ private _scroll(): void {
2590
+ this._els.thread.scrollTop = this._els.thread.scrollHeight;
2591
+ }
2592
+
2593
+ private _autosize(): void {
2594
+ const ta = this._els.input;
2595
+ ta.style.height = 'auto';
2596
+ ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`;
2597
+ }
2598
+
2599
+ /**
2600
+ * Rebuild the specialist agents and their coordinator.
2601
+ *
2602
+ * Called on connect and whenever the host changes `agents`, `routes` or
2603
+ * `fallback`. Inexpensive: each agent is a role over the one shared provider,
2604
+ * so no additional model is loaded.
2605
+ */
2606
+ private _buildAgents(): void {
2607
+ if (!this.provider || !this._agentDefs) {
2608
+ this._coordinator = null;
2609
+ return;
2610
+ }
2611
+ const names = Object.keys(this._agentDefs);
2612
+ const agents: Record<string, AgentHarness> = {};
2613
+ for (const name of names) {
2614
+ const def = this._agentDefs[name];
2615
+ agents[name] = new AgentHarness({
2616
+ provider: this.provider,
2617
+ toolSource: this.toolSource,
2618
+ renderer: this._renderer(),
2619
+ profile: (schema) => this._profileForSchema(schema as JsonSchema),
2620
+ onMessages: () => this._saveConversations(),
2621
+ systemPrompt: def.systemPrompt,
2622
+ tools: def.tools,
2623
+ name,
2624
+ unavailable: def.unavailable,
2625
+ });
2626
+ }
2627
+
2628
+ const descriptions: Record<string, string> = {};
2629
+ for (const name of names) {
2630
+ const d = this._agentDefs[name].description;
2631
+ if (d) descriptions[name] = d;
2632
+ }
2633
+
2634
+ const fallback = this._fallbackAgent && agents[this._fallbackAgent] ? this._fallbackAgent : names[0];
2635
+ this._coordinator = new AgentCoordinator({
2636
+ agents,
2637
+ rules: this._routes,
2638
+ fallback,
2639
+ // The model pass is the last resort, and it uses the same weights the
2640
+ // specialists do, so there is no second model to load.
2641
+ provider: this.provider,
2642
+ descriptions,
2643
+ onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
2644
+ });
2645
+ }
2646
+
2647
+ /** Zero when the provider cannot count, which hides the token metrics. */
2648
+ private async countTokens(text: string): Promise<number> {
2649
+ return (await this.provider.countTokens?.(text)) ?? 0;
2650
+ }
2651
+
2652
+ /**
2653
+ * Add a line to the thread naming the agent that took the turn.
2654
+ *
2655
+ * Behind `debug`, like the tool traces: a misroute is otherwise invisible,
2656
+ * showing only as an unhelpful answer.
2657
+ */
2658
+ private _paintRoute(agent: string, via: string): void {
2659
+ if (!this.debug) return;
2660
+ const item: Extract<ThreadItem, { kind: 'route' }> = { kind: 'route', agent, via };
2661
+ this._active()?.items.push(item);
2662
+ this._els.thread.appendChild(this._routeEl(item));
2663
+ this._saveConversations();
2664
+ this._scroll();
2665
+ }
2666
+
2667
+ private _routeEl(item: Extract<ThreadItem, { kind: 'route' }>): HTMLElement {
2668
+ const el = document.createElement('div');
2669
+ el.className = 'routeline';
2670
+ el.textContent = `routed to ${item.agent} · ${item.via}`;
2671
+ return el;
2672
+ }
2673
+
2674
+ /**
2675
+ * Submit a form that was confirmed on another page.
2676
+ *
2677
+ * An action runs by filling the form on its own page, so a call made
2678
+ * elsewhere was confirmed there and carried here. The values were already
2679
+ * reviewed and pressed, which is why this submits rather than asking again —
2680
+ * the button that started it said it would open this page and submit.
2681
+ *
2682
+ * Nothing waiting is the ordinary case, and costs a single storage read.
2683
+ */
2684
+ /**
2685
+ * Continue the conversation that opened this page, and say it is open.
2686
+ *
2687
+ * Reached when the agent opened it or a result card's link was followed.
2688
+ * The thread is the one the visitor was in: by id, or failing that the one
2689
+ * holding the journey that left, so a visit saved without an id still
2690
+ * lands where it started rather than in whichever thread loaded first.
2691
+ */
2692
+ private _resumePendingVisit(): void {
2693
+ const visit = takePendingVisit();
2694
+ if (!visit) return;
2695
+ const journey = visit.steps?.find((s) => s.journey)?.journey;
2696
+ const conv =
2697
+ this._conversations.find((c) => c.id === visit.conversationId) ??
2698
+ (journey
2699
+ ? this._conversations.find((c) => c.items.some((it) => it.kind === 'journey' && it.id === journey))
2700
+ : undefined) ??
2701
+ this._active();
2702
+ if (!conv) return;
2703
+ this._activeId = conv.id;
2704
+ this._renderHistory();
2705
+ this._renderThread();
2706
+ // Finished before the line below, so the card reads done above it.
2707
+ if (visit.steps?.length) {
2708
+ restoreJourney(visit.steps);
2709
+ recordStep({ id: 'navigate', label: `Opened ${visit.label}`, status: 'done' });
2710
+ recordStep({ id: 'submit', label: 'Arrived', status: 'done' });
2711
+ }
2712
+ const label = visit.label || 'the page';
2713
+ this._appendMsg('assistant', `You're on ${label} now.`);
2714
+ // What the model needs to know to answer "what is on this page".
2715
+ conv.messages.push({ role: 'user', content: `[${OPEN_PAGE_TOOL} opened ${label}: ${visit.pageUrl}]` });
2716
+ this._saveConversations();
2717
+ this._scroll();
2718
+ // Covering the page just opened would hide what was asked for.
2719
+ if (this.mode !== 'fullscreen') this._open();
2720
+ }
2721
+
2722
+ private async _resumePendingAction(): Promise<void> {
2723
+ const outcome = await resumePendingAction({
2724
+ onStart: (pending) => {
2725
+ // Back to the conversation it was started in, so the result lands
2726
+ // under the question that prompted it rather than in a fresh thread.
2727
+ const conv = this._conversations.find((c) => c.id === pending.conversationId);
2728
+ if (conv) {
2729
+ this._activeId = conv.id;
2730
+ this._renderHistory();
2731
+ this._renderThread();
2732
+ }
2733
+ this._open();
2734
+ this._setStatus('Submitting the details you confirmed…');
2735
+ },
2736
+ exec: (tool, values) => this.toolSource.execTool(tool, tool.name, values),
2737
+ });
2738
+ if (outcome.status !== 'none') this._setStatus('');
2739
+
2740
+ switch (outcome.status) {
2741
+ case 'none':
2742
+ return;
2743
+
2744
+ // Either way the form is out of reach, and saying which of the two
2745
+ // failed would describe our plumbing rather than their situation.
2746
+ case 'no-registry':
2747
+ case 'no-tool':
2748
+ this._appendMsg(
2749
+ 'assistant',
2750
+ `I could not find the ${humanize(outcome.pending.pageToolName)} on this page, so ` +
2751
+ 'nothing was submitted. Ask me again and I will fill it in from here.',
2752
+ );
2753
+ return;
2754
+
2755
+ case 'incomplete':
2756
+ this._appendMsg(
2757
+ 'assistant',
2758
+ `I brought your details over. This form also needs ${outcome.missing
2759
+ .map((m) => humanize(m).toLowerCase())
2760
+ .join(', ')}.`,
2761
+ );
2762
+ this._renderForm(outcome.tool, outcome.pending.values);
2763
+ return;
2764
+
2765
+ case 'rejected':
2766
+ this._appendMsg('assistant', outcome.text || 'That form did not accept the details.');
2767
+ return;
2768
+
2769
+ case 'failed':
2770
+ this._appendMsg('assistant', `That form could not be submitted: ${outcome.error}`);
2771
+ return;
2772
+
2773
+ case 'submitted': {
2774
+ const active = this._active();
2775
+ const item = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text);
2776
+ active?.items.push(item);
2777
+ active?.messages.push({
2778
+ role: 'user',
2779
+ content:
2780
+ `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
2781
+ 'Keep these unless I ask to change them.',
2782
+ });
2783
+ this._els.thread.appendChild(this._detailEl(item));
2784
+ this._saveConversations();
2785
+ this._scroll();
2786
+ }
2787
+ }
2788
+ }
2789
+
2790
+ /**
2791
+ * Bring in the site's own MCP server, once.
2792
+ *
2793
+ * The page's own action tools register themselves. Remote tools do not: they
2794
+ * live on an MCP server that must be discovered through
2795
+ * `/.well-known/ai-catalog.json` and bridged into the page's registry. The
2796
+ * same connection lists the actions on the site's other pages, unless
2797
+ * `site-actions="false"`.
2798
+ *
2799
+ * Runs on first open rather than on connect, so an unopened launcher costs no
2800
+ * network request. Failure is neither fatal nor loud: a site with no MCP
2801
+ * server is an ordinary case, and the page's own tools work regardless.
2802
+ */
2803
+ private _connectTools(): Promise<void> {
2804
+ if (this._connect) return this._connect;
2805
+ if (this.getAttribute('discover') === 'false') {
2806
+ this._connect = Promise.resolve();
2807
+ return this._connect;
2808
+ }
2809
+ // No credential is passed, and the element takes none: a key in markup or
2810
+ // in a page global is served to every visitor and readable by every script
2811
+ // on the origin. Private tools belong behind an endpoint you host — see
2812
+ // `createProxyProvider` for the same decision on the model key.
2813
+ this._connect = connectTools(undefined, {
2814
+ // Short, because this blocks the first turn: a provider that has not
2815
+ // appeared within a few seconds is not going to.
2816
+ timeout: 4000,
2817
+ siteActions: this.getAttribute('site-actions') !== 'false',
2818
+ })
2819
+ .then(({ remoteTools, siteActions, siteTools, sitePages, warning }) => {
2820
+ this._sitePages = sitePages;
2821
+ if (warning) console.warn('[nxis-agent]', warning);
2822
+ if (remoteTools.length) {
2823
+ console.info(`[nxis-agent] discovered remote tools: ${remoteTools.join(', ')}`);
2824
+ }
2825
+ if (siteActions.length) {
2826
+ console.info(
2827
+ `[nxis-agent] ${siteActions.length} site action(s) → ` +
2828
+ `${siteTools.length} tool(s): ${siteTools.join(', ')}`,
2829
+ );
2830
+ }
2831
+ void this._refreshToolCount();
2832
+ // The page's own script can register after we connect, and a native
2833
+ // registry announces it. Keep the count honest as it does.
2834
+ this.toolSource.modelContext()?.addEventListener('toolchange', () => {
2835
+ void this._refreshToolCount();
2836
+ if (this._toolsOpen) void this._renderTools();
2837
+ });
2838
+ })
2839
+ .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
2840
+ return this._connect;
2841
+ }
2842
+
2843
+ // ── Tools menu ─────────────────────────────────────────────────────────────
2844
+
2845
+ /** Open or close the list of tools the agent can use. */
2846
+ private _toggleTools(open = !this._toolsOpen): void {
2847
+ this._toolsOpen = open;
2848
+ this._els.toolsBtn.setAttribute('aria-expanded', String(open));
2849
+ this._els.toolsPop.hidden = !open;
2850
+ if (open) void this._renderTools();
2851
+ }
2852
+
2853
+ /** The number on the tools button, from what the agent would be offered now. */
2854
+ private async _refreshToolCount(): Promise<void> {
2855
+ const tools = await this._harness.offeredTools();
2856
+ this._els.toolsCount.textContent = String(tools.length);
2857
+ this._els.toolsCount.hidden = tools.length === 0;
2858
+ this._els.toolsBtn.setAttribute(
2859
+ 'aria-label',
2860
+ tools.length ? `Tools on this page, ${tools.length}` : 'Tools on this page',
2861
+ );
2862
+ }
2863
+
2864
+ /**
2865
+ * Draw the tools menu.
2866
+ *
2867
+ * Lists what the agent would be offered, not every tool registered: that
2868
+ * is the honest answer to "what can it do here". Grouped by where a tool
2869
+ * reaches — this page's own forms, then what works across the site — which
2870
+ * is what explains why a form from another listing is on the list. Every
2871
+ * row is disabled: the site provides these, and they are not the visitor's
2872
+ * to switch off.
2873
+ */
2874
+ private async _renderTools(): Promise<void> {
2875
+ const pop = this._els.toolsPop;
2876
+ const head =
2877
+ '<div class="tp-head"><div class="tp-title">What the agent can use here</div>' +
2878
+ '<div class="tp-sub">This site provides these tools. They load with the page and cannot be turned off.</div></div>';
2879
+ pop.innerHTML = `${head}<div class="tp-empty">Looking for tools…</div>`;
2880
+
2881
+ const tools = await this._harness.offeredTools();
2882
+ if (!this._toolsOpen) return;
2883
+ this._els.toolsCount.textContent = String(tools.length);
2884
+ this._els.toolsCount.hidden = tools.length === 0;
2885
+
2886
+ pop.innerHTML = head;
2887
+ if (!tools.length) {
2888
+ pop.insertAdjacentHTML('beforeend', '<div class="tp-empty">No tools on this page yet.</div>');
2889
+ return;
2890
+ }
2891
+
2892
+ const readOnly = (t: ModelContextToolInfo) => !!t.annotations?.readOnlyHint;
2893
+ const onPage = tools.filter((t) => !isSiteTool(t) && !readOnly(t));
2894
+ const acrossSite = tools.filter((t) => isSiteTool(t) || readOnly(t));
2895
+ const group = (label: string, list: ModelContextToolInfo[]) => {
2896
+ if (!list.length) return;
2897
+ const title = document.createElement('div');
2898
+ title.className = 'tp-grp';
2899
+ title.textContent = label;
2900
+ pop.appendChild(title);
2901
+ for (const tool of list) pop.appendChild(this._toolRow(tool));
2902
+ };
2903
+ group('On this page', onPage);
2904
+ group('Across the site', acrossSite);
2905
+
2906
+ if (tools.some((t) => !readOnly(t))) {
2907
+ pop.insertAdjacentHTML(
2908
+ 'beforeend',
2909
+ '<div class="tp-foot">Anything that submits a form asks you first.</div>',
2910
+ );
2911
+ }
2912
+ }
2913
+
2914
+ /** One tool in the menu: what it is, whether it writes, and a lock. */
2915
+ private _toolRow(tool: ModelContextToolInfo): HTMLElement {
2916
+ const site = isSiteTool(tool);
2917
+ const reads = !!tool.annotations?.readOnlyHint;
2918
+ const base = FRIENDLY_TOOL_NAMES[tool.name] ?? humanize(withoutSiteSuffix(tool.name));
2919
+ const name = (base.charAt(0).toUpperCase() + base.slice(1)) + (site ? ' · any page' : '');
2920
+ const desc = (tool.description ?? '').split(/(?<=[.!?])\s/)[0] ?? '';
2921
+
2922
+ const row = document.createElement('div');
2923
+ row.className = 'tp-tool';
2924
+ row.setAttribute('aria-disabled', 'true');
2925
+ row.title = desc ? `${name} — ${desc}` : name;
2926
+
2927
+ const icon = document.createElement('span');
2928
+ icon.className = 'tp-ti';
2929
+ icon.innerHTML = site ? ICON_GLOBE : reads ? ICON_SEARCH : ICON_FORM;
2930
+
2931
+ const text = document.createElement('span');
2932
+ text.className = 'tp-tn';
2933
+ const n = document.createElement('span');
2934
+ n.className = 'tp-name';
2935
+ // Text, not markup: names and descriptions come from the site's data.
2936
+ n.textContent = name;
2937
+ text.appendChild(n);
2938
+ if (desc) {
2939
+ const d = document.createElement('span');
2940
+ d.className = 'tp-desc';
2941
+ d.textContent = desc;
2942
+ text.appendChild(d);
2943
+ }
2944
+
2945
+ const kind = document.createElement('span');
2946
+ kind.className = 'tp-kind';
2947
+ kind.textContent = reads ? 'reads' : 'asks first';
2948
+
2949
+ const lock = document.createElement('span');
2950
+ lock.className = 'tp-lock';
2951
+ lock.innerHTML = ICON_LOCK;
2952
+
2953
+ row.append(icon, text, kind, lock);
2954
+ return row;
2955
+ }
2956
+
2957
+ /**
2958
+ * Report a failure in the thread, where it will be seen.
2959
+ *
2960
+ * Translates the underlying error into a sentence that says what to do next,
2961
+ * rather than surfacing the exception.
2962
+ */
2963
+ private _reportEngineError(err: unknown): void {
2964
+ const raw = String((err as Error)?.message || err || 'Unknown error');
2965
+ let human: string;
2966
+ if (/webgpu/i.test(raw)) {
2967
+ human =
2968
+ 'This browser cannot run the on-device model — it needs WebGPU. ' +
2969
+ 'Chrome or Edge 113+, or Safari 18+, will work.';
2970
+ } else if (/cache|quota|storage/i.test(raw)) {
2971
+ human =
2972
+ "The model could not be stored in this browser's cache, so it did not finish " +
2973
+ 'loading. Clearing this site\'s data usually fixes it, and the next attempt ' +
2974
+ 'will skip the cache.';
2975
+ } else if (/fetch|network|import|load/i.test(raw)) {
2976
+ human =
2977
+ 'The model files could not be downloaded. Check the connection and try ' +
2978
+ 'again — nothing was lost.';
2979
+ } else {
2980
+ human = `The on-device model could not be started: ${raw}`;
2981
+ }
2982
+ this._setStatus(raw, true);
2983
+ this._appendMsg('assistant', human);
2984
+ }
2985
+
2986
+ /**
2987
+ * Draw load progress on whichever mark is currently visible.
2988
+ *
2989
+ * Exactly one ring is ever on screen: the empty state's mark until a
2990
+ * conversation replaces it, then the header's. Phases that report no totals,
2991
+ * such as the runtime fetch and the warm-up, spin rather than fill.
2992
+ */
2993
+ private _paintLoad(): void {
2994
+ // A provider with nothing to load is ready as soon as it exists, so none
2995
+ // of the progress treatment applies. `load` is optional on the interface so
2996
+ // that this can be detected.
2997
+ const loading = (this.provider?.load ? this.provider.loading : false) ?? false;
2998
+
2999
+ // The empty state was drawn before this load existed, or before it
3000
+ // finished. Redrawing it calls back here against the right DOM.
3001
+ if (this._emptyLoading !== null && this._emptyLoading !== loading) {
3002
+ this._renderThread();
3003
+ return;
3004
+ }
3005
+
3006
+ const detail = this._loadDetail;
3007
+ const determinate = loading && !!detail?.total && this._loadPct > 0;
3008
+ const clamped = Math.max(0, Math.min(100, this._loadPct));
3009
+
3010
+ const ring = this._els?.thread?.querySelector('.ring') as HTMLElement | null;
3011
+ const head = this.shadowRoot?.querySelector('.head-ring') as HTMLElement | null;
3012
+
3013
+ for (const [node, show] of [
3014
+ [ring, loading],
3015
+ // The header only takes over once the empty state is gone.
3016
+ [head, loading && !ring],
3017
+ ] as const) {
3018
+ if (!node) continue;
3019
+ node.hidden = !show;
3020
+ node.classList.toggle('indet', show && !determinate);
3021
+ if (show && determinate) node.style.setProperty('--pct', String(clamped));
3022
+ }
3023
+
3024
+ const byteLine = this._els?.thread?.querySelector('.stage-bytes');
3025
+ if (byteLine) {
3026
+ byteLine.textContent = determinate
3027
+ ? `${formatBytes(detail!.loaded)} / ${formatBytes(detail!.total)}`
3028
+ : '';
3029
+ }
3030
+ }
3031
+
3032
+ /**
3033
+ * Split this element into several specialist agents.
3034
+ *
3035
+ * Each definition becomes an {@link AgentHarness} sharing this element's
3036
+ * provider, so one model in memory serves every role. With none set, the
3037
+ * element runs as a single agent with every tool and one prompt.
3038
+ *
3039
+ * @example
3040
+ * ```ts
3041
+ * agent.agents = {
3042
+ * retrieval: { systemPrompt: '…', tools: (t) => t.name.startsWith('nxis_') },
3043
+ * booking: { systemPrompt: '…', tools: (t) => /book/.test(t.name) },
3044
+ * };
3045
+ * agent.routes = [{ route: 'booking', match: /\bbook\b/ }];
3046
+ * agent.fallback = 'retrieval';
3047
+ * ```
3048
+ */
3049
+ get agents(): Record<string, AgentDefinition> | null {
3050
+ return this._agentDefs;
3051
+ }
3052
+ set agents(defs: Record<string, AgentDefinition> | null) {
3053
+ this._agentDefs = defs && Object.keys(defs).length ? defs : null;
3054
+ this._buildAgents();
3055
+ }
3056
+
3057
+ /** The routing table. Ignored unless `agents` is set. */
3058
+ get routes(): RouteRule[] {
3059
+ return this._routes;
3060
+ }
3061
+ set routes(rules: RouteRule[]) {
3062
+ this._routes = rules ?? [];
3063
+ this._buildAgents();
3064
+ }
3065
+
3066
+ /**
3067
+ * Where a turn goes when the rules abstain, nothing is sticky and the model
3068
+ * declined. Defaults to the first agent declared.
3069
+ */
3070
+ get fallback(): string {
3071
+ return this._fallbackAgent;
3072
+ }
3073
+ set fallback(name: string) {
3074
+ this._fallbackAgent = name;
3075
+ this._buildAgents();
3076
+ }
3077
+
3078
+ /**
3079
+ * Where completions come from. Defaults to Gemma on WebGPU.
3080
+ *
3081
+ * Set a provider to run the agent on a remote model. Anything a host writes
3082
+ * here should talk to their own origin rather than a vendor directly: a key
3083
+ * in a browser is a key anyone can read from the page.
3084
+ */
3085
+ get modelProvider(): ModelProvider | null {
3086
+ return this._provider;
3087
+ }
3088
+ set modelProvider(next: ModelProvider | null) {
3089
+ this._provider = next;
3090
+ // Late enough to matter: rebuild so the next turn uses it.
3091
+ if (this._els?.thread) {
3092
+ this.provider = next ?? this.provider;
3093
+ this._paintProvenance();
3094
+ this._buildAgents();
3095
+ }
3096
+ }
3097
+
3098
+ /**
3099
+ * Show the tool calls behind each answer.
3100
+ *
3101
+ * Off by default, and intended for whoever is building the agent. Traces
3102
+ * carry the arguments a tool was called with, which for a form tool includes
3103
+ * personal data, so while this is off they are neither rendered nor stored
3104
+ * rather than merely hidden.
3105
+ *
3106
+ * Enabling it affects subsequent turns, not those already recorded.
3107
+ */
3108
+ /**
3109
+ * How much of the page the agent takes when open.
3110
+ *
3111
+ * `'fullscreen'` covers the page, which suits a site where the agent is the
3112
+ * experience. `'window'` docks it in the corner at
3113
+ * `--window-w` × `--window-h` (400 × 620 by default), for a site whose own
3114
+ * content should stay visible and usable beside it. Below 520px a window
3115
+ * covers the page anyway, since at that width the two are the same thing.
3116
+ *
3117
+ * `'inline'` fills the box the host puts the element in and is always
3118
+ * open — for a copilot rail or a sidebar the host lays out itself. The
3119
+ * element does not position itself or move the page; the host does both.
3120
+ */
3121
+ get mode(): 'fullscreen' | 'window' | 'inline' {
3122
+ const raw = this.getAttribute('mode');
3123
+ return raw === 'window' || raw === 'inline' ? raw : 'fullscreen';
3124
+ }
3125
+ set mode(next: 'fullscreen' | 'window' | 'inline') {
3126
+ this.setAttribute('mode', next === 'window' || next === 'inline' ? next : 'fullscreen');
3127
+ this._applyMode();
3128
+ }
3129
+
3130
+ /** Put the overlay in the shape {@link mode} asks for. */
3131
+ private _applyMode(): void {
3132
+ const overlay = this._els?.overlay;
3133
+ if (!overlay) return;
3134
+ const mode = this.mode;
3135
+ overlay.classList.toggle('window', mode === 'window');
3136
+ overlay.classList.toggle('inline', mode === 'inline');
3137
+ if (mode === 'inline') {
3138
+ // Always on screen, so it has to be ready without an open. Tools are
3139
+ // cheap to connect; the model still waits for `preload` or a first
3140
+ // message, so a rail on every page does not cost every visitor 3.4 GB.
3141
+ overlay.classList.add('show');
3142
+ void this._connectTools();
3143
+ }
3144
+ }
3145
+
3146
+ get debug(): boolean {
3147
+ const raw = (this.getAttribute('debug') || '').toLowerCase();
3148
+ if (!this.hasAttribute('debug')) return false;
3149
+ return raw !== 'false' && raw !== '0' && raw !== 'off';
3150
+ }
3151
+ set debug(on: boolean) {
3152
+ if (on) this.setAttribute('debug', 'true');
3153
+ else this.removeAttribute('debug');
3154
+ if (this._els?.thread) this._renderThread();
3155
+ }
3156
+
3157
+ /**
3158
+ * How eagerly to fetch the model weights.
3159
+ *
3160
+ * `eager` starts on connect, `idle` waits for the browser to be free (the
3161
+ * default), and `none` waits until the panel is opened or a message is sent.
3162
+ * `preload="false"` is accepted as a synonym for `none`.
3163
+ */
3164
+ get preload(): PreloadMode {
3165
+ const raw = (this.getAttribute('preload') || '').toLowerCase();
3166
+ if (raw === 'eager') return 'eager';
3167
+ if (raw === 'false' || raw === 'none') return 'none';
3168
+ return 'idle';
3169
+ }
3170
+ set preload(mode: PreloadMode) {
3171
+ this.setAttribute('preload', mode);
3172
+ // Honoured when set after connect, so a host can start the load mid-session
3173
+ // without a reload.
3174
+ if (mode === 'eager' && this.provider && !this.provider.ready && !this.provider.loading) {
3175
+ this.provider.preload?.('eager');
3176
+ }
3177
+ }
3178
+
3179
+ /**
3180
+ * The logo as markup.
3181
+ *
3182
+ * A value starting with `<` is used as written; anything else is treated as
3183
+ * an image URL.
3184
+ */
3185
+ private _logoMarkup(): string {
3186
+ const logo = this._logo.trim();
3187
+ return logo.startsWith('<') ? logo : `<img src="${escapeHtml(logo)}" alt="" />`;
3188
+ }
3189
+
3190
+ /**
3191
+ * Push the current name and logo into the rendered shadow tree.
3192
+ *
3193
+ * Called on connect and on every set, so hosts can configure the element
3194
+ * before or after it upgrades without having to know which happened first.
3195
+ */
3196
+ private _applyIdentity(): void {
3197
+ const root = this.shadowRoot;
3198
+ if (!root) return;
3199
+ const logo = this._logoMarkup();
3200
+ root.querySelectorAll('.launcher, .brand').forEach((node) => {
3201
+ node.innerHTML = logo;
3202
+ });
3203
+ const heading = root.querySelector('.main-head h2');
3204
+ if (heading) heading.textContent = this._name;
3205
+
3206
+ this._paintProvenance();
3207
+ const launcher = root.querySelector('.launcher');
3208
+ launcher?.setAttribute('title', `Open ${this._name}`);
3209
+ launcher?.setAttribute('aria-label', `Open ${this._name}`);
3210
+ root.querySelector('.overlay')?.setAttribute('aria-label', this._name);
3211
+ }
3212
+
3213
+ /**
3214
+ * Say where the answers come from.
3215
+ *
3216
+ * The package's claim is that nothing leaves the tab, so the one case where
3217
+ * that stops being true has to be on screen. Painted separately from the rest
3218
+ * of the identity because the provider is built after the shadow tree, and
3219
+ * can be replaced later.
3220
+ */
3221
+ private _paintProvenance(): void {
3222
+ const local = this.provider?.local ?? true;
3223
+ // Beside the input: which model is answering, and where it runs.
3224
+ const model = this.shadowRoot?.querySelector('.composer .model');
3225
+ if (model) {
3226
+ const label = this.provider?.label || this.provider?.name || 'Model';
3227
+ (model.querySelector('.model-name') as HTMLElement).textContent = label;
3228
+ (model.querySelector('.model-where') as HTMLElement).textContent = local ? 'on-device' : 'remote';
3229
+ model.setAttribute('title', `${label}, ${local ? 'running in this browser' : 'running on a server'}`);
3230
+ }
3231
+ const badge = this.shadowRoot?.querySelector('.provenance');
3232
+ if (!badge?.lastChild) return;
3233
+ badge.lastChild.textContent = local ? 'On-device' : 'Remote';
3234
+ badge.setAttribute(
3235
+ 'title',
3236
+ local
3237
+ ? 'The model runs in this browser. Nothing you type leaves the device.'
3238
+ : 'Answers come from a model on a server, so this conversation leaves the device.',
3239
+ );
3240
+ }
3241
+
3242
+ /** Write the theme's custom properties onto the host element. */
3243
+ private _applyTheme(): void {
3244
+ for (const props of Object.values(THEME_TOKENS)) {
3245
+ for (const prop of props) this.style.removeProperty(prop);
3246
+ }
3247
+ for (const [key, value] of Object.entries(this._theme)) {
3248
+ const props = THEME_TOKENS[key as keyof AgentTheme];
3249
+ if (!props || !value) continue;
3250
+ for (const prop of props) this.style.setProperty(prop, value);
3251
+ }
3252
+ }
3253
+
3254
+ private _template(): string {
3255
+ return `
3256
+ <style>
3257
+ /* NxisAI Visual Direction — tokens from design.md (drop-in CSS). */
3258
+ :host {
3259
+ all: initial;
3260
+ --bg:#FFFFFF; --surface:#F5F7FB; --surface-2:#EDF1F9;
3261
+ --border:#E4E9F2; --border-strong:#CFD8E8;
3262
+ --ink:#0F172A; --ink-2:#475569; --ink-3:#8A97AC;
3263
+ --blue:#2563EB; --blue-strong:#1D4ED8; --blue-tint:#EEF3FF;
3264
+ --green:#22C55E; --green-strong:#16A34A; --green-tint:#E8FAEF;
3265
+ --inline-bg:#EEF2F8; --red:#B91C1C; --stop:#DC2626; --stop-strong:#B91C1C;
3266
+ --radius:14px; --radius-sm:10px; --radius-pill:999px;
3267
+ --shadow-sm:0 1px 2px rgba(15,23,42,.05);
3268
+ --shadow:0 1px 2px rgba(15,23,42,.04), 0 10px 30px rgba(15,23,42,.06);
3269
+ --display:'Manrope', system-ui, sans-serif;
3270
+ --sans:'Inter', system-ui, sans-serif;
3271
+ --mono:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
3272
+ font-family: var(--sans);
3273
+
3274
+ /*
3275
+ * Tailwind v4 composes box-shadow, ring and border-style from --tw-*
3276
+ * custom properties, and "all: initial" above wipes them, so every
3277
+ * child inherits empty values, and shadow/border utilities resolve to
3278
+ * nothing while looking perfectly correct in the stylesheet. Restoring
3279
+ * the defaults here is what makes those utilities work inside a shadow
3280
+ * root that isolates itself from the page.
3281
+ */
3282
+ --tw-shadow: 0 0 #0000;
3283
+ --tw-inset-shadow: 0 0 #0000;
3284
+ --tw-ring-shadow: 0 0 #0000;
3285
+ --tw-inset-ring-shadow: 0 0 #0000;
3286
+ --tw-ring-offset-shadow: 0 0 #0000;
3287
+ --tw-border-style: solid;
3288
+ }
3289
+
3290
+ /* Tailwind, scoped to this shadow root. Utilities resolve to the NxisAI
3291
+ palette because agent.tw.css themes them from design.md. */
3292
+ ${TAILWIND_CSS}
3293
+
3294
+ /* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
3295
+ ${A2UI_THEME_CSS}
3296
+
3297
+ /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
3298
+ .launcher {
3299
+ position: fixed; right: 20px; bottom: 20px; z-index: 9998;
3300
+ width: 56px; height: 56px; border-radius: var(--radius); cursor: pointer; padding: 0;
3301
+ display: flex; align-items: center; justify-content: center;
3302
+ border: 1px solid var(--border); background: var(--surface);
3303
+ box-shadow: var(--shadow-sm);
3304
+ transition: transform .15s ease, box-shadow .15s ease;
3305
+ }
3306
+ .launcher:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
3307
+ .launcher svg, .launcher img { width: 34px; height: auto; display: block; }
3308
+
3309
+ .overlay { position: fixed; inset: 0; z-index: 9999; display: none; background: var(--bg); color: var(--ink); }
3310
+ .overlay.show { display: flex; animation: fade .2s ease; }
3311
+ @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
3312
+
3313
+ /* ── Window mode ────────────────────────────────────────────
3314
+ The same panel, docked in a corner rather than covering the
3315
+ page — for a site that wants an assistant beside its content
3316
+ instead of in front of it. The host sets the size through
3317
+ --window-w and --window-h. */
3318
+ .overlay.window {
3319
+ inset: auto; right: 20px; bottom: 20px;
3320
+ width: min(var(--window-w, 400px), calc(100vw - 32px));
3321
+ height: min(var(--window-h, 620px), calc(100vh - 32px));
3322
+ border: 1px solid var(--border-strong);
3323
+ border-radius: var(--radius);
3324
+ box-shadow: 0 24px 60px rgba(15,23,42,.22);
3325
+ overflow: hidden;
3326
+ }
3327
+ /* ── Inline mode ────────────────────────────────────────────
3328
+ Fills whatever box the host puts it in — a copilot rail, a sidebar,
3329
+ a column — and leaves where that box goes, and what the page does
3330
+ to make room, to the host. Always open: there is no launcher to
3331
+ press and nothing to close back to. */
3332
+ :host([mode="inline"]) { display: block; position: relative; height: 100%; }
3333
+ :host([mode="inline"]) .launcher { display: none; }
3334
+ /* overflow: hidden is what keeps the conversation drawer inside the
3335
+ host's box. Closed, the drawer waits just off the left edge; without
3336
+ the clip it sits on top of the host's page, and pressing the menu
3337
+ only slides it between two visible places. */
3338
+ .overlay.inline { position: absolute; inset: 0; z-index: auto; animation: none; overflow: hidden; }
3339
+ .overlay.inline .close { display: none; }
3340
+
3341
+ /* The conversation list cannot sit beside a narrow thread, so in a
3342
+ window or a rail it behaves as the drawer the narrow layout already
3343
+ defines, whatever the viewport is doing. */
3344
+ .overlay.window .side, .overlay.inline .side {
3345
+ position: absolute; inset: 0 auto 0 0; z-index: 6;
3346
+ transform: translateX(-100%); transition: transform .2s ease;
3347
+ box-shadow: 0 18px 48px rgba(15,23,42,.18);
3348
+ }
3349
+ .overlay.window.nav .side, .overlay.inline.nav .side { transform: none; }
3350
+ .overlay.window.nav .scrim, .overlay.inline.nav .scrim {
3351
+ display: block; position: absolute; inset: 0; z-index: 5;
3352
+ background: rgba(15,23,42,.32);
3353
+ }
3354
+ .overlay.window .menu, .overlay.inline .menu { display: flex; }
3355
+ .overlay.window .msg, .overlay.inline .msg { max-width: 88%; }
3356
+ .overlay.window .hist-del, .overlay.inline .hist-del { opacity: 1; }
3357
+ .overlay.window .main-head, .overlay.inline .main-head { padding: 12px 14px; }
3358
+ .overlay.window .thread, .overlay.inline .thread { padding: 14px; }
3359
+ @media (prefers-reduced-motion: reduce) {
3360
+ .overlay.window .side, .overlay.inline .side { transition: none; }
3361
+ }
3362
+
3363
+ /* ── Journeys ───────────────────────────────────────────────
3364
+ What the agent did about a request, as a card under it. */
3365
+ .msg.journey { align-self: stretch; max-width: 100%; padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--bg); overflow: hidden; }
3366
+ .msg.journey > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 9px 12px; font-size: 12.5px; color: var(--ink-2); }
3367
+ .msg.journey > summary::-webkit-details-marker { display: none; }
3368
+ .msg.journey > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
3369
+ .msg.journey[open] > summary { background: var(--surface); border-bottom: 1px solid var(--border); }
3370
+ .j-title { font-family: var(--display); font-weight: 700; color: var(--ink); white-space: nowrap; }
3371
+ .j-state { margin-left: auto; min-width: 0; font-size: 11.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3372
+ .j-sum-mark { width: 16px; height: 16px; flex: none; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; }
3373
+ .is-running .j-sum-mark { border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
3374
+ .is-done .j-sum-mark { background: var(--green-tint); color: var(--green-strong); }
3375
+ .is-failed .j-sum-mark { background: rgba(185,28,28,.1); color: var(--red); }
3376
+ .is-failed .j-state { color: var(--red); }
3377
+ @keyframes jspin { to { transform: rotate(360deg); } }
3378
+ .j-steps { list-style: none; margin: 0; padding: 12px; display: grid; }
3379
+ .j-step { display: grid; grid-template-columns: 20px 1fr; gap: 10px; position: relative; padding-bottom: 10px; font-size: 12.5px; }
3380
+ .j-step:last-child { padding-bottom: 0; }
3381
+ .j-step:not(:last-child)::before { content: ''; position: absolute; left: 9px; top: 21px; bottom: 1px; width: 1px; background: var(--border-strong); }
3382
+ .j-mark { width: 19px; height: 19px; border-radius: 50%; border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink-3); font-size: 10px; display: grid; place-items: center; z-index: 1; }
3383
+ .j-step.is-done .j-mark { background: var(--green-tint); border-color: var(--green-strong); color: var(--green-strong); }
3384
+ .j-step.is-active .j-mark { background: var(--blue); border-color: var(--blue); color: #fff; }
3385
+ .j-step.is-failed .j-mark { background: rgba(185,28,28,.1); border-color: var(--red); color: var(--red); }
3386
+ .j-label { color: var(--ink-2); line-height: 1.35; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3387
+ .j-label small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3388
+ .j-step:hover .j-label, .j-step:hover .j-label small { white-space: normal; overflow: visible; }
3389
+ .j-step.is-done .j-label, .j-step.is-active .j-label { color: var(--ink); }
3390
+ .j-step.is-active .j-label { font-weight: 600; }
3391
+ .j-label small { display: block; font-size: 11px; font-weight: 400; color: var(--ink-3); margin-top: 1px; }
3392
+
3393
+ /* The running step, while its card is scrolled out of view. */
3394
+ .jstatus { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; border-bottom: 1px solid var(--border); background: var(--blue-tint); color: var(--ink); font-family: var(--sans); font-size: 12.5px; font-weight: 600; padding: 8px 20px; cursor: pointer; text-align: left; }
3395
+ .jstatus[hidden] { display: none; }
3396
+ .jstatus:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
3397
+ .js-spin { width: 12px; height: 12px; flex: none; border-radius: 50%; border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
3398
+ .js-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3399
+ .js-go { margin-left: auto; font-weight: 500; color: var(--blue-strong); }
3400
+ .overlay.window .jstatus, .overlay.inline .jstatus { padding: 8px 14px; }
3401
+ @media (prefers-reduced-motion: reduce) { .is-running .j-sum-mark, .js-spin { animation: none; } }
3402
+ /* Narrower than the card, a card is the viewport: drop the chrome
3403
+ rather than leaving a 16px frame around a full-height panel. */
3404
+ @media (max-width: 520px) {
3405
+ .overlay.window { inset: 0; width: auto; height: auto; border: 0; border-radius: 0; box-shadow: none; }
3406
+ }
3407
+
3408
+ /* ── Side panel ─────────────────────────────────────────────── */
3409
+ .scrim { display: none; }
3410
+ .side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-right: 1px solid var(--border); }
3411
+ .side-head { display: flex; align-items: center; gap: 10px; padding: 16px 14px 12px; }
3412
+ .side-head .brand { width: 22px; height: 22px; display: block; }
3413
+ .side-head .brand svg, .side-head .brand img { width: 22px; height: auto; display: block; }
3414
+ .side-title { font-family: var(--display); font-weight: 800; font-size: 14px; }
3415
+ .newchat { margin-left: auto; width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--ink-2); cursor: pointer; display: flex; align-items: center; justify-content: center; }
3416
+ .newchat:hover { color: var(--blue-strong); border-color: var(--blue); }
3417
+ .newchat svg { width: 16px; height: 16px; }
3418
+ .history { flex: 1; overflow-y: auto; padding: 6px 10px; display: flex; flex-direction: column; gap: 3px; }
3419
+ .hist-row { display: flex; align-items: center; border-radius: 8px; }
3420
+ .hist-row:hover { background: var(--surface-2); }
3421
+ .hist-row.active { background: var(--blue-tint); }
3422
+ .hist-item { flex: 1; min-width: 0; text-align: left; background: none; border: none; padding: 9px 10px; font-family: var(--sans); font-size: 13px; color: var(--ink-2); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3423
+ .hist-row.active .hist-item { color: var(--blue-strong); font-weight: 600; }
3424
+ .hist-del { flex-shrink: 0; opacity: 0; width: 26px; height: 26px; margin-right: 4px; border: none; background: none; color: var(--ink-3); cursor: pointer; border-radius: 6px; display: flex; align-items: center; justify-content: center; }
3425
+ .hist-row:hover .hist-del, .hist-row.active .hist-del { opacity: 1; }
3426
+ .hist-del:hover { color: var(--red); background: var(--bg); }
3427
+ .hist-del svg { width: 15px; height: 15px; }
3428
+
3429
+
3430
+
3431
+
3432
+
3433
+
3434
+
3435
+
3436
+
3437
+
3438
+
3439
+
3440
+
3441
+ /* ── Main view ──────────────────────────────────────────────── */
3442
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
3443
+ .main-head { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--border); }
3444
+ .menu { display: none; width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--ink-2); cursor: pointer; align-items: center; justify-content: center; }
3445
+ .menu svg { width: 18px; height: 18px; }
3446
+ .main-head .brand { width: 24px; height: 24px; display: block; }
3447
+ .main-head .brand svg, .main-head .brand img { width: 24px; height: auto; display: block; }
3448
+ .main-head h2 { margin: 0; font-family: var(--display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
3449
+ /* Tags & chips — Inter 500 13px pill, optional leading dot. */
3450
+ .tag { font-family: var(--sans); font-weight: 500; font-size: 13px; padding: 4px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-strong); color: var(--ink-2); background: var(--bg); display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
3451
+ .tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); }
3452
+ .tag.blue { color: var(--blue-strong); background: var(--blue-tint); border-color: transparent; }
3453
+ .tag.blue .dot { background: var(--blue); }
3454
+ .tag.green { color: var(--green-strong); background: var(--green-tint); border-color: transparent; }
3455
+ .tag.green .dot { background: var(--green); }
3456
+ .main-head .close { margin-left: auto; background: none; border: none; font-size: 24px; line-height: 1; color: var(--ink-3); cursor: pointer; font-family: inherit; }
3457
+ .prog { height: 2px; background: var(--surface-2); }
3458
+ .prog > i { display: block; height: 100%; width: 0; background: var(--blue); transition: width .2s ease; }
3459
+
3460
+ .thread { flex: 1; overflow-y: auto; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
3461
+ .empty { margin: auto; text-align: center; color: var(--ink-3); display: flex; flex-direction: column; align-items: center; gap: 14px; }
3462
+ .empty svg, .empty img { width: 44px; height: auto; opacity: .9; }
3463
+ .empty p { max-width: 320px; font-size: 14px; line-height: 1.5; }
3464
+ .chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 460px; }
3465
+ .chip { font-family: var(--sans); font-weight: 500; font-size: 13px; padding: 6px 14px; border-radius: var(--radius-pill); border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink-2); cursor: pointer; transition: border-color .15s ease, color .15s ease, background .15s ease; }
3466
+ .chip:hover { border-color: var(--blue); color: var(--blue-strong); background: var(--blue-tint); }
3467
+
3468
+ /* Thinking indicator — three dots and an optional phase label. */
3469
+ .msg.thinking { display: inline-flex; align-items: center; gap: 10px; }
3470
+ .think-dots { display: inline-flex; align-items: center; gap: 4px; }
3471
+ .think-dots i {
3472
+ width: 6px; height: 6px; border-radius: 50%;
3473
+ background: var(--ink-3); display: block;
3474
+ animation: nxis-think 1.2s ease-in-out infinite;
3475
+ }
3476
+ .think-dots i:nth-child(2) { animation-delay: .15s; }
3477
+ .think-dots i:nth-child(3) { animation-delay: .3s; }
3478
+ @keyframes nxis-think {
3479
+ 0%, 80%, 100% { opacity: .28; transform: translateY(0); }
3480
+ 40% { opacity: 1; transform: translateY(-3px); }
3481
+ }
3482
+ .think-label {
3483
+ font-family: var(--mono); font-size: 11px; font-weight: 500;
3484
+ letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3);
3485
+ }
3486
+ .think-label:empty { display: none; }
3487
+
3488
+ /* ── The wait, with numbers on it ──────────────────────────────────
3489
+ A first run downloads gigabytes. Dots alone say "working"; they never
3490
+ say "for how long", which is the only question a multi-minute wait
3491
+ raises. The line appears for the phases that have bytes behind them
3492
+ and stays hidden for the ones that do not. */
3493
+ .msg.thinking { flex-direction: column; align-items: stretch; gap: 8px; min-width: 190px; }
3494
+ .think-row { display: inline-flex; align-items: center; gap: 10px; }
3495
+ .think-bytes {
3496
+ margin-left: auto; font-family: var(--mono); font-size: 10px;
3497
+ color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap;
3498
+ }
3499
+ .think-bytes:empty { display: none; }
3500
+
3501
+ /* ── The mark, charging ────────────────────────────────────────────
3502
+ Same progress, drawn on the object already sitting in the empty
3503
+ state. The ring wraps whatever the host set as their logo, so the
3504
+ thing that fills is theirs. */
3505
+ .empty-mark { position: relative; display: grid; place-items: center; }
3506
+ .empty-mark .ring {
3507
+ --pct: 0;
3508
+ position: absolute; inset: -13px;
3509
+ border-radius: 50%;
3510
+ background: conic-gradient(var(--blue) calc(var(--pct) * 1%), var(--surface-2) 0);
3511
+ transition: background .35s linear;
3512
+ }
3513
+ .empty-mark .ring::after {
3514
+ content: ""; position: absolute; inset: 3px;
3515
+ border-radius: 50%; background: var(--bg);
3516
+ }
3517
+ .empty-mark svg, .empty-mark img { position: relative; z-index: 1; }
3518
+
3519
+ /* The stage, standing in for the invitation while weights arrive. */
3520
+ .empty .stage {
3521
+ font-family: var(--mono); font-weight: 500; font-size: 11px;
3522
+ letter-spacing: .05em; color: var(--ink-2); margin: 0;
3523
+ }
3524
+ .empty .stage-bytes {
3525
+ font-family: var(--mono); font-size: 10px; color: var(--ink-3);
3526
+ font-variant-numeric: tabular-nums; margin: -8px 0 0;
3527
+ }
3528
+ .empty .stage-bytes:empty { display: none; }
3529
+ /* The suggestions have waited for the model; let them arrive, rather than
3530
+ appearing as though they had been there all along. */
3531
+ .chips.appear { animation: nxis-chips-in .32s ease both; }
3532
+ @keyframes nxis-chips-in {
3533
+ from { opacity: 0; transform: translateY(6px); }
3534
+ to { opacity: 1; transform: none; }
3535
+ }
3536
+
3537
+ /* The header mark carries what is left of the load once the thread is
3538
+ open: the same ring, smaller, out of the conversation's way. */
3539
+ .main-head .head-mark { position: relative; display: grid; place-items: center; }
3540
+ /* The ring is absolutely positioned, so it paints above a static sibling,
3541
+ and its ::after fills the middle with the panel colour. Without this the
3542
+ disc covers the logo and the header shows an empty circle. */
3543
+ .main-head .brand { position: relative; z-index: 1; }
3544
+ .main-head .head-ring {
3545
+ --pct: 0; position: absolute; inset: -4px; border-radius: 50%;
3546
+ background: conic-gradient(var(--blue) calc(var(--pct) * 1%), var(--surface-2) 0);
3547
+ transition: background .35s linear;
3548
+ }
3549
+ .main-head .head-ring::after {
3550
+ content: ""; position: absolute; inset: 2px; border-radius: 50%; background: var(--bg);
3551
+ }
3552
+ /* No numbers yet: sweep instead of sitting at zero, which reads as hung. */
3553
+ .empty-mark .ring.indet, .main-head .head-ring.indet {
3554
+ background: conic-gradient(from 0deg, transparent 0deg, var(--blue) 300deg, transparent 360deg);
3555
+ animation: nxis-ring-spin 1.1s linear infinite;
3556
+ }
3557
+ @keyframes nxis-ring-spin { to { transform: rotate(360deg); } }
3558
+ /*
3559
+ * Motion carries the signal here, so removing it needs a replacement
3560
+ * rather than a still frame: the dots hold a steady mid-opacity so the
3561
+ * bubble still reads as pending.
3562
+ */
3563
+ @media (prefers-reduced-motion: reduce) {
3564
+ /* The rings carry information, so they stay; only their easing goes. */
3565
+ .empty-mark .ring, .main-head .head-ring, .caret { transition: none; }
3566
+ .chips.appear { animation: none; }
3567
+ /* A spin with the motion removed has to still say "busy": hold a
3568
+ partial arc rather than an empty track. */
3569
+ .empty-mark .ring.indet, .main-head .head-ring.indet {
3570
+ animation: none;
3571
+ background: conic-gradient(var(--blue) 25%, var(--surface-2) 0);
3572
+ }
3573
+ .think-dots i { animation: none; opacity: .5; }
3574
+ }
3575
+
3576
+ /* Bubble styling now lives in _msgEl as Tailwind utilities. The .msg
3577
+ class is kept only as a hook for the wrappers below, which are layout
3578
+ rather than bubbles — so there is nothing here to undo. */
3579
+
3580
+
3581
+
3582
+ .msg.tool.error { border-color: var(--red); color: var(--red); }
3583
+ .msg.tool b { display: inline-flex; align-items: center; gap: 6px; }
3584
+ .msg.tool svg { width: 14px; height: 14px; }
3585
+ .msg.form, .msg.detail { align-self: flex-start; width: 100%; max-width: 380px; }
3586
+
3587
+ /* ── The receipt ───────────────────────────────────────────────────
3588
+ Muted enough to scroll past, addressable when something looks wrong. */
3589
+ /* The route, when debugging: quieter than a receipt, since it is one
3590
+ word of provenance rather than something to read. */
3591
+ .routeline {
3592
+ align-self: flex-start; font-family: var(--mono); font-size: 10px;
3593
+ letter-spacing: .06em; color: var(--ink-3); opacity: .8; padding: 1px 0;
3594
+ }
3595
+
3596
+ details.trace { align-self: flex-start; max-width: 92%; }
3597
+ details.trace > summary {
3598
+ cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 7px;
3599
+ font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
3600
+ color: var(--ink-3); padding: 2px 0;
3601
+ }
3602
+ details.trace > summary::-webkit-details-marker { display: none; }
3603
+ details.trace > summary:hover { color: var(--ink-2); }
3604
+ details.trace > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
3605
+ details.trace.err > summary { color: var(--red); }
3606
+ .caret {
3607
+ width: 0; height: 0; border-left: 4px solid currentColor;
3608
+ border-top: 3.5px solid transparent; border-bottom: 3.5px solid transparent;
3609
+ transition: transform .16s ease; flex-shrink: 0;
3610
+ }
3611
+ details.trace[open] .caret { transform: rotate(90deg); }
3612
+ .trace-body {
3613
+ margin-top: 7px; background: var(--surface); border: 1px solid var(--border);
3614
+ border-radius: 9px; padding: 9px 11px; display: flex; flex-direction: column; gap: 6px;
3615
+ }
3616
+ .trace-label {
3617
+ font-family: var(--mono); font-size: 9.5px; letter-spacing: .12em;
3618
+ text-transform: uppercase; color: var(--ink-3);
3619
+ }
3620
+ .trace-body pre {
3621
+ margin: 0; font-family: var(--mono); font-size: 10.5px; line-height: 1.5;
3622
+ color: var(--ink-2); white-space: pre-wrap; word-break: break-word;
3623
+ max-height: 160px; overflow-y: auto;
3624
+ }
3625
+ .trace-body pre.raw { color: var(--ink); }
3626
+
3627
+ .statusline { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.04em; color: var(--ink-3); padding: 4px 20px 0; min-height: 15px; }
3628
+ .statusline.err { color: var(--red); }
3629
+ /* ── Composer: the input, with a bar beneath it ─────────────
3630
+ Tools on the left, the model beside them, send or stop on the
3631
+ right. One box around both, so the bar reads as part of the input
3632
+ rather than a second control strip. */
3633
+ .composer { padding: 12px 20px 14px; border-top: 1px solid var(--border); }
3634
+ .overlay.window .composer, .overlay.inline .composer { padding: 10px 12px 12px; }
3635
+ .cbox { border: 1px solid var(--border-strong); border-radius: 14px; background: var(--bg); padding: 8px 8px 8px 12px; transition: border-color .15s ease, box-shadow .15s ease; }
3636
+ .cbox:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
3637
+ .composer textarea {
3638
+ display: block; width: 100%; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
3639
+ background: transparent; border: 0; padding: 2px 2px 8px; outline: none; max-height: 140px; overflow-y: auto;
3640
+ }
3641
+ .composer textarea::placeholder { color: var(--ink-3); }
3642
+ .cbar { display: flex; align-items: center; gap: 8px; min-width: 0; }
3643
+ .composer .go { margin-left: auto; flex-shrink: 0; width: 34px; height: 34px; border-radius: 10px; border: none; background: var(--blue); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 2px rgba(37,99,235,.3); }
3644
+ .composer .go:hover { background: var(--blue-strong); }
3645
+ .composer .go:disabled { opacity: .45; cursor: not-allowed; }
3646
+ /* Stop is red, with a filled white square: while a prompt runs this is
3647
+ the one control that matters, and it has to be found at a glance. */
3648
+ .composer .go.stop { background: var(--stop); color: #fff; border: 0; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
3649
+ .composer .go.stop:hover { background: var(--stop-strong); }
3650
+ .composer .go.stop svg { width: 15px; height: 15px; }
3651
+ .composer .go.stop svg path { fill: currentColor; }
3652
+ .composer .go:focus-visible, .tools-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
3653
+
3654
+ .model { display: inline-flex; align-items: center; gap: 6px; min-width: 0; margin-left: 2px; font-size: 12px; font-weight: 500; color: var(--ink-2); white-space: nowrap; overflow: hidden; }
3655
+ .model-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--green); }
3656
+ .model.busy .model-dot { background: var(--blue); animation: mpulse 1.2s ease-in-out infinite; }
3657
+ @keyframes mpulse { 50% { opacity: .35; } }
3658
+ .model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
3659
+ .model-where { flex: none; font-family: var(--mono); font-size: 10px; letter-spacing: .04em; color: var(--ink-3); }
3660
+
3661
+ /* ── Tools menu ── */
3662
+ .tools { position: relative; flex: none; }
3663
+ .tools-btn { position: relative; width: 32px; height: 32px; border: 1px solid var(--border-strong); border-radius: 9px; background: var(--bg); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; padding: 0; }
3664
+ .tools-btn:hover { color: var(--ink); border-color: var(--ink-3); }
3665
+ .tools-btn[aria-expanded="true"] { border-color: var(--blue); color: var(--blue); background: var(--blue-tint); }
3666
+ .tools-btn svg { width: 17px; height: 17px; }
3667
+ .tools-count { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; padding: 0 4px; border-radius: 9px; background: var(--ink); color: var(--bg); font-size: 10px; font-weight: 600; display: grid; place-items: center; border: 2px solid var(--bg); }
3668
+ .tools-count[hidden] { display: none; }
3669
+ .tools-pop { position: absolute; left: -4px; bottom: calc(100% + 12px); z-index: 20; width: 320px; max-width: calc(100vw - 48px); max-height: 380px; overflow-y: auto; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 13px; box-shadow: 0 14px 36px rgba(15,23,42,.16), 0 2px 6px rgba(15,23,42,.06); padding: 6px; }
3670
+ .tools-pop[hidden] { display: none; }
3671
+ .overlay.window .tools-pop, .overlay.inline .tools-pop { width: 296px; }
3672
+ .tp-head { padding: 8px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
3673
+ .tp-title { font-family: var(--display); font-weight: 700; font-size: 13px; color: var(--ink); }
3674
+ .tp-sub { font-size: 11.5px; line-height: 1.4; color: var(--ink-3); margin-top: 2px; }
3675
+ .tp-grp { font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); padding: 8px 10px 3px; }
3676
+ /* Greyed and locked: visible, not the visitor's to change. */
3677
+ .tp-tool { display: flex; align-items: center; gap: 9px; padding: 6px 10px; border-radius: 8px; cursor: default; }
3678
+ .tp-ti { width: 24px; height: 24px; flex: none; border-radius: 6px; background: var(--surface-2); color: var(--ink-3); display: grid; place-items: center; opacity: .75; }
3679
+ .tp-ti svg { width: 13px; height: 13px; }
3680
+ .tp-tn { min-width: 0; flex: 1; }
3681
+ .tp-name { display: block; font-size: 12.5px; font-weight: 500; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3682
+ .tp-desc { display: block; font-size: 11px; color: var(--ink-3); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3683
+ .tp-kind { flex: none; font-family: var(--mono); font-size: 9px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
3684
+ .tp-lock { flex: none; width: 13px; height: 13px; color: var(--ink-3); opacity: .7; display: grid; }
3685
+ .tp-lock svg { width: 13px; height: 13px; }
3686
+ .tp-empty { padding: 12px 10px; font-size: 12px; color: var(--ink-3); }
3687
+ .tp-foot { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--border); font-size: 11px; color: var(--ink-3); }
3688
+ @media (prefers-reduced-motion: reduce) { .model.busy .model-dot { animation: none; } }
3689
+ /* A message the person sent, plus the row of things they can do to it.
3690
+ The wrapper carries the alignment the bubble used to carry on its own. */
3691
+ .turn { display: flex; flex-direction: column; align-items: flex-end; }
3692
+ .turn:focus { outline: none; }
3693
+ .turn .msg.user { align-self: flex-end; }
3694
+ .actions { display: flex; gap: 2px; padding: 2px 2px 0; }
3695
+ .act {
3696
+ display: grid; place-items: center; width: 26px; height: 26px; padding: 0;
3697
+ background: none; border: none; border-radius: 7px; cursor: pointer;
3698
+ color: var(--ink-3); transition: color .12s ease, background .12s ease;
3699
+ }
3700
+ .act:hover { color: var(--ink-2); background: var(--surface); }
3701
+ .act:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
3702
+ .act svg { width: 15px; height: 15px; display: block; }
3703
+ .act.done { color: var(--green-strong); }
3704
+
3705
+ /* Split on hover capability, not on width: a touch laptop needs the row
3706
+ visible and a narrow desktop window does not. Where there is no hover,
3707
+ the actions are simply there, because no gesture will reveal them. */
3708
+ .actions { opacity: .55; transition: opacity .13s ease; }
3709
+ @media (hover: hover) {
3710
+ .actions { opacity: 0; }
3711
+ .turn:hover .actions,
3712
+ .turn:focus-within .actions { opacity: 1; }
3713
+ }
3714
+ @media (prefers-reduced-motion: reduce) {
3715
+ .actions { transition: none; }
3716
+ }
3717
+
3718
+ .editing {
3719
+ align-self: flex-end; max-width: 84%; width: 84%;
3720
+ display: flex; flex-direction: column; gap: 7px;
3721
+ background: var(--bg); border: 1px solid var(--blue); border-radius: 12px; padding: 9px 10px;
3722
+ }
3723
+ .editing textarea {
3724
+ width: 100%; border: none; outline: none; resize: none; background: transparent;
3725
+ font-family: var(--sans); font-size: 13px; line-height: 1.5; color: var(--ink);
3726
+ }
3727
+ .edit-actions { display: flex; gap: 7px; justify-content: flex-end; }
3728
+ .ebtn {
3729
+ font-family: var(--sans); font-size: 12px; font-weight: 500; border-radius: 8px;
3730
+ padding: 5px 11px; cursor: pointer; border: 1px solid var(--border-strong);
3731
+ background: var(--bg); color: var(--ink-2);
3732
+ }
3733
+ .ebtn:hover { border-color: var(--blue); color: var(--blue-strong); }
3734
+ .ebtn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
3735
+ .ebtn.primary:hover { background: var(--blue-strong); color: #fff; }
3736
+ .edit-hint {
3737
+ font-family: var(--mono); font-size: 10px; letter-spacing: .05em;
3738
+ color: var(--ink-3); align-self: flex-end; padding-top: 2px;
3739
+ }
3740
+
3741
+ .stopped-note {
3742
+ align-self: flex-start;
3743
+ font-size: 12px;
3744
+ color: var(--ink-3);
3745
+ padding: 2px 0 6px;
3746
+ letter-spacing: .01em;
3747
+ }
3748
+ .composer .go svg { width: 17px; height: 17px; }
3749
+
3750
+ /* Attribution. Quiet enough to sit under a customer's own chat surface
3751
+ without competing with it, legible enough to be worth having. */
3752
+ .foot { display: flex; justify-content: center; padding: 0 20px 12px; }
3753
+ .powered {
3754
+ display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
3755
+ font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.1em;
3756
+ text-transform: uppercase; color: var(--ink-3);
3757
+ border-radius: var(--radius-pill); padding: 4px 10px;
3758
+ transition: color .15s ease, background .15s ease;
3759
+ }
3760
+ .powered:hover { color: var(--ink-2); background: var(--surface); }
3761
+ .powered:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
3762
+ .powered svg { width: 13px; height: auto; display: block; flex-shrink: 0; }
3763
+
3764
+ /* ── Inline generated form ──────────────────────────────────── */
3765
+ .genform { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 18px 16px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 13px; }
3766
+ .genform-head { display: flex; flex-direction: column; gap: 3px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
3767
+ .gf-tool { font-family: var(--display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
3768
+ .gf-desc { font-size: 13px; color: var(--ink-2); }
3769
+ .field { display: flex; flex-direction: column; gap: 5px; }
3770
+ .field label { font-family: var(--mono); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
3771
+ .field .req { color: var(--blue); }
3772
+ .field input, .field select {
3773
+ font-family: var(--sans); font-size: 14px; color: var(--ink);
3774
+ background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 11px; outline: none;
3775
+ transition: border-color .15s ease, box-shadow .15s ease;
3776
+ }
3777
+ .field input:focus, .field select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
3778
+ .field input.invalid, .field select.invalid { border-color: var(--red); box-shadow: 0 0 0 3px rgba(185,28,28,.12); }
3779
+ .field .check { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--ink); }
3780
+ .field .check input { width: auto; }
3781
+ .genform-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
3782
+ /* Buttons — radius 10, weight 600; Action confirms a positive outcome,
3783
+ Secondary (ghost class) is the alternative path. */
3784
+ .btn { font-family: var(--sans); font-size: 14px; font-weight: 600; border-radius: var(--radius-sm); padding: 11px 20px; cursor: pointer; border: 1px solid transparent; display: inline-flex; align-items: center; gap: 8px; transition: .15s; }
3785
+ .btn svg { width: 16px; height: 16px; }
3786
+ .btn.ghost { background: var(--bg); border-color: var(--border-strong); color: var(--ink); }
3787
+ .btn.ghost:hover { border-color: var(--ink-3); }
3788
+ /*
3789
+ * Signal Blue, not Action Green. design.md §4 gives green to "confirm a
3790
+ * positive outcome", which is the submitted card's badge and eyebrow,
3791
+ * after the fact. The button is the main path before anything has
3792
+ * happened, and that is blue. Using green on both blurred "about to
3793
+ * happen" into "has happened".
3794
+ *
3795
+ * It also has to be blue to stay consistent with the A2UI cards:
3796
+ * --a2ui-color-primary drives their primary buttons AND their
3797
+ * ChoicePicker selection, so repointing it at green would have coloured
3798
+ * "Water Heaters" as a positive outcome rather than a choice.
3799
+ */
3800
+ .btn.primary { background: var(--blue); color: #fff; }
3801
+ .btn.primary:hover { background: var(--blue-strong); }
3802
+ .btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
3803
+ .genform-result { display: none; font-size: 13px; align-items: center; gap: 8px; padding: 2px; }
3804
+ .genform-result.error { display: flex; color: var(--red); }
3805
+ .genform-result svg { width: 16px; height: 16px; flex-shrink: 0; }
3806
+ .gf-empty { color: var(--ink-3); font-size: 14px; }
3807
+
3808
+ /* ── Inline detail (confirmation) card ──────────────────────── */
3809
+ .detailcard { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 4px; }
3810
+ .dc-badge { width: 44px; height: 44px; border-radius: 50%; background: var(--green-tint); color: var(--green-strong); display: flex; align-items: center; justify-content: center; margin-bottom: 8px; }
3811
+ .dc-badge svg { width: 22px; height: 22px; }
3812
+ .dc-eyebrow { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--green-strong); }
3813
+ .dc-title { font-family: var(--display); font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
3814
+ .dc-sub { font-size: 13px; color: var(--ink-2); margin-bottom: 6px; }
3815
+ .dc-list { margin: 6px 0 0; border-top: 1px solid var(--border); }
3816
+ /* Review is the before-picture of the same card. Green marks a positive
3817
+ outcome (design.md §2), and nothing has happened yet — so the pending
3818
+ state carries Signal Blue, the colour of the action about to be taken. */
3819
+ .dc-badge.review { background: var(--blue-tint); color: var(--blue); }
3820
+ .dc-eyebrow.review { color: var(--blue-strong); }
3821
+ .dc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
3822
+ .dc-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
3823
+ /* One line per row, truncated. A card in a window or a rail is too
3824
+ narrow for a long value to wrap without becoming a column of
3825
+ fragments. The whole row comes back on hover or keyboard focus, and
3826
+ every cell carries its full text as a title too. */
3827
+ .dc-row dt { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); padding-top: 2px; flex: 0 1 auto; min-width: 0; max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3828
+ .dc-row dd { margin: 0; flex: 1 1 auto; min-width: 0; font-size: 14px; color: var(--ink); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3829
+ .dc-row:hover dt, .dc-row:hover dd, .dc-row:focus-within dt, .dc-row:focus-within dd { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
3830
+ .dc-row:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 4px; }
3831
+ /* Buttons keep their words on one line and wrap as whole buttons. */
3832
+ .dc-actions { flex-wrap: wrap; }
3833
+ .dc-actions .btn { white-space: nowrap; }
3834
+ .overlay.window .dc-actions .btn, .overlay.inline .dc-actions .btn { padding: 9px 14px; font-size: 13px; }
3835
+
3836
+ /* Code inside a message: monospace, and scrolled rather than wrapped,
3837
+ so a long line does not break at arbitrary places. */
3838
+ .msg .codeblock { margin: 6px 0 0; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); overflow-x: auto; max-width: 100%; }
3839
+ .msg .codeblock:first-child { margin-top: 0; }
3840
+ .msg .codeblock code { font-family: var(--mono); font-size: 12.5px; line-height: 1.55; white-space: pre; color: var(--ink); }
3841
+ .msg.user .codeblock { background: rgba(255,255,255,.16); border-color: rgba(255,255,255,.28); }
3842
+ .msg.user .codeblock code { color: #fff; }
3843
+
3844
+ /* A long block folds away: kept, but not filling the thread. */
3845
+ .codefold { margin-top: 6px; }
3846
+ .codefold > summary { cursor: pointer; list-style: none; font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); padding: 2px 0; }
3847
+ .codefold > summary::-webkit-details-marker { display: none; }
3848
+ .codefold > summary::before { content: '▸ '; }
3849
+ .codefold[open] > summary::before { content: '▾ '; }
3850
+ .codefold > summary:hover { color: var(--ink-2); }
3851
+ .codefold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
3852
+ .codefold .codeblock { margin-top: 4px; }
3853
+
3854
+ /* ── Inline result cards — feature-card pattern: tinted icon chip,
3855
+ tags, tight Manrope headline, one supporting line. ─────── */
3856
+ .msg.results { align-self: stretch; width: 100%; }
3857
+ .rc-head { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--blue); margin: 2px 0 10px; }
3858
+ .rc-empty { font-size: 13px; color: var(--ink-2); padding: 8px 0; }
3859
+ .rc-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
3860
+ .resultcard { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-sm); transition: box-shadow .15s ease, transform .15s ease; }
3861
+ .resultcard:hover { box-shadow: var(--shadow); transform: translateY(-1px); }
3862
+ .rc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
3863
+ .rc-chip { width: 44px; height: 44px; border-radius: 50%; background: var(--blue-tint); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
3864
+ .rc-chip svg, .rc-chip img { width: 21px; height: auto; display: block; }
3865
+ .rc-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
3866
+ .rc-tags .tag { font-size: 12px; padding: 3px 10px; }
3867
+ .rc-title { font-family: var(--display); font-size: 16px; font-weight: 800; color: var(--ink); line-height: 1.25; letter-spacing: -0.01em; }
3868
+ .rc-sub { font-size: 13px; line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
3869
+ .rc-list { margin: 10px 0 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; }
3870
+ .rc-row { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; line-height: 1.4; }
3871
+ .rc-row dt { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); flex: 0 1 auto; min-width: 0; max-width: 45%; padding-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3872
+ .rc-row dd { margin: 0; flex: 1 1 auto; min-width: 0; color: var(--ink); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3873
+ .rc-row:hover dt, .rc-row:hover dd, .rc-row:focus-within dt, .rc-row:focus-within dd { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
3874
+ /* The headline and its supporting line truncate too, and come back
3875
+ when the card is hovered or focused. */
3876
+ .rc-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3877
+ .rc-sub { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
3878
+ .resultcard:hover .rc-title, .resultcard:focus-within .rc-title { white-space: normal; overflow: visible; }
3879
+ .resultcard:hover .rc-sub, .resultcard:focus-within .rc-sub { -webkit-line-clamp: unset; overflow: visible; }
3880
+ .rc-id { margin-top: 10px; min-width: 0; }
3881
+ .rc-open { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 13px; font-weight: 600; color: var(--blue-strong); text-decoration: none; }
3882
+ .rc-open span { transition: transform 120ms ease; }
3883
+ .rc-open:hover { text-decoration: underline; }
3884
+ .rc-open:hover span { transform: translateX(2px); }
3885
+ .rc-open:focus-visible { outline: 2px solid var(--blue-strong); outline-offset: 2px; border-radius: 4px; }
3886
+ .rc-id code { display: block; font-family: var(--mono); font-size: 10px; color: var(--blue-strong); background: var(--inline-bg); border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3887
+ .rc-row dd a { color: var(--blue-strong); text-decoration: none; }
3888
+ .rc-row dd a:hover { text-decoration: underline; }
3889
+
3890
+ /* ── Metrics panel (technical details on demand) ─────────────── */
3891
+ .msg.metrics { align-self: stretch; width: 100%; max-width: 100%; background: none; border: none; padding: 0; white-space: normal; }
3892
+ .metricscard { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }
3893
+ .metricscard summary { cursor: pointer; list-style: none; font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.06em; color: var(--ink-2); padding: 11px 16px; user-select: none; }
3894
+ .metricscard summary::-webkit-details-marker { display: none; }
3895
+ .metricscard summary:hover { color: var(--blue-strong); }
3896
+ .metricscard[open] summary { border-bottom: 1px solid var(--border); }
3897
+ .mx-table { width: calc(100% - 32px); margin: 12px 16px; border-collapse: collapse; font-size: 12px; }
3898
+ .mx-table th { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-3); text-align: left; padding: 4px 8px 6px 0; border-bottom: 1px solid var(--border); }
3899
+ .mx-table td { padding: 5px 8px 5px 0; border-bottom: 1px solid var(--border); color: var(--ink-2); }
3900
+ .mx-table th.num, .mx-table td.num { text-align: right; font-family: var(--mono); font-size: 11px; }
3901
+ .mx-table tr.total td { color: var(--ink); font-weight: 600; border-bottom: none; }
3902
+ .mx-compare { padding: 4px 16px 16px; display: flex; flex-direction: column; gap: 8px; }
3903
+ .mx-compare-title { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 2px; }
3904
+ .mx-bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
3905
+ .mx-bar-label { font-family: var(--mono); font-size: 10px; color: var(--ink-3); width: 56px; flex-shrink: 0; }
3906
+ .mx-bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
3907
+ .mx-bar-track i { display: block; height: 100%; border-radius: 4px; }
3908
+ .mx-bar-track i.mcp { background: var(--blue); }
3909
+ .mx-bar-track i.rag { background: var(--ink-3); }
3910
+ .mx-bar-value { font-family: var(--mono); font-size: 11px; color: var(--ink-2); width: 64px; text-align: right; flex-shrink: 0; }
3911
+ .mx-savings { align-self: flex-start; font-size: 12px; font-weight: 600; border-radius: var(--radius-pill); padding: 4px 12px; margin-top: 2px; }
3912
+ .mx-savings.pos { color: var(--green-strong); background: var(--green-tint); }
3913
+ .mx-savings.neg { color: var(--red); background: #FEF2F2; }
3914
+ .mx-note { font-size: 11px; line-height: 1.5; color: var(--ink-3); }
3915
+
3916
+ @media (max-width: 840px) {
3917
+ .side { position: absolute; inset: 0 auto 0 0; z-index: 6; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 0 18px 48px rgba(15,23,42,.18); }
3918
+ .overlay.nav .side { transform: none; }
3919
+ /* Under the drawer, over the conversation: the strip of thread still
3920
+ showing beside the drawer is what a tap to dismiss lands on. */
3921
+ .overlay.nav .scrim {
3922
+ display: block; position: absolute; inset: 0; z-index: 5;
3923
+ background: rgba(15,23,42,.32);
3924
+ animation: nx-scrim-in .2s ease;
3925
+ }
3926
+ @keyframes nx-scrim-in { from { opacity: 0; } to { opacity: 1; } }
3927
+ @media (prefers-reduced-motion: reduce) {
3928
+ .side { transition: none; }
3929
+ .overlay.nav .scrim { animation: none; }
3930
+ }
3931
+ .menu { display: flex; }
3932
+ .msg { max-width: 86%; }
3933
+ .hist-del { opacity: 1; }
3934
+ }
3935
+ </style>
3936
+ <button class="launcher" title="Open ${escapeHtml(this._name)}" aria-label="Open ${escapeHtml(this._name)}">${this._logoMarkup()}</button>
3937
+ <div class="overlay" role="dialog" aria-label="${escapeHtml(this._name)}">
3938
+ <div class="scrim" hidden></div>
3939
+ <aside class="side">
3940
+ <div class="side-head">
3941
+ <span class="brand">${this._logoMarkup()}</span>
3942
+ <span class="side-title">Conversations</span>
3943
+ <button class="newchat" title="New conversation" aria-label="New conversation">${ICON_PLUS}</button>
3944
+ </div>
3945
+ <div class="history"></div>
3946
+ </aside>
3947
+ <main class="main">
3948
+ <div class="main-head">
3949
+ <button class="menu" aria-label="Toggle conversations">${ICON_MENU}</button>
3950
+ <span class="head-mark"><span class="head-ring" hidden></span><span class="brand">${this._logoMarkup()}</span></span>
3951
+ <h2>${escapeHtml(this._name)}</h2>
3952
+ <span class="tag blue provenance"><i class="dot"></i>On-device</span>
3953
+ <button class="close" aria-label="Close">×</button>
3954
+ </div>
3955
+ <div class="prog"><i></i></div>
3956
+ <button class="jstatus" type="button" hidden>
3957
+ <span class="js-spin" aria-hidden="true"></span><span class="js-label"></span><span class="js-go">Show</span>
3958
+ </button>
3959
+ <div class="thread"></div>
3960
+ <div class="statusline"></div>
3961
+ <div class="composer">
3962
+ <div class="cbox">
3963
+ <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
3964
+ <div class="cbar">
3965
+ <div class="tools">
3966
+ <button class="tools-btn" type="button" aria-haspopup="dialog" aria-expanded="false" aria-label="Tools on this page">${ICON_TOOLS}<span class="tools-count" hidden></span></button>
3967
+ <div class="tools-pop" role="dialog" aria-label="What the agent can use here" hidden></div>
3968
+ </div>
3969
+ <span class="model"><i class="model-dot" aria-hidden="true"></i><span class="model-name"></span><span class="model-where"></span></span>
3970
+ <button class="go" aria-label="Send">${ICON_ARROW}</button>
3971
+ </div>
3972
+ </div>
3973
+ </div>
3974
+ <footer class="foot">
3975
+ <a class="powered" href="${NXIS_SITE_URL}" target="_blank" rel="noopener noreferrer">
3976
+ ${NXIS_LOGO_SVG}<span>Powered by NxisAI</span>
3977
+ </a>
3978
+ </footer>
3979
+ </main>
3980
+ </div>`;
3981
+ }
3982
+ }
3983
+
3984
+ customElements.define('nxis-agent-fullscreen', NxisAgentFullscreen);