@nxisai/agent-harness 0.2.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 (126) hide show
  1. package/README.md +434 -0
  2. package/dist/a2ui/cards.d.ts +55 -0
  3. package/dist/a2ui/cards.d.ts.map +1 -0
  4. package/dist/a2ui/cards.js +133 -0
  5. package/dist/a2ui/cards.js.map +1 -0
  6. package/dist/a2ui/emit.d.ts +33 -0
  7. package/dist/a2ui/emit.d.ts.map +1 -0
  8. package/dist/a2ui/emit.js +116 -0
  9. package/dist/a2ui/emit.js.map +1 -0
  10. package/dist/a2ui/form.d.ts +20 -0
  11. package/dist/a2ui/form.d.ts.map +1 -0
  12. package/dist/a2ui/form.js +92 -0
  13. package/dist/a2ui/form.js.map +1 -0
  14. package/dist/a2ui/host.d.ts +34 -0
  15. package/dist/a2ui/host.d.ts.map +1 -0
  16. package/dist/a2ui/host.js +47 -0
  17. package/dist/a2ui/host.js.map +1 -0
  18. package/dist/a2ui/index.d.ts +23 -0
  19. package/dist/a2ui/index.d.ts.map +1 -0
  20. package/dist/a2ui/index.js +20 -0
  21. package/dist/a2ui/index.js.map +1 -0
  22. package/dist/a2ui/stepper.d.ts +26 -0
  23. package/dist/a2ui/stepper.d.ts.map +1 -0
  24. package/dist/a2ui/stepper.js +162 -0
  25. package/dist/a2ui/stepper.js.map +1 -0
  26. package/dist/a2ui/theme.d.ts +21 -0
  27. package/dist/a2ui/theme.d.ts.map +1 -0
  28. package/dist/a2ui/theme.js +134 -0
  29. package/dist/a2ui/theme.js.map +1 -0
  30. package/dist/a2ui/to-v09.d.ts +31 -0
  31. package/dist/a2ui/to-v09.d.ts.map +1 -0
  32. package/dist/a2ui/to-v09.js +104 -0
  33. package/dist/a2ui/to-v09.js.map +1 -0
  34. package/dist/engine.d.ts +209 -0
  35. package/dist/engine.d.ts.map +1 -0
  36. package/dist/engine.js +505 -0
  37. package/dist/engine.js.map +1 -0
  38. package/dist/harness/harness.d.ts +284 -0
  39. package/dist/harness/harness.d.ts.map +1 -0
  40. package/dist/harness/harness.js +568 -0
  41. package/dist/harness/harness.js.map +1 -0
  42. package/dist/harness/plan.d.ts +32 -0
  43. package/dist/harness/plan.d.ts.map +1 -0
  44. package/dist/harness/plan.js +68 -0
  45. package/dist/harness/plan.js.map +1 -0
  46. package/dist/harness/policy.d.ts +49 -0
  47. package/dist/harness/policy.d.ts.map +1 -0
  48. package/dist/harness/policy.js +34 -0
  49. package/dist/harness/policy.js.map +1 -0
  50. package/dist/harness/renderer.d.ts +163 -0
  51. package/dist/harness/renderer.d.ts.map +1 -0
  52. package/dist/harness/renderer.js +12 -0
  53. package/dist/harness/renderer.js.map +1 -0
  54. package/dist/harness/router/coordinator.d.ts +120 -0
  55. package/dist/harness/router/coordinator.d.ts.map +1 -0
  56. package/dist/harness/router/coordinator.js +145 -0
  57. package/dist/harness/router/coordinator.js.map +1 -0
  58. package/dist/harness/router/rules.d.ts +48 -0
  59. package/dist/harness/router/rules.d.ts.map +1 -0
  60. package/dist/harness/router/rules.js +38 -0
  61. package/dist/harness/router/rules.js.map +1 -0
  62. package/dist/harness/slots.d.ts +73 -0
  63. package/dist/harness/slots.d.ts.map +1 -0
  64. package/dist/harness/slots.js +68 -0
  65. package/dist/harness/slots.js.map +1 -0
  66. package/dist/harness/tools/bridge.d.ts +71 -0
  67. package/dist/harness/tools/bridge.d.ts.map +1 -0
  68. package/dist/harness/tools/bridge.js +271 -0
  69. package/dist/harness/tools/bridge.js.map +1 -0
  70. package/dist/harness/tools/client.d.ts +11 -0
  71. package/dist/harness/tools/client.d.ts.map +1 -0
  72. package/dist/harness/tools/client.js +21 -0
  73. package/dist/harness/tools/client.js.map +1 -0
  74. package/dist/harness/tools/discovery.d.ts +13 -0
  75. package/dist/harness/tools/discovery.d.ts.map +1 -0
  76. package/dist/harness/tools/discovery.js +43 -0
  77. package/dist/harness/tools/discovery.js.map +1 -0
  78. package/dist/harness/tools/local-context.d.ts +38 -0
  79. package/dist/harness/tools/local-context.d.ts.map +1 -0
  80. package/dist/harness/tools/local-context.js +55 -0
  81. package/dist/harness/tools/local-context.js.map +1 -0
  82. package/dist/harness/tools/schema.d.ts +52 -0
  83. package/dist/harness/tools/schema.d.ts.map +1 -0
  84. package/dist/harness/tools/schema.js +175 -0
  85. package/dist/harness/tools/schema.js.map +1 -0
  86. package/dist/harness/tools/source.d.ts +65 -0
  87. package/dist/harness/tools/source.d.ts.map +1 -0
  88. package/dist/harness/tools/source.js +89 -0
  89. package/dist/harness/tools/source.js.map +1 -0
  90. package/dist/harness/tools/webmcp.d.ts +50 -0
  91. package/dist/harness/tools/webmcp.d.ts.map +1 -0
  92. package/dist/harness/tools/webmcp.js +114 -0
  93. package/dist/harness/tools/webmcp.js.map +1 -0
  94. package/dist/index.d.ts +49 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.js +38 -0
  97. package/dist/index.js.map +1 -0
  98. package/dist/providers/types.d.ts +123 -0
  99. package/dist/providers/types.d.ts.map +1 -0
  100. package/dist/providers/types.js +2 -0
  101. package/dist/providers/types.js.map +1 -0
  102. package/dist/types.d.ts +223 -0
  103. package/dist/types.d.ts.map +1 -0
  104. package/dist/types.js +8 -0
  105. package/dist/types.js.map +1 -0
  106. package/dist/ui/agent-fullscreen.d.ts +41 -0
  107. package/dist/ui/agent-fullscreen.d.ts.map +1 -0
  108. package/dist/ui/agent-fullscreen.js +2821 -0
  109. package/dist/ui/agent-fullscreen.js.map +1 -0
  110. package/dist/ui/config.d.ts +88 -0
  111. package/dist/ui/config.d.ts.map +1 -0
  112. package/dist/ui/config.js +96 -0
  113. package/dist/ui/config.js.map +1 -0
  114. package/dist/ui/index.d.ts +22 -0
  115. package/dist/ui/index.d.ts.map +1 -0
  116. package/dist/ui/index.js +19 -0
  117. package/dist/ui/index.js.map +1 -0
  118. package/dist/ui/tailwind.generated.d.ts +2 -0
  119. package/dist/ui/tailwind.generated.d.ts.map +1 -0
  120. package/dist/ui/tailwind.generated.js +3 -0
  121. package/dist/ui/tailwind.generated.js.map +1 -0
  122. package/dist/webmcp-contract.d.ts +57 -0
  123. package/dist/webmcp-contract.d.ts.map +1 -0
  124. package/dist/webmcp-contract.js +11 -0
  125. package/dist/webmcp-contract.js.map +1 -0
  126. package/package.json +39 -0
@@ -0,0 +1,2821 @@
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 } from '../a2ui/host.js';
24
+ import { emitCardsSurface } 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 } from '../a2ui/stepper.js';
33
+ import { AgentHarness, isAbort } from '../harness/harness.js';
34
+ import { WebMcpTools } from '../harness/tools/webmcp.js';
35
+ import { extractResult, missingRequired, toolSchema, } from '../harness/tools/schema.js';
36
+ import { connectTools } from '../harness/tools/source.js';
37
+ import { AgentCoordinator } from '../harness/router/coordinator.js';
38
+ import { LocalGemmaProvider, humanize, escapeHtml, NXIS_LOGO_SVG, NXIS_SITE_URL, ICON_CHECK, ICON_WARN, ICON_ARROW, ICON_PLUS, ICON_MENU, ICON_TRASH, ICON_STOP, ICON_COPY, ICON_PENCIL, ICON_REDO, ICON_CHECK_MARK, } from '../engine.js';
39
+ import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
40
+ // Prefixed keys so state never collides with other Nxis examples on the same origin.
41
+ const PROFILE_KEY = 'nxis-listings-agent-profile';
42
+ const CONV_KEY = 'nxis-listings-agent-conversations';
43
+ // ── Metrics (Typed Agent vs Traditional RAG) ─────────────────────────────────
44
+ // RAG retrieval assumptions, ported from the original demo's MetricsPanel.
45
+ const AVG_HTML_CHUNK_TOKENS = 650; // avg tokens per chunked HTML segment (nav/footer/CSS included)
46
+ const CHUNKS_PER_RESULT = 3; // header + services + reviews chunks to match one JSON-LD record
47
+ const TOOL_DEF_OVERHEAD = 600; // ~tokens for the MCP tool schemas in the prompt
48
+ function estimateRagTokens(rounds) {
49
+ const toolCalls = rounds.reduce((s, m) => s + m.toolCalls, 0);
50
+ // Floor: each retrieval call fetches at least 3 results in a typical RAG setup.
51
+ const numResults = Math.max(rounds.reduce((s, m) => s + m.results, 0), toolCalls * 3);
52
+ // Round 1 input = system prompt + tool definitions + user message. RAG keeps
53
+ // the same prompt + user but swaps tool definitions for retrieved chunks.
54
+ const round1Input = rounds.length ? rounds[0].promptTokens : 3000;
55
+ const ragBaseContext = Math.max(round1Input - TOOL_DEF_OVERHEAD, 0);
56
+ const ragChunks = numResults * CHUNKS_PER_RESULT;
57
+ return {
58
+ ragInput: ragBaseContext + ragChunks * AVG_HTML_CHUNK_TOKENS,
59
+ ragOutput: rounds.reduce((s, m) => s + m.outputTokens, 0),
60
+ numResults,
61
+ ragChunks,
62
+ };
63
+ }
64
+ const fmt = (n) => n.toLocaleString();
65
+ /**
66
+ * The contents of a pending bubble: three dots, a phase label and a byte count.
67
+ *
68
+ * The dots are separate spans rather than an animated glyph, so the motion can
69
+ * be disabled under `prefers-reduced-motion` while the bubble still reads as
70
+ * pending. The label and byte spans are filled per phase and hidden while empty.
71
+ */
72
+ const THINKING_MARKUP = '<span class="think-row">' +
73
+ '<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
74
+ '<span class="think-label"></span>' +
75
+ '<span class="think-bytes"></span>' +
76
+ '</span>';
77
+ /** Format a byte count for display, as `"412 MB"` or `"1.6 GB"`. */
78
+ function formatBytes(n) {
79
+ const mb = n / (1024 * 1024);
80
+ if (mb >= 1024)
81
+ return `${(mb / 1024).toFixed(1)} GB`;
82
+ return `${Math.round(mb)} MB`;
83
+ }
84
+ const uid = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
85
+ class NxisAgentFullscreen extends HTMLElement {
86
+ constructor() {
87
+ super();
88
+ /** True while the turn in flight can still be called off. */
89
+ this._stoppable = false;
90
+ /**
91
+ * Settles when the turn in flight is finished, however it finished.
92
+ *
93
+ * Edit and send-again stop the current turn before replacing the question it
94
+ * was answering, and a stop resolves a tick later. Without something to wait
95
+ * on, the re-run would race `_onSend`'s own guard and be dropped.
96
+ */
97
+ this._turnDone = null;
98
+ /** Built only when the host defines agents — otherwise there is one harness. */
99
+ this._coordinator = null;
100
+ this._agentDefs = null;
101
+ this._routes = [];
102
+ this._fallbackAgent = '';
103
+ /** The live ask/confirm surface, if one is open. */
104
+ this._prompt = null;
105
+ this._persistPrefs = false;
106
+ this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
107
+ this._suggestions = DEFAULT_SUGGESTIONS;
108
+ this._emptyState = DEFAULT_EMPTY_STATE;
109
+ this._name = DEFAULT_AGENT_NAME;
110
+ this._logo = DEFAULT_LOGO;
111
+ this._theme = {};
112
+ /** The pending bubble currently on screen, if any — the target for progress. */
113
+ this._liveBubble = null;
114
+ /** The engine's current phase name, shown in the empty state while loading. */
115
+ this._stage = '';
116
+ /** The one discovery run, kicked off on first open and awaited before a turn. */
117
+ this._connect = null;
118
+ this._loadPct = 0;
119
+ /**
120
+ * Which mode the empty state is currently drawn in, or null when a
121
+ * conversation has replaced it.
122
+ *
123
+ * The load almost never starts while the empty state is being built, since
124
+ * opening the panel is what starts it, so the view has to notice that it moved
125
+ * underneath it and redraw. Without this the chips stay up through a load
126
+ * that was supposed to withhold them, and never come back when it ends.
127
+ */
128
+ this._emptyLoading = null;
129
+ /** A provider the host supplied, used instead of the local model. */
130
+ this._provider = null;
131
+ this.attachShadow({ mode: 'open' });
132
+ this._turnActive = false;
133
+ this._tool = null; // tool of the live inline form (if any)
134
+ this._formEl = null; // the live inline form element (transient, not persisted)
135
+ this._profile = {}; // device-local profile
136
+ this._conversations = []; // [{ id, title, messages, items }]
137
+ this._activeId = null;
138
+ this._surfaces = [];
139
+ // Opt out with a2ui="false" to compare against the inline renderers, which
140
+ // are kept rather than deleted, since the basic catalog cannot express the tag
141
+ // colours, links and logo chip they use.
142
+ this._useA2ui = this.getAttribute('a2ui') !== 'false';
143
+ }
144
+ connectedCallback() {
145
+ this.shadowRoot.innerHTML = this._template();
146
+ this._applyIdentity();
147
+ this._applyTheme();
148
+ const root = this.shadowRoot;
149
+ this._els = {
150
+ launcher: root.querySelector('.launcher'),
151
+ overlay: root.querySelector('.overlay'),
152
+ close: root.querySelector('.close'),
153
+ menu: root.querySelector('.menu'),
154
+ scrim: root.querySelector('.scrim'),
155
+ newchat: root.querySelector('.newchat'),
156
+ history: root.querySelector('.history'),
157
+ thread: root.querySelector('.thread'),
158
+ status: root.querySelector('.statusline'),
159
+ bar: root.querySelector('.prog > i'),
160
+ input: root.querySelector('.composer textarea'),
161
+ go: root.querySelector('.composer .go'),
162
+ };
163
+ // A host-supplied provider wins; otherwise the local model, configured from
164
+ // the attributes that only mean anything to it.
165
+ this.provider =
166
+ this._provider ??
167
+ new LocalGemmaProvider({
168
+ modelHost: this.getAttribute('model-host') || undefined,
169
+ modelPathTemplate: this.getAttribute('model-path-template') || undefined,
170
+ // cache="false" skips Cache Storage: the escape hatch for a browser
171
+ // whose cache backend fails on entries this size.
172
+ useBrowserCache: this.getAttribute('cache') !== 'false',
173
+ systemPrompt: this._systemPrompt,
174
+ onStatus: (t, e) => this._setStatus(t, e),
175
+ onProgress: (pct, detail) => {
176
+ this._loadPct = pct;
177
+ this._loadDetail = detail;
178
+ this._setBar(pct);
179
+ this._liveBubble?.setProgress(pct, detail);
180
+ this._paintLoad();
181
+ },
182
+ });
183
+ this.toolSource = new WebMcpTools();
184
+ this._paintProvenance();
185
+ this._buildAgents();
186
+ // The element renders; the harness decides. Everything from choosing a
187
+ // tool to running it happens behind harness.turn().
188
+ this._harness = new AgentHarness({
189
+ provider: this.provider,
190
+ toolSource: this.toolSource,
191
+ renderer: this._renderer(),
192
+ profile: (schema) => this._profileForSchema(schema),
193
+ onMessages: () => this._saveConversations(),
194
+ });
195
+ // Preferences are supplied by the host page through `element.preferences`.
196
+ // Anything a previous build persisted is picked up so upgrading does not
197
+ // silently drop a user's saved details; `persist` then stays true so they
198
+ // are not silently dropped on the next write either.
199
+ this._prefsApi = this._buildPreferencesApi();
200
+ this._profile = this._loadProfile();
201
+ this._persistPrefs = Object.keys(this._profile).length > 0;
202
+ // Conversation history.
203
+ this._loadConversations();
204
+ this._renderHistory();
205
+ this._renderThread();
206
+ this._els.launcher.addEventListener('click', () => this._open());
207
+ this._els.close.addEventListener('click', () => {
208
+ this._els.overlay.classList.remove('show');
209
+ // A remote turn can outlive the panel, and its answer would go unread.
210
+ this._setStoppable(false);
211
+ (this._coordinator ?? this._harness).stop();
212
+ });
213
+ this._els.menu.addEventListener('click', () => this._setNav(!this._navOpen()));
214
+ // Tapping the conversation is how a drawer is dismissed everywhere else.
215
+ // The drawer is a fixed 280px, so on any phone a strip of the thread stays
216
+ // visible beside it, and that strip is the target.
217
+ this._els.scrim.addEventListener('click', () => this._setNav(false));
218
+ this._els.newchat.addEventListener('click', () => this._newConversation());
219
+ this._els.go.addEventListener('click', () => {
220
+ // One control, two jobs. A separate stop button would be inactive for
221
+ // most of a conversation, and appears where attention is not.
222
+ if (this._stoppable)
223
+ this._stopTurn();
224
+ else
225
+ this._onSend();
226
+ });
227
+ this._els.input.addEventListener('input', () => this._autosize());
228
+ this._els.input.addEventListener('keydown', (e) => {
229
+ if (e.key === 'Enter' && !e.shiftKey) {
230
+ e.preventDefault();
231
+ this._onSend();
232
+ }
233
+ });
234
+ // Escape stops the turn, and only that. Closing the panel on the same key
235
+ // would mean the reflex for "wait, no" also throws away the thread it was
236
+ // aimed at, and the two mistakes look identical until it is too late.
237
+ this._els.overlay.addEventListener('keydown', (e) => {
238
+ if (e.key !== 'Escape')
239
+ return;
240
+ // The drawer is on top of everything, so it is what Escape is aimed at
241
+ // while it is open. Only once it is gone does the key reach the turn.
242
+ if (this._navOpen()) {
243
+ e.preventDefault();
244
+ e.stopPropagation();
245
+ this._setNav(false);
246
+ return;
247
+ }
248
+ if (!this._stoppable)
249
+ return;
250
+ e.preventDefault();
251
+ e.stopPropagation();
252
+ this._stopTurn();
253
+ });
254
+ const preload = this.preload;
255
+ if (preload !== 'none')
256
+ this.provider.preload?.(preload);
257
+ if (this.hasAttribute('auto-open'))
258
+ this._open();
259
+ }
260
+ _navOpen() {
261
+ return this._els.overlay.classList.contains('nav');
262
+ }
263
+ /**
264
+ * Show or hide the conversation drawer.
265
+ *
266
+ * On a narrow screen the drawer covers the panel, including the button that
267
+ * opened it, so every way out has to be somewhere else: the strip of
268
+ * conversation beside it, Escape, or picking a conversation.
269
+ */
270
+ _setNav(open) {
271
+ this._els.overlay.classList.toggle('nav', open);
272
+ this._els.menu.setAttribute('aria-expanded', String(open));
273
+ // Kept out of the accessibility tree and out of the tab order when closed:
274
+ // a full-bleed element that swallows taps is not something to leave lying
275
+ // over the conversation.
276
+ this._els.scrim.hidden = !open;
277
+ // Focus follows the drawer, returning to the control it came from.
278
+ if (!open)
279
+ this._els.menu.focus();
280
+ }
281
+ /** Programmatic open (used by pages that dock the agent full-page). */
282
+ open() {
283
+ this._open();
284
+ }
285
+ _open() {
286
+ this._els.overlay.classList.add('show');
287
+ this._els.input.focus();
288
+ void this._connectTools();
289
+ (this.provider.load?.() ?? Promise.resolve())
290
+ // A finished load has to redraw too: the chips were withheld while it ran.
291
+ .then(() => this._paintLoad())
292
+ .catch((err) => this._reportEngineError(err));
293
+ // ensureEngine flips `loading` synchronously, so the empty state can switch
294
+ // to the stage view now rather than waiting for the first status callback.
295
+ this._paintLoad();
296
+ }
297
+ // ── Conversations ──────────────────────────────────────────────────────────
298
+ _loadConversations() {
299
+ try {
300
+ const arr = JSON.parse(localStorage.getItem(CONV_KEY) || 'null');
301
+ if (Array.isArray(arr) && arr.length) {
302
+ this._conversations = arr;
303
+ this._activeId = arr[0].id;
304
+ return;
305
+ }
306
+ }
307
+ catch {
308
+ /* fall through to a fresh one */
309
+ }
310
+ this._newConversation();
311
+ }
312
+ _saveConversations() {
313
+ try {
314
+ // Live forms are transient: persist only finalized thread items.
315
+ const slim = this._conversations.map((c) => ({
316
+ id: c.id,
317
+ title: c.title,
318
+ messages: c.messages,
319
+ items: c.items.filter((it) => it.kind !== 'form'),
320
+ }));
321
+ localStorage.setItem(CONV_KEY, JSON.stringify(slim));
322
+ }
323
+ catch {
324
+ /* storage blocked/full */
325
+ }
326
+ }
327
+ _active() {
328
+ return this._conversations.find((c) => c.id === this._activeId) || null;
329
+ }
330
+ _newConversation() {
331
+ const conv = {
332
+ id: uid(),
333
+ title: 'New conversation',
334
+ messages: [{ role: 'system', content: this._systemPrompt }],
335
+ items: [],
336
+ };
337
+ this._conversations.unshift(conv);
338
+ this._activeId = conv.id;
339
+ this._tool = null;
340
+ this._formEl = null;
341
+ this._syncProfileContext();
342
+ this._setNav(false);
343
+ if (this._els.history) {
344
+ this._renderHistory();
345
+ this._renderThread();
346
+ this._saveConversations();
347
+ }
348
+ if (this._els.input)
349
+ this._els.input.focus();
350
+ }
351
+ _switchConversation(id) {
352
+ if (id === this._activeId)
353
+ return;
354
+ this._activeId = id;
355
+ this._tool = null;
356
+ this._formEl = null;
357
+ this._syncProfileContext();
358
+ this._setNav(false);
359
+ this._renderHistory();
360
+ this._renderThread();
361
+ }
362
+ _renderHistory() {
363
+ this._els.history.innerHTML = this._conversations
364
+ .map((c) => `<div class="hist-row${c.id === this._activeId ? ' active' : ''}">
365
+ <button class="hist-item" data-id="${c.id}">${escapeHtml(c.title || 'New conversation')}</button>
366
+ <button class="hist-del" data-id="${c.id}" title="Delete conversation" aria-label="Delete conversation">${ICON_TRASH}</button>
367
+ </div>`)
368
+ .join('');
369
+ this._els.history.querySelectorAll('.hist-item').forEach((b) => b.addEventListener('click', () => this._switchConversation(b.dataset.id)));
370
+ this._els.history.querySelectorAll('.hist-del').forEach((b) => b.addEventListener('click', (e) => {
371
+ e.stopPropagation();
372
+ this._deleteConversation(b.dataset.id);
373
+ }));
374
+ }
375
+ _deleteConversation(id) {
376
+ const idx = this._conversations.findIndex((c) => c.id === id);
377
+ if (idx === -1)
378
+ return;
379
+ const wasActive = id === this._activeId;
380
+ this._conversations.splice(idx, 1);
381
+ if (!this._conversations.length) {
382
+ this._newConversation(); // creates a fresh one + re-renders + saves
383
+ return;
384
+ }
385
+ if (wasActive) {
386
+ this._activeId = this._conversations[Math.min(idx, this._conversations.length - 1)].id;
387
+ this._tool = null;
388
+ this._formEl = null;
389
+ this._syncProfileContext();
390
+ this._renderThread();
391
+ }
392
+ this._renderHistory();
393
+ this._saveConversations();
394
+ }
395
+ // ── Thread rendering ───────────────────────────────────────────────────────
396
+ _renderThread() {
397
+ const t = this._els.thread;
398
+ // innerHTML = '' detaches the surface elements but leaves their processors
399
+ // subscribed; dispose explicitly before dropping the references.
400
+ for (const s of this._surfaces) {
401
+ try {
402
+ s.dispose();
403
+ }
404
+ catch { /* already disposed */ }
405
+ }
406
+ this._surfaces = [];
407
+ t.innerHTML = '';
408
+ this._emptyLoading = null;
409
+ const conv = this._active();
410
+ if (!conv || !conv.items.length) {
411
+ // The mark doubles as the loading indicator, with a ring filling around
412
+ // it while the weights download, rather than adding a second widget.
413
+ //
414
+ // The suggestions are withheld until the model exists, since tapping one
415
+ // before then would do nothing.
416
+ const loading = (this.provider?.load ? this.provider.loading : false) ?? false;
417
+ this._emptyLoading = loading;
418
+ const body = loading
419
+ ? `<p class="stage">${escapeHtml(this._stage || 'Loading model…')}</p>` +
420
+ `<p class="stage-bytes"></p>`
421
+ : `<p>${escapeHtml(this._emptyState)}</p>` +
422
+ `<div class="chips appear">` +
423
+ this._suggestions.map((c) => `<button class="chip">${escapeHtml(c)}</button>`).join('') +
424
+ `</div>`;
425
+ t.innerHTML =
426
+ `<div class="empty">` +
427
+ `<span class="empty-mark"><span class="ring" hidden></span>${this._logoMarkup()}</span>` +
428
+ body +
429
+ `</div>`;
430
+ this._paintLoad();
431
+ t.querySelectorAll('.chip').forEach((b) => b.addEventListener('click', () => {
432
+ this._els.input.value = b.textContent || '';
433
+ this._onSend();
434
+ }));
435
+ return;
436
+ }
437
+ for (let i = 0; i < conv.items.length; i++) {
438
+ const it = conv.items[i];
439
+ if (it.kind === 'msg')
440
+ this._paintMsg(it.role, it.text, i);
441
+ else if (it.kind === 'tool')
442
+ this._paintToolResult(it.name, it.text, it.isError);
443
+ else if (it.kind === 'trace') {
444
+ if (this.debug)
445
+ this._els.thread.appendChild(this._traceEl(it));
446
+ }
447
+ else if (it.kind === 'stopped') {
448
+ this._paintStopped();
449
+ }
450
+ else if (it.kind === 'route') {
451
+ if (this.debug)
452
+ this._els.thread.appendChild(this._routeEl(it));
453
+ }
454
+ else if (it.kind === 'detail')
455
+ this._els.thread.appendChild(this._detailEl(it));
456
+ else if (it.kind === 'results')
457
+ this._els.thread.appendChild(this._resultsEl(it));
458
+ else if (it.kind === 'metrics')
459
+ this._els.thread.appendChild(this._metricsEl(it));
460
+ }
461
+ this._scroll();
462
+ }
463
+ _clearEmpty() {
464
+ const e = this._els.thread.querySelector('.empty');
465
+ if (e)
466
+ e.remove();
467
+ }
468
+ /**
469
+ * A chat bubble.
470
+ *
471
+ * `.msg` styles the bubble and also wraps non-bubble content, so the form,
472
+ * detail and results wrappers each had to undo it (`background: none;
473
+ * border: none; padding: 0`. Splitting the two roles removes that undo:
474
+ * bubbles get bubble utilities, wrappers get layout utilities, and neither
475
+ * has to know about the other.
476
+ *
477
+ * The squared corner nearest the sender is the one carrying information: it
478
+ * is how a glance tells user from assistant before any colour is read.
479
+ */
480
+ _msgEl(role, text) {
481
+ const el = document.createElement('div');
482
+ const base = 'max-w-[76%] whitespace-pre-wrap break-words px-3.5 py-2.5 text-sm leading-relaxed';
483
+ const byRole = role === 'user'
484
+ ? 'self-end bg-blue text-white rounded-2xl rounded-br-md'
485
+ : 'self-start bg-surface text-ink border border-border-hair rounded-2xl rounded-bl-md shadow-resting';
486
+ el.className = `msg ${role} ${base} ${byRole}`;
487
+ el.textContent = text;
488
+ return el;
489
+ }
490
+ /**
491
+ * A tool outcome.
492
+ *
493
+ * Not a bubble, since nobody said it. Styled as a system line: full width, mono
494
+ * eyebrow for the tool name, and the result beneath in body text. Colour
495
+ * carries the outcome (design.md §2: green is a positive result, and error
496
+ * red is reserved for actual failure).
497
+ */
498
+ _toolEl(name, text, isError) {
499
+ const el = document.createElement('div');
500
+ el.className =
501
+ 'msg tool self-stretch w-full rounded-card border px-3.5 py-2.5 ' +
502
+ (isError
503
+ ? 'bg-surface border-red/25 text-red'
504
+ : 'bg-surface border-border-hair text-green-strong');
505
+ el.innerHTML =
506
+ `<b class="flex items-center gap-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em]">` +
507
+ `${isError ? ICON_WARN : ICON_CHECK}${escapeHtml(name)}</b>` +
508
+ `<div class="mt-1 font-sans text-[13px] leading-normal text-ink-2">${escapeHtml(text)}</div>`;
509
+ return el;
510
+ }
511
+ _detailEl(item) {
512
+ const rows = (item.rows || [])
513
+ .map((r) => `<div class="dc-row"><dt>${escapeHtml(r.label)}</dt><dd>${escapeHtml(r.value)}</dd></div>`)
514
+ .join('');
515
+ const el = document.createElement('div');
516
+ el.className = 'msg detail';
517
+ el.innerHTML = `
518
+ <div class="detailcard">
519
+ <div class="dc-badge">${ICON_CHECK}</div>
520
+ <div class="dc-eyebrow">Submitted</div>
521
+ <div class="dc-title">${escapeHtml(item.title)}</div>
522
+ <div class="dc-sub">${escapeHtml(item.note)}</div>
523
+ <dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
524
+ </div>`;
525
+ return el;
526
+ }
527
+ /**
528
+ * Draw one message.
529
+ *
530
+ * A message the user sent carries a row of actions, so `index` identifies
531
+ * which item it is: copy needs the text, and edit and send-again need to know
532
+ * how much of the conversation they will replace.
533
+ *
534
+ * @param role - Who sent the message.
535
+ * @param text - The message text.
536
+ * @param index - Its position in the conversation's items. Required for the
537
+ * action bar; omit it for messages that should not carry one.
538
+ * @returns The bubble element.
539
+ */
540
+ _paintMsg(role, text, index) {
541
+ this._clearEmpty();
542
+ const el = this._msgEl(role, text);
543
+ if (role !== 'user' || index === undefined) {
544
+ this._els.thread.appendChild(el);
545
+ return el;
546
+ }
547
+ const turn = document.createElement('div');
548
+ turn.className = 'turn';
549
+ // Focusable so the row can be reached without a pointer: `:focus-within`
550
+ // is what reveals it, and a bubble that cannot take focus never fires it.
551
+ turn.tabIndex = -1;
552
+ turn.dataset.item = String(index);
553
+ turn.appendChild(el);
554
+ turn.appendChild(this._actionBar(index, text));
555
+ this._els.thread.appendChild(turn);
556
+ return el;
557
+ }
558
+ /** Copy, edit, send again, under the person's own message. */
559
+ _actionBar(index, text) {
560
+ const bar = document.createElement('div');
561
+ bar.className = 'actions';
562
+ bar.innerHTML =
563
+ `<button class="act" data-act="copy" type="button" aria-label="Copy message" title="Copy">${ICON_COPY}</button>` +
564
+ `<button class="act" data-act="edit" type="button" aria-label="Edit message" title="Edit">${ICON_PENCIL}</button>` +
565
+ `<button class="act" data-act="resend" type="button" aria-label="Send again" title="Send again">${ICON_REDO}</button>`;
566
+ const copy = bar.querySelector('[data-act="copy"]');
567
+ copy.addEventListener('click', () => {
568
+ // The tick is the confirmation; a toast would be redundant.
569
+ navigator.clipboard?.writeText(text).catch(() => { });
570
+ copy.innerHTML = ICON_CHECK_MARK;
571
+ copy.classList.add('done');
572
+ copy.setAttribute('aria-label', 'Copied');
573
+ setTimeout(() => {
574
+ copy.innerHTML = ICON_COPY;
575
+ copy.classList.remove('done');
576
+ copy.setAttribute('aria-label', 'Copy message');
577
+ }, 1400);
578
+ });
579
+ bar
580
+ .querySelector('[data-act="resend"]')
581
+ .addEventListener('click', () => void this._rerunFrom(index, text));
582
+ bar
583
+ .querySelector('[data-act="edit"]')
584
+ .addEventListener('click', () => this._editMessage(index, text));
585
+ return bar;
586
+ }
587
+ /**
588
+ * Turn the bubble into a field where it already sits.
589
+ *
590
+ * Not the composer. Editing in the composer means looking at one copy of the
591
+ * sentence while typing into another, and the one being changed is the one
592
+ * further from the eye.
593
+ */
594
+ _editMessage(index, text) {
595
+ const turn = this._els.thread.querySelector(`.turn[data-item="${index}"]`);
596
+ if (!turn)
597
+ return;
598
+ const box = document.createElement('div');
599
+ box.className = 'editing';
600
+ box.innerHTML =
601
+ `<textarea rows="2"></textarea>` +
602
+ `<div class="edit-actions">` +
603
+ `<button class="ebtn" data-e="cancel" type="button">Cancel</button>` +
604
+ `<button class="ebtn primary" data-e="save" type="button">Send</button>` +
605
+ `</div>`;
606
+ const hint = document.createElement('div');
607
+ hint.className = 'edit-hint';
608
+ // Said before the commit, not after. Editing the first of five turns throws
609
+ // away the four below it, because they answered a question that no longer
610
+ // exists, and that is not something to discover by watching it happen.
611
+ hint.textContent = 'everything below is replaced when this is sent';
612
+ const ta = box.querySelector('textarea');
613
+ ta.value = text;
614
+ turn.replaceWith(box);
615
+ box.after(hint);
616
+ ta.focus();
617
+ ta.setSelectionRange(ta.value.length, ta.value.length);
618
+ const close = () => {
619
+ hint.remove();
620
+ box.remove();
621
+ this._renderThread();
622
+ };
623
+ const send = () => {
624
+ const next = ta.value.trim();
625
+ if (!next)
626
+ return;
627
+ void this._rerunFrom(index, next);
628
+ };
629
+ box.querySelector('[data-e="cancel"]').addEventListener('click', close);
630
+ box.querySelector('[data-e="save"]').addEventListener('click', send);
631
+ ta.addEventListener('keydown', (e) => {
632
+ if (e.key === 'Escape') {
633
+ e.stopPropagation();
634
+ close();
635
+ }
636
+ if (e.key === 'Enter' && !e.shiftKey) {
637
+ e.preventDefault();
638
+ send();
639
+ }
640
+ });
641
+ }
642
+ /**
643
+ * Send a message again from this point, replacing everything below it.
644
+ *
645
+ * Discards the following items and messages from both the thread and the
646
+ * transcript, since they answered a question that no longer exists.
647
+ *
648
+ * @param index - The item index of the message to resend.
649
+ * @param text - The message to send, edited or unchanged.
650
+ */
651
+ async _rerunFrom(index, text) {
652
+ if (!this._active())
653
+ return;
654
+ // The turn in flight is answering the question being replaced.
655
+ if (this._turnActive) {
656
+ this._stopTurn();
657
+ await this._turnDone;
658
+ }
659
+ this._truncateFrom(index);
660
+ this._renderThread();
661
+ this._els.input.value = text;
662
+ this._autosize();
663
+ await this._onSend();
664
+ }
665
+ /**
666
+ * Drop every item and every message from `index` down, the message itself
667
+ * included: it is about to be sent again.
668
+ */
669
+ _truncateFrom(index) {
670
+ const conv = this._active();
671
+ if (!conv)
672
+ return;
673
+ let turns = 0;
674
+ for (let i = 0; i < index; i++) {
675
+ const it = conv.items[i];
676
+ if (it.kind === 'msg' && it.role === 'user')
677
+ turns++;
678
+ }
679
+ // Tool results are written into the transcript as user turns too, so the
680
+ // person's own messages are the unbracketed ones.
681
+ let seen = 0;
682
+ let cut = conv.messages.length;
683
+ for (let i = 0; i < conv.messages.length; i++) {
684
+ const m = conv.messages[i];
685
+ if (m.role !== 'user' || m.content.startsWith('['))
686
+ continue;
687
+ if (seen++ === turns) {
688
+ cut = i;
689
+ break;
690
+ }
691
+ }
692
+ conv.messages.length = cut;
693
+ conv.items.length = index;
694
+ this._saveConversations();
695
+ }
696
+ /**
697
+ * The receipt for a tool call: one muted line, collapsed.
698
+ *
699
+ * `<details>` rather than a button and a class, because the summary is a
700
+ * real disclosure control, so keyboard, screen reader and find-in-page all
701
+ * work without anything being wired up. Closed by default: this is the row
702
+ * a person scrolls past, and opens only when the answer above it looks
703
+ * wrong.
704
+ */
705
+ _traceEl(item) {
706
+ const el = document.createElement('details');
707
+ el.className = `trace${item.isError ? ' err' : ''}`;
708
+ const summary = document.createElement('summary');
709
+ const verb = item.isError ? 'failed' : 'ran';
710
+ const outcome = item.isError
711
+ ? 'error'
712
+ : typeof item.count === 'number'
713
+ ? `${item.count} result${item.count === 1 ? '' : 's'}`
714
+ : 'ok';
715
+ summary.innerHTML =
716
+ `<span class="caret" aria-hidden="true"></span>` +
717
+ `<span>${escapeHtml(humanize(item.name))} ${verb} · ${escapeHtml(outcome)}</span>`;
718
+ el.appendChild(summary);
719
+ const body = document.createElement('div');
720
+ body.className = 'trace-body';
721
+ const args = JSON.stringify(item.args ?? {}, null, 2);
722
+ body.innerHTML =
723
+ `<span class="trace-label">Call</span>` +
724
+ `<pre>${escapeHtml(`${item.name}(${args === '{}' ? '' : args})`)}</pre>` +
725
+ `<span class="trace-label">Response</span>` +
726
+ `<pre class="raw">${escapeHtml(item.text || '(empty)')}</pre>`;
727
+ el.appendChild(body);
728
+ return el;
729
+ }
730
+ _paintToolResult(name, text, isError) {
731
+ this._clearEmpty();
732
+ const el = this._toolEl(name, text, isError);
733
+ this._els.thread.appendChild(el);
734
+ return el;
735
+ }
736
+ /** Append a bubble both to the persisted thread and the DOM. */
737
+ /**
738
+ * Call off the turn in flight.
739
+ *
740
+ * The note left in the thread is presented neutrally: a stop is neither an
741
+ * error nor a refusal, so it is not styled as a warning.
742
+ */
743
+ _stopTurn() {
744
+ if (!this._stoppable)
745
+ return;
746
+ this._setStoppable(false);
747
+ (this._coordinator ?? this._harness).stop();
748
+ const conv = this._active();
749
+ if (conv) {
750
+ conv.items.push({ kind: 'stopped' });
751
+ this._saveConversations();
752
+ }
753
+ this._paintStopped();
754
+ this._setStatus('');
755
+ this._scroll();
756
+ this._els.input.focus();
757
+ }
758
+ _paintStopped() {
759
+ const el = document.createElement('div');
760
+ el.className = 'stopped-note';
761
+ el.textContent = 'Stopped';
762
+ this._els.thread.appendChild(el);
763
+ return el;
764
+ }
765
+ /** Swap the composer button between sending and stopping. */
766
+ _setStoppable(canStop) {
767
+ if (this._stoppable === canStop)
768
+ return;
769
+ this._stoppable = canStop;
770
+ const go = this._els.go;
771
+ go.classList.toggle('stop', canStop);
772
+ go.innerHTML = canStop ? ICON_STOP : ICON_ARROW;
773
+ go.setAttribute('aria-label', canStop ? 'Stop' : 'Send');
774
+ // Enabled either way: disabling it during a turn would disable the very
775
+ // control needed to stop that turn.
776
+ go.disabled = false;
777
+ }
778
+ _appendMsg(role, text) {
779
+ const conv = this._active();
780
+ conv.items.push({ kind: 'msg', role, text });
781
+ const el = this._paintMsg(role, text, conv.items.length - 1);
782
+ this._saveConversations();
783
+ this._scroll();
784
+ return el;
785
+ }
786
+ /**
787
+ * The assistant's bubble for one phase of a turn.
788
+ *
789
+ * Starts as an animated thinking indicator rather than a static ellipsis: a
790
+ * turn can be silent for tens of seconds while weights download, and a
791
+ * motionless dot is indistinguishable from a hung page. The first token
792
+ * replaces the indicator with text, and only a non-empty `finalize` persists
793
+ * the bubble into the stored conversation.
794
+ */
795
+ _appendStreaming() {
796
+ const el = this._paintMsg('assistant', '');
797
+ el.classList.add('thinking');
798
+ el.innerHTML = THINKING_MARKUP;
799
+ // The bubble is a live region so a screen reader hears the phase change
800
+ // without the focus moving to it.
801
+ el.setAttribute('aria-live', 'polite');
802
+ this._scroll();
803
+ const setLabel = (label) => {
804
+ const node = el.querySelector('.think-label');
805
+ if (node)
806
+ node.textContent = label ?? '';
807
+ };
808
+ /**
809
+ * Show byte counts for the current phase, or nothing.
810
+ *
811
+ * Only the weight download reports totals. The runtime fetch and the warm-up
812
+ * do not, and those phases show the dots alone rather than an empty readout.
813
+ */
814
+ const setProgress = (_pct, detail) => {
815
+ const bytes = el.querySelector('.think-bytes');
816
+ if (!bytes)
817
+ return;
818
+ // Figures rather than a bar: the ring on the header mark already carries
819
+ // the proportion, and what it cannot show is how much is left in bytes.
820
+ bytes.textContent = detail?.total
821
+ ? `${formatBytes(detail.loaded)} / ${formatBytes(detail.total)}`
822
+ : '';
823
+ };
824
+ const bubble = {
825
+ // Naming a new phase reuses the same bubble, so a turn that loads, then
826
+ // discovers, then generates remains one element in the thread rather than
827
+ // three that appear and vanish.
828
+ thinking: (label) => {
829
+ if (!el.classList.contains('thinking')) {
830
+ el.classList.add('thinking');
831
+ el.innerHTML = THINKING_MARKUP;
832
+ }
833
+ setLabel(label);
834
+ this._scroll();
835
+ },
836
+ // `t` is the whole answer so far, not the newest fragment; assigning it
837
+ // is correct; appending would repeat the answer on every token.
838
+ update: (t) => {
839
+ if (!t)
840
+ return;
841
+ // First token: drop the indicator and become an ordinary bubble. The
842
+ // live region goes with it, since announcing a streaming answer word by
843
+ // word would talk over the reader.
844
+ if (el.classList.contains('thinking')) {
845
+ el.classList.remove('thinking');
846
+ el.removeAttribute('aria-live');
847
+ el.innerHTML = '';
848
+ if (this._liveBubble === bubble)
849
+ this._liveBubble = null;
850
+ }
851
+ el.textContent = t;
852
+ this._scroll();
853
+ },
854
+ // Empty text means the phase had nothing to leave behind (a finished
855
+ // tool call, a failed load), so the bubble goes rather than lingering as
856
+ // a blank one. Anything else is persisted; a pending indicator never is.
857
+ finalize: (t) => {
858
+ el.classList.remove('thinking');
859
+ el.removeAttribute('aria-live');
860
+ if (this._liveBubble === bubble)
861
+ this._liveBubble = null;
862
+ if (!t) {
863
+ el.remove();
864
+ return;
865
+ }
866
+ el.textContent = t;
867
+ this._active().items.push({ kind: 'msg', role: 'assistant', text: t });
868
+ this._saveConversations();
869
+ this._scroll();
870
+ },
871
+ setProgress,
872
+ };
873
+ // The engine reports progress to the element, not through the turn loop,
874
+ // so the element has to know which bubble is currently on screen.
875
+ this._liveBubble = bubble;
876
+ return bubble;
877
+ }
878
+ // ── Turn loop ──────────────────────────────────────────────────────────────
879
+ async _onSend() {
880
+ const goal = this._els.input.value.trim();
881
+ if (!goal || this._turnActive)
882
+ return;
883
+ this._els.input.value = '';
884
+ this._autosize();
885
+ this._appendMsg('user', goal);
886
+ const conv = this._active();
887
+ if (conv && (!conv.title || conv.title === 'New conversation')) {
888
+ conv.title = goal.length > 40 ? goal.slice(0, 40) + '…' : goal;
889
+ this._renderHistory();
890
+ this._saveConversations();
891
+ }
892
+ this._turnActive = true;
893
+ this._setStoppable(true);
894
+ let settle;
895
+ this._turnDone = new Promise((r) => (settle = r));
896
+ try {
897
+ // The toolset has to be complete before the model is asked to choose from
898
+ // it. Discovery normally finished while the weights were downloading; this
899
+ // only waits when a message is sent the instant the panel opens.
900
+ await this._connectTools();
901
+ this._syncProfileContext();
902
+ // One agent, or several with a router in front: the signatures match.
903
+ await (this._coordinator ?? this._harness).turn(goal, conv.messages);
904
+ }
905
+ catch (err) {
906
+ // A cancelled turn has already reported itself in the thread, and is not
907
+ // a failure to report a second time.
908
+ if (!isAbort(err))
909
+ this._reportEngineError(err);
910
+ }
911
+ finally {
912
+ this._turnActive = false;
913
+ this._setStoppable(false);
914
+ this._turnDone = null;
915
+ settle();
916
+ }
917
+ }
918
+ // ── HarnessRenderer ────────────────────────────────────────────────────────
919
+ /**
920
+ * The element's implementation of {@link HarnessRenderer}.
921
+ *
922
+ * Drawing and reporting back only: whether to ask, what to ask for, and when
923
+ * to run a tool are all decided by the harness.
924
+ */
925
+ _renderer() {
926
+ return {
927
+ message: (role, text) => this._appendMsg(role, text),
928
+ streaming: () => this._appendStreaming(),
929
+ status: (text, isError) => this._setStatus(text, isError),
930
+ ask: (req) => this._renderAsk(req),
931
+ confirm: (req) => this._renderConfirm(req),
932
+ results: (tool, res) => {
933
+ const conv = this._active();
934
+ const item = this._buildResultsItem(tool, res);
935
+ conv?.items.push(item);
936
+ this._els.thread.appendChild(this._resultsEl(item));
937
+ this._upsertMetrics();
938
+ this._saveConversations();
939
+ this._scroll();
940
+ },
941
+ detail: (tool, values, text) => {
942
+ const conv = this._active();
943
+ const item = this._buildDetailItem(tool, values, text);
944
+ conv?.items.push(item);
945
+ this._els.thread.appendChild(this._detailEl(item));
946
+ this._saveConversations();
947
+ this._scroll();
948
+ },
949
+ trace: ({ tool, args, text, isError, count }) => {
950
+ // Not recorded at all when debugging is off. See the `debug` getter.
951
+ if (!this.debug)
952
+ return;
953
+ const item = {
954
+ kind: 'trace',
955
+ name: tool.name,
956
+ args,
957
+ text,
958
+ isError,
959
+ count,
960
+ };
961
+ this._active()?.items.push(item);
962
+ this._els.thread.appendChild(this._traceEl(item));
963
+ this._saveConversations();
964
+ this._scroll();
965
+ },
966
+ toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
967
+ dismissPrompt: () => this._dismissPrompt(),
968
+ // The harness says when stopping stops being possible, because it is the
969
+ // part that knows a tool has been invoked. Once a call is out, offering
970
+ // to stop would be offering to un-send a booking.
971
+ stoppable: (canStop) => this._setStoppable(canStop),
972
+ };
973
+ }
974
+ _dismissPrompt() {
975
+ if (this._prompt) {
976
+ try {
977
+ this._prompt.dispose();
978
+ }
979
+ catch { /* already gone */ }
980
+ this._prompt = null;
981
+ }
982
+ if (this._formEl && this._formEl.isConnected)
983
+ this._formEl.remove();
984
+ this._formEl = null;
985
+ }
986
+ /** Mount an A2UI prompt surface into the thread. */
987
+ _mountPrompt(messages, onAction) {
988
+ this._clearEmpty();
989
+ this._dismissPrompt();
990
+ const wrap = document.createElement('div');
991
+ wrap.className = 'form a2ui a2ui-scope self-start w-full max-w-[420px]';
992
+ const mounted = mountSurface(wrap, messages, (event) => {
993
+ const name = event?.event?.name ?? event?.name ?? '';
994
+ const ctx = (event?.event?.context ?? event?.context ?? {});
995
+ onAction(String(name), ctx);
996
+ });
997
+ if (!mounted)
998
+ return null;
999
+ this._els.thread.appendChild(wrap);
1000
+ this._formEl = wrap;
1001
+ this._prompt = mounted;
1002
+ this._surfaces.push(mounted);
1003
+ this._scroll();
1004
+ return mounted;
1005
+ }
1006
+ /** Gaps to fill — one question, or a wizard when the plan has several. */
1007
+ _renderAsk(req) {
1008
+ const { tool, plan } = req;
1009
+ if (!plan.steps.length) {
1010
+ req.onComplete({});
1011
+ return;
1012
+ }
1013
+ let step = 0;
1014
+ const mounted = this._mountPrompt(emitAsk(tool.name, plan, req.resolution.values, 'ask'), (name, ctx) => {
1015
+ if (name === 'nxis.cancel') {
1016
+ req.onCancel();
1017
+ return;
1018
+ }
1019
+ if (name === 'nxis.back') {
1020
+ step = Math.max(0, step - 1);
1021
+ this._prompt?.send(emitStep('ask', tool.name, plan, step));
1022
+ return;
1023
+ }
1024
+ if (name === 'nxis.next') {
1025
+ // Do not advance past an unanswered question: the step exists
1026
+ // precisely because this value is unknown.
1027
+ const form = (this._prompt?.read('form') ?? {});
1028
+ const unanswered = plan.steps[step].slots.filter((sl) => { const v = form[sl.name]; return v === undefined || v === null || String(v).trim() === ''; });
1029
+ if (unanswered.length) {
1030
+ this._setStatus(`Please answer: ${unanswered.map((sl) => humanize(sl.name)).join(', ')}`, true);
1031
+ return;
1032
+ }
1033
+ this._setStatus('');
1034
+ step = Math.min(plan.steps.length - 1, step + 1);
1035
+ this._prompt?.send(emitStep('ask', tool.name, plan, step));
1036
+ return;
1037
+ }
1038
+ if (name === 'nxis.submit') {
1039
+ const form = (this._prompt?.read('form') ?? {});
1040
+ req.onComplete(form);
1041
+ }
1042
+ void ctx;
1043
+ });
1044
+ // A renderer failure must not strand the turn; fall back to the DOM form.
1045
+ if (!mounted)
1046
+ this._renderFormLegacy(tool, req.resolution.values);
1047
+ }
1048
+ /**
1049
+ * Show what a write will send, and wait for the user to submit it.
1050
+ *
1051
+ * Drawn as DOM rather than an A2UI surface so the confirmation matches the
1052
+ * detail card it becomes once submitted. A2UI's basic catalog cannot express
1053
+ * that styling: each component nests its own shadow root, and the `--a2ui-*`
1054
+ * properties that reach inside are per component type rather than per
1055
+ * instance. `emitConfirm` remains available for renderers that prefer the
1056
+ * surface.
1057
+ */
1058
+ _renderConfirm(req) {
1059
+ this._clearEmpty();
1060
+ this._dismissPrompt();
1061
+ const rows = req.slots
1062
+ .map((sl) => `<div class="dc-row"><dt>${escapeHtml(sl.schema.description || humanize(sl.name))}</dt><dd>${escapeHtml(String(sl.value ?? ''))}</dd></div>`)
1063
+ .join('');
1064
+ const wrap = document.createElement('div');
1065
+ wrap.className = 'msg detail';
1066
+ wrap.innerHTML = `
1067
+ <div class="detailcard">
1068
+ <div class="dc-badge review">${ICON_ARROW}</div>
1069
+ <div class="dc-eyebrow review">Ready to send</div>
1070
+ <div class="dc-title">${escapeHtml(humanize(req.tool.name))}</div>
1071
+ <div class="dc-sub">Review these details, then submit.</div>
1072
+ <dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
1073
+ <div class="dc-actions">
1074
+ <button type="button" class="btn ghost" data-act="cancel">Cancel</button>
1075
+ <button type="button" class="btn ghost" data-act="edit">Edit</button>
1076
+ <button type="button" class="btn primary" data-act="submit">Submit ${ICON_ARROW}</button>
1077
+ </div>
1078
+ </div>`;
1079
+ wrap.querySelector('[data-act="cancel"]').addEventListener('click', () => req.onCancel());
1080
+ wrap.querySelector('[data-act="edit"]').addEventListener('click', () => req.onEdit());
1081
+ wrap.querySelector('[data-act="submit"]').addEventListener('click', () => {
1082
+ const btn = wrap.querySelector('[data-act="submit"]');
1083
+ btn.disabled = true;
1084
+ req.onSubmit(req.values);
1085
+ });
1086
+ this._els.thread.appendChild(wrap);
1087
+ this._formEl = wrap;
1088
+ this._scroll();
1089
+ }
1090
+ // ── Inline generated form (from JSON-Schema) ───────────────────────────────
1091
+ _renderForm(tool, args) {
1092
+ if (this._useA2ui) {
1093
+ try {
1094
+ this._renderFormA2ui(tool, args);
1095
+ return;
1096
+ }
1097
+ catch (err) {
1098
+ // Never strand the user without a form; fall through to the DOM one.
1099
+ console.warn('[a2ui] form surface failed, using inline form', err);
1100
+ }
1101
+ }
1102
+ this._renderFormLegacy(tool, args);
1103
+ }
1104
+ /** The tool form as an A2UI surface rather than hand-built DOM. */
1105
+ _renderFormA2ui(tool, args) {
1106
+ this._clearEmpty();
1107
+ this._tool = tool;
1108
+ const wrap = document.createElement('div');
1109
+ wrap.className = 'form a2ui a2ui-scope self-start w-full max-w-[420px]';
1110
+ const surfaceHost = document.createElement('div');
1111
+ // The status line stays outside the surface: reporting progress and errors
1112
+ // is the host's job, and the basic catalog has no component for it.
1113
+ const result = document.createElement('div');
1114
+ result.className = 'genform-result';
1115
+ wrap.append(surfaceHost, result);
1116
+ const messages = emitToolForm(tool.name, (tool.description || '').trim(), toolSchema(tool), args);
1117
+ const mounted = mountSurface(surfaceHost, messages, (event) => {
1118
+ const name = event?.event?.name ?? event?.name;
1119
+ if (name === 'nxis.cancel') {
1120
+ this._cancelForm();
1121
+ return;
1122
+ }
1123
+ if (name !== 'nxis.submit')
1124
+ return;
1125
+ const values = (mounted?.read('form') ?? {});
1126
+ const missing = missingRequired(tool, values);
1127
+ if (missing.length) {
1128
+ this._showFormError(result, `Please fill in: ${missing.map((m) => humanize(m)).join(', ')}.`);
1129
+ this._setStatus('');
1130
+ return;
1131
+ }
1132
+ void this._submitValues(tool, values, result, () => { });
1133
+ });
1134
+ if (!mounted)
1135
+ throw new Error('no form surface produced');
1136
+ this._surfaces.push(mounted);
1137
+ if (this._formEl && this._formEl.isConnected)
1138
+ this._formEl.remove();
1139
+ this._els.thread.appendChild(wrap);
1140
+ this._formEl = wrap;
1141
+ this._scroll();
1142
+ }
1143
+ _renderFormLegacy(tool, args) {
1144
+ this._clearEmpty();
1145
+ this._tool = tool;
1146
+ const schema = toolSchema(tool);
1147
+ const props = schema.properties || {};
1148
+ const required = new Set(schema.required || []);
1149
+ const fields = Object.entries(props)
1150
+ .map(([key, spec]) => {
1151
+ const id = `f_${key}`;
1152
+ const label = `${humanize(key)}${required.has(key) ? ' <span class="req">*</span>' : ''}`;
1153
+ return `<div class="field"><label for="${id}">${label}</label>${this._control(id, key, spec || {}, args[key])}</div>`;
1154
+ })
1155
+ .join('');
1156
+ const wrap = document.createElement('div');
1157
+ wrap.className = 'msg form';
1158
+ wrap.innerHTML = `
1159
+ <form class="genform" novalidate>
1160
+ <div class="genform-head">
1161
+ <div class="gf-tool">${escapeHtml(humanize(tool.name))}</div>
1162
+ <div class="gf-desc">${escapeHtml((tool.description || '').trim())}</div>
1163
+ </div>
1164
+ ${fields || '<p class="gf-empty">This tool takes no inputs.</p>'}
1165
+ <div class="genform-actions">
1166
+ <button type="button" class="btn ghost" data-act="cancel">Cancel</button>
1167
+ <button type="submit" class="btn primary">Submit ${ICON_ARROW}</button>
1168
+ </div>
1169
+ <div class="genform-result"></div>
1170
+ </form>`;
1171
+ if (this._formEl && this._formEl.isConnected)
1172
+ this._formEl.remove();
1173
+ this._els.thread.appendChild(wrap);
1174
+ this._formEl = wrap;
1175
+ const form = wrap.querySelector('.genform');
1176
+ form.querySelector('[data-act="cancel"]').addEventListener('click', () => this._cancelForm());
1177
+ form.addEventListener('submit', (e) => {
1178
+ e.preventDefault();
1179
+ this._submitForm(tool, form);
1180
+ });
1181
+ const clearInvalid = (e) => e.target.classList && e.target.classList.remove('invalid');
1182
+ form.addEventListener('input', clearInvalid);
1183
+ form.addEventListener('change', clearInvalid);
1184
+ this._scroll();
1185
+ }
1186
+ _cancelForm() {
1187
+ if (this._formEl)
1188
+ this._formEl.remove();
1189
+ this._formEl = null;
1190
+ this._tool = null;
1191
+ this._appendMsg('assistant', 'Okay — cancelled. Tell me what you’d like to do.');
1192
+ }
1193
+ /** Build one input control from a property schema. */
1194
+ _control(id, name, spec, value) {
1195
+ const enumVals = spec.enum || (spec.items && spec.items.enum);
1196
+ if (enumVals) {
1197
+ const opts = enumVals
1198
+ .map((v) => `<option value="${escapeHtml(String(v))}"${String(v) === String(value) ? ' selected' : ''}>${escapeHtml(humanize(String(v)))}</option>`)
1199
+ .join('');
1200
+ return `<select id="${id}" data-name="${escapeHtml(name)}"><option value="">Select…</option>${opts}</select>`;
1201
+ }
1202
+ if (spec.type === 'boolean') {
1203
+ return `<label class="check"><input id="${id}" data-name="${escapeHtml(name)}" type="checkbox"${value ? ' checked' : ''}> Yes</label>`;
1204
+ }
1205
+ let type = 'text';
1206
+ if (spec.type === 'integer' || spec.type === 'number')
1207
+ type = 'number';
1208
+ else if (spec.format === 'date' || /\bdate\b/.test(name))
1209
+ type = 'date';
1210
+ else if (/email/.test(name))
1211
+ type = 'email';
1212
+ let val = value == null ? '' : String(value);
1213
+ if (type === 'date' && val) {
1214
+ const parts = val.split(/[\/\-]/);
1215
+ if (parts.length === 3 && parts[0].length <= 2) {
1216
+ let y = parts[2];
1217
+ if (y.length === 2)
1218
+ y = (parseInt(y) > 50 ? '19' : '20') + y;
1219
+ val = `${y}-${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`;
1220
+ }
1221
+ }
1222
+ val = escapeHtml(val);
1223
+ return `<input id="${id}" data-name="${escapeHtml(name)}" type="${type}" value="${val}">`;
1224
+ }
1225
+ _collect(form) {
1226
+ const out = {};
1227
+ form.querySelectorAll('[data-name]').forEach((node) => {
1228
+ const el = node;
1229
+ const name = el.dataset.name;
1230
+ if (el.type === 'checkbox') {
1231
+ out[name] = el.checked;
1232
+ return;
1233
+ }
1234
+ const v = el.value;
1235
+ if (v !== '' && v != null)
1236
+ out[name] = el.type === 'number' ? Number(v) : v;
1237
+ });
1238
+ return out;
1239
+ }
1240
+ async _submitForm(tool, form) {
1241
+ const result = form.querySelector('.genform-result');
1242
+ const submitBtn = form.querySelector('button[type="submit"]');
1243
+ const values = this._collect(form);
1244
+ // Highlighting the offending controls is specific to the DOM form; the
1245
+ // A2UI surface has no equivalent, so it stays here rather than in the
1246
+ // shared path below.
1247
+ form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
1248
+ const missing = missingRequired(tool, values);
1249
+ if (missing.length) {
1250
+ missing.forEach((m) => {
1251
+ let ctrl = null;
1252
+ try {
1253
+ ctrl = form.querySelector(`[data-name="${CSS.escape(m)}"]`);
1254
+ }
1255
+ catch {
1256
+ /* ignore */
1257
+ }
1258
+ if (ctrl)
1259
+ ctrl.classList.add('invalid');
1260
+ });
1261
+ this._showFormError(result, `Please fill in: ${missing.map((m) => humanize(m)).join(', ')}.`);
1262
+ this._setStatus('');
1263
+ return;
1264
+ }
1265
+ await this._submitValues(tool, values, result, (b) => (submitBtn.disabled = b));
1266
+ }
1267
+ _showFormError(result, msg) {
1268
+ result.className = 'genform-result error';
1269
+ result.innerHTML = `${ICON_WARN}<span>${escapeHtml(msg)}</span>`;
1270
+ }
1271
+ /**
1272
+ * Everything after "we have valid values": run the tool, then swap the form
1273
+ * for a detail card or result cards.
1274
+ *
1275
+ * Shared by the DOM form and the A2UI surface, which differ only in how
1276
+ * values are collected and how invalid fields are flagged, so only that part
1277
+ * stays renderer-specific.
1278
+ */
1279
+ async _submitValues(tool, values, result, setBusy) {
1280
+ const submitBtn = { set disabled(b) { setBusy(b); } };
1281
+ submitBtn.disabled = true;
1282
+ result.className = 'genform-result';
1283
+ result.textContent = 'Submitting…';
1284
+ try {
1285
+ this._setStatus(`Running ${tool.name}…`);
1286
+ const res = await this.toolSource.execTool(tool, tool.name, values);
1287
+ const isError = !!(res && res.isError);
1288
+ const text = extractResult(res);
1289
+ this._setStatus('');
1290
+ if (isError) {
1291
+ result.className = 'genform-result error';
1292
+ result.innerHTML = `${ICON_WARN}<span>${escapeHtml(text || 'Failed')}</span>`;
1293
+ submitBtn.disabled = false;
1294
+ return;
1295
+ }
1296
+ // Success → the form transitions inline. A write tool (form/action)
1297
+ // becomes a "Submitted" detail card; a read-only tool (typed retrieval)
1298
+ // becomes result cards for the records it returned.
1299
+ const conv = this._active();
1300
+ const readOnly = !!(tool.annotations && tool.annotations.readOnlyHint);
1301
+ let item;
1302
+ let card;
1303
+ if (readOnly) {
1304
+ const results = this._buildResultsItem(tool, res);
1305
+ const summary = results.cards
1306
+ .map((c) => c.title)
1307
+ .filter(Boolean)
1308
+ .slice(0, 8)
1309
+ .join(', ');
1310
+ conv.messages.push({
1311
+ role: 'user',
1312
+ content: `[${tool.name} returned ${results.note}${summary ? ': ' + summary : ''}]`,
1313
+ });
1314
+ // Attribute the typed-retrieval cost to the round that proposed the call.
1315
+ const round = conv.metrics && conv.metrics[conv.metrics.length - 1];
1316
+ if (round) {
1317
+ round.toolCalls += 1;
1318
+ round.results += results.cards.length;
1319
+ round.toolResultTokens += await this.countTokens(text);
1320
+ }
1321
+ item = results;
1322
+ card = this._resultsEl(results);
1323
+ }
1324
+ else {
1325
+ conv.messages.push({
1326
+ role: 'user',
1327
+ content: `[${tool.name} submitted with: ${JSON.stringify(values)}]. Keep these unless I ask to change them.`,
1328
+ });
1329
+ const detail = this._buildDetailItem(tool, values, text);
1330
+ item = detail;
1331
+ card = this._detailEl(detail);
1332
+ }
1333
+ conv.items.push(item);
1334
+ if (this._formEl && this._formEl.isConnected)
1335
+ this._formEl.replaceWith(card);
1336
+ else
1337
+ this._els.thread.appendChild(card);
1338
+ this._formEl = null;
1339
+ this._tool = null;
1340
+ if (readOnly)
1341
+ this._upsertMetrics();
1342
+ this._saveConversations();
1343
+ this._scroll();
1344
+ }
1345
+ catch (e) {
1346
+ result.className = 'genform-result error';
1347
+ result.innerHTML = `${ICON_WARN}<span>${escapeHtml(String(e.message || e))}</span>`;
1348
+ submitBtn.disabled = false;
1349
+ }
1350
+ }
1351
+ _buildDetailItem(tool, values, resultText) {
1352
+ const schema = toolSchema(tool);
1353
+ const props = schema.properties || {};
1354
+ const order = [...Object.keys(props), ...Object.keys(values).filter((k) => !(k in props))];
1355
+ const rows = order
1356
+ .filter((k) => values[k] !== undefined && values[k] !== null && values[k] !== '')
1357
+ .map((k) => {
1358
+ const spec = props[k] || {};
1359
+ let v = values[k];
1360
+ if (spec.type === 'boolean' || typeof v === 'boolean')
1361
+ v = v ? 'Yes' : 'No';
1362
+ else if (spec.enum || (spec.items && spec.items.enum))
1363
+ v = humanize(String(v));
1364
+ return { label: humanize(k), value: String(v) };
1365
+ });
1366
+ const note = resultText && !/^\s*[[{]/.test(resultText) ? resultText : 'Your details were submitted successfully.';
1367
+ return { kind: 'detail', title: humanize(tool.name), note, rows };
1368
+ }
1369
+ // ── Read-tool results → cards ──────────────────────────────────────────────
1370
+ /** Pull the array of records out of a tool response. The WebMCP polyfill's
1371
+ * result normalizer can drop `structuredContent`, so every source is tried:
1372
+ * structuredContent (object or JSON string) and each content text part, and
1373
+ * return the first that yields records. Shapes covered: query → results[],
1374
+ * list_types → types[], get → result{}, or a bare entity/array. */
1375
+ _extractItems(res) {
1376
+ const sources = [];
1377
+ // Collect every candidate payload, recursively unwrapping tool-response
1378
+ // envelopes. `res` can be an object, a JSON string, or (with a WebMCP browser
1379
+ // extension in the loop) the whole `{content, structuredContent}` envelope
1380
+ // serialized to a string, possibly more than once. Diving into both
1381
+ // `structuredContent` and each `content[].text` covers all of them.
1382
+ const visit = (raw, depth = 0) => {
1383
+ if (raw == null || depth > 5)
1384
+ return;
1385
+ const val = raw;
1386
+ if (typeof val === 'string') {
1387
+ const t = val.trim();
1388
+ if (!t)
1389
+ return;
1390
+ let parsed;
1391
+ try {
1392
+ parsed = JSON.parse(t);
1393
+ }
1394
+ catch {
1395
+ return; // plain (non-JSON) text — nothing to extract
1396
+ }
1397
+ // Parsed value may itself be an object/array OR another JSON string
1398
+ // (double-encoded); recurse either way.
1399
+ return visit(parsed, depth + 1);
1400
+ }
1401
+ if (!val || typeof val !== 'object')
1402
+ return;
1403
+ sources.push(val);
1404
+ const v = val;
1405
+ if (v.structuredContent != null)
1406
+ visit(v.structuredContent, depth + 1);
1407
+ if (Array.isArray(v.content)) {
1408
+ for (const c of v.content) {
1409
+ if (c && typeof c === 'object')
1410
+ visit(c.text, depth + 1);
1411
+ }
1412
+ }
1413
+ };
1414
+ visit(res);
1415
+ for (const data of sources) {
1416
+ const items = this._itemsFrom(data);
1417
+ if (items.length)
1418
+ return items;
1419
+ }
1420
+ console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
1421
+ return [];
1422
+ }
1423
+ /** Find the record array within one parsed payload. */
1424
+ _itemsFrom(data) {
1425
+ const objs = (arr) => arr.filter((x) => x && typeof x === 'object');
1426
+ if (Array.isArray(data))
1427
+ return objs(data);
1428
+ if (!data || typeof data !== 'object')
1429
+ return [];
1430
+ const d = data;
1431
+ for (const key of ['results', 'types', 'items', 'entities', 'data']) {
1432
+ if (Array.isArray(d[key]))
1433
+ return objs(d[key]);
1434
+ }
1435
+ if (d.result && typeof d.result === 'object')
1436
+ return [d.result];
1437
+ if (d['@id'] != null || d['@type'] != null || d.name != null)
1438
+ return [d];
1439
+ return [];
1440
+ }
1441
+ /** Build a display card from a single record (a JSON-LD entity or a type row). */
1442
+ _entityCard(entity) {
1443
+ const str = (v) => (v == null ? '' : String(Array.isArray(v) ? v[0] : v));
1444
+ const rawType = entity['@type'];
1445
+ const title = str(entity.name ?? entity.title ?? entity.headline ?? rawType ?? entity['@id'] ?? 'Result');
1446
+ const type = rawType ? humanize(str(rawType)) : '';
1447
+ // Positive outcome (rating) → green tag, per the color rules.
1448
+ let rating;
1449
+ const ar = entity.aggregateRating;
1450
+ if (ar && typeof ar === 'object' && ar.ratingValue != null) {
1451
+ const stars = '★'.repeat(Math.max(1, Math.min(5, Math.round(Number(ar.ratingValue) || 0))));
1452
+ rating = ar.reviewCount != null ? `${stars} ${str(ar.ratingValue)} · ${str(ar.reviewCount)}` : `${stars} ${str(ar.ratingValue)}`;
1453
+ }
1454
+ // 24/7 emergency service is only implied by round-the-clock opening hours.
1455
+ const hours = entity.openingHoursSpecification;
1456
+ const emergency = Array.isArray(hours) &&
1457
+ hours.some((h) => h &&
1458
+ typeof h === 'object' &&
1459
+ String(h.opens) === '00:00' &&
1460
+ String(h.closes) === '23:59');
1461
+ // One supporting line under the headline (feature-card pattern).
1462
+ const desc = str(entity.description).trim();
1463
+ const sub = desc ? (desc.length > 150 ? desc.slice(0, 147) + '…' : desc) : undefined;
1464
+ const rows = [];
1465
+ const push = (label, value, href) => {
1466
+ const v = str(value).trim();
1467
+ if (v && rows.length < 4 && !rows.some((r) => r.label === label)) {
1468
+ rows.push({ label, value: v.length > 90 ? v.slice(0, 87) + '…' : v, ...(href ? { href } : {}) });
1469
+ }
1470
+ };
1471
+ const addr = entity.address;
1472
+ if (addr && typeof addr === 'object') {
1473
+ const parts = [addr.streetAddress, addr.addressLocality, addr.addressRegion].filter(Boolean).map(str);
1474
+ if (parts.length)
1475
+ push('Address', parts.join(', '));
1476
+ }
1477
+ // Preferred scalar fields, then any remaining scalars as a fallback.
1478
+ const preferred = ['telephone', 'email', 'url', 'count'];
1479
+ const seen = new Set(['name', 'title', 'headline', 'image', 'description', ...preferred]);
1480
+ for (const key of preferred) {
1481
+ const v = entity[key];
1482
+ if (typeof v === 'string' || typeof v === 'number') {
1483
+ const href = key === 'telephone' ? `tel:${String(v).replace(/[^\d+]/g, '')}` : undefined;
1484
+ push(humanize(key === 'url' ? 'website' : key), v, href);
1485
+ }
1486
+ }
1487
+ if (rows.length < 3) {
1488
+ for (const [k, v] of Object.entries(entity)) {
1489
+ if (k.startsWith('@') || seen.has(k))
1490
+ continue;
1491
+ if (typeof v === 'string' || typeof v === 'number')
1492
+ push(humanize(k), v);
1493
+ }
1494
+ }
1495
+ const id = entity['@id'] != null ? str(entity['@id']) : undefined;
1496
+ return { title, type, rating, emergency, sub, rows, id };
1497
+ }
1498
+ _buildResultsItem(tool, res) {
1499
+ const items = this._extractItems(res);
1500
+ const cards = items.slice(0, 12).map((e) => this._entityCard(e));
1501
+ const note = !cards.length
1502
+ ? 'No results found.'
1503
+ : `${cards.length} result${cards.length === 1 ? '' : 's'}`;
1504
+ return { kind: 'results', title: humanize(tool.name), note, cards };
1505
+ }
1506
+ /** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
1507
+ * supporting line, then mono-labelled detail rows. */
1508
+ /**
1509
+ * ResultCard → A2UI card data.
1510
+ *
1511
+ * Three things do not survive the trip, all catalog limits rather than
1512
+ * mapping choices: the tag colours (basic Text has no tone), the tel: links
1513
+ * on rows (no link component), and the @id line. They are dropped here
1514
+ * rather than faked so the difference is visible when comparing renderers.
1515
+ */
1516
+ _toA2uiCards(cards) {
1517
+ return cards.map((c) => ({
1518
+ title: c.title,
1519
+ sub: c.sub ?? '',
1520
+ tags: [
1521
+ ...(c.type ? [{ label: c.type }] : []),
1522
+ ...(c.rating ? [{ label: c.rating }] : []),
1523
+ ...(c.emergency ? [{ label: '24/7' }] : []),
1524
+ ],
1525
+ rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
1526
+ }));
1527
+ }
1528
+ /** Results rendered through A2UI instead of the bespoke card markup. */
1529
+ _resultsElA2ui(item) {
1530
+ const wrap = document.createElement('div');
1531
+ wrap.className = 'results a2ui a2ui-scope self-stretch w-full';
1532
+ if (!item.cards.length) {
1533
+ wrap.innerHTML = `<div class="rc-empty">${escapeHtml(item.note)}</div>`;
1534
+ return wrap;
1535
+ }
1536
+ try {
1537
+ const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
1538
+ const mounted = mountSurface(wrap, messages);
1539
+ if (!mounted)
1540
+ throw new Error('no surface produced');
1541
+ this._surfaces.push(mounted);
1542
+ }
1543
+ catch (err) {
1544
+ // A renderer failure must not take the conversation with it.
1545
+ console.warn('[a2ui] results surface failed, using inline cards', err);
1546
+ return this._resultsElLegacy(item);
1547
+ }
1548
+ return wrap;
1549
+ }
1550
+ /**
1551
+ * Typed-retrieval results.
1552
+ *
1553
+ * Drawn as DOM, for the same reason the review card is: the house result card
1554
+ * has a tinted logo chip, blue and green pill tags, a Manrope headline and
1555
+ * tel: links on its rows, and none of that is expressible in A2UI's basic
1556
+ * catalog. Each component nests its own shadow root, so only the global
1557
+ * --a2ui-* properties reach inside, and they are per component type, so there
1558
+ * is no way to say "this tag is a rating" from out here.
1559
+ *
1560
+ * emitCardsSurface remains in a2ui/cards.ts for renderers that want the
1561
+ * surface; a2ui="a2ui-results" opts this element back into it for comparison.
1562
+ */
1563
+ _resultsEl(item) {
1564
+ return this.getAttribute('a2ui') === 'a2ui-results'
1565
+ ? this._resultsElA2ui(item)
1566
+ : this._resultsElLegacy(item);
1567
+ }
1568
+ _resultsElLegacy(item) {
1569
+ const wrap = document.createElement('div');
1570
+ wrap.className = 'msg results';
1571
+ if (!item.cards.length) {
1572
+ wrap.innerHTML = `<div class="rc-empty">${escapeHtml(item.note)}</div>`;
1573
+ return wrap;
1574
+ }
1575
+ const cardsHtml = item.cards
1576
+ .map((c) => {
1577
+ const rows = c.rows
1578
+ .map((r) => `<div class="rc-row"><dt>${escapeHtml(r.label)}</dt><dd>${r.href ? `<a href="${escapeHtml(r.href)}">${escapeHtml(r.value)}</a>` : escapeHtml(r.value)}</dd></div>`)
1579
+ .join('');
1580
+ const tags = (c.type ? `<span class="tag blue"><i class="dot"></i>${escapeHtml(c.type)}</span>` : '') +
1581
+ (c.rating ? `<span class="tag green"><i class="dot"></i>${escapeHtml(c.rating)}</span>` : '') +
1582
+ (c.emergency ? `<span class="tag green"><i class="dot"></i>24/7</span>` : '');
1583
+ return `<div class="resultcard">
1584
+ <div class="rc-top">
1585
+ <div class="rc-chip">${this._logoMarkup()}</div>
1586
+ <div class="rc-tags">${tags}</div>
1587
+ </div>
1588
+ <div class="rc-title">${escapeHtml(c.title)}</div>
1589
+ ${c.sub ? `<div class="rc-sub">${escapeHtml(c.sub)}</div>` : ''}
1590
+ ${rows ? `<dl class="rc-list">${rows}</dl>` : ''}
1591
+ ${c.id ? `<div class="rc-id"><code>${escapeHtml(c.id)}</code></div>` : ''}
1592
+ </div>`;
1593
+ })
1594
+ .join('');
1595
+ wrap.innerHTML = `<div class="rc-head">${escapeHtml(item.note)}</div><div class="rc-cards">${cardsHtml}</div>`;
1596
+ return wrap;
1597
+ }
1598
+ // ── Metrics panel (real Gemma tokenizer counts vs a RAG estimate) ──────────
1599
+ /** Keep one 'metrics' thread item, always at the end of the thread. */
1600
+ _upsertMetrics() {
1601
+ // The comparison needs a tokenizer. A provider that cannot count would
1602
+ // render every figure as zero, which would read as a measurement.
1603
+ if (!this.provider?.countTokens)
1604
+ return;
1605
+ const conv = this._active();
1606
+ if (!conv || !conv.metrics || !conv.metrics.length)
1607
+ return;
1608
+ const idx = conv.items.findIndex((it) => it.kind === 'metrics');
1609
+ if (idx !== -1)
1610
+ conv.items.splice(idx, 1);
1611
+ const item = { kind: 'metrics', rounds: conv.metrics };
1612
+ conv.items.push(item);
1613
+ const existing = this._els.thread.querySelector('.msg.metrics');
1614
+ if (existing)
1615
+ existing.remove();
1616
+ this._els.thread.appendChild(this._metricsEl(item));
1617
+ }
1618
+ _metricsEl(item) {
1619
+ const rounds = item.rounds;
1620
+ const totalInput = rounds.reduce((s, m) => s + m.promptTokens, 0);
1621
+ const totalOutput = rounds.reduce((s, m) => s + m.outputTokens, 0);
1622
+ const totalToolCalls = rounds.reduce((s, m) => s + m.toolCalls, 0);
1623
+ const totalToolTokens = rounds.reduce((s, m) => s + m.toolResultTokens, 0);
1624
+ const mcpTotal = totalInput + totalOutput;
1625
+ const { ragInput, ragOutput, numResults } = estimateRagTokens(rounds);
1626
+ const ragTotal = ragInput + ragOutput;
1627
+ const maxTotal = Math.max(mcpTotal, ragTotal, 1);
1628
+ const savings = ragTotal > 0 ? ((ragTotal - mcpTotal) / ragTotal) * 100 : 0;
1629
+ const rows = rounds
1630
+ .map((m) => `<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>`)
1631
+ .join('');
1632
+ const bar = (label, value, cls) => `<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>`;
1633
+ const badge = Math.abs(savings) > 1
1634
+ ? `<div class="mx-savings ${savings > 0 ? 'pos' : 'neg'}">${savings > 0
1635
+ ? `↓ ${savings.toFixed(0)}% fewer tokens with typed retrieval`
1636
+ : `↑ ${Math.abs(savings).toFixed(0)}% more — multi-round context accumulation adds overhead`}</div>`
1637
+ : '';
1638
+ const el = document.createElement('div');
1639
+ el.className = 'msg metrics';
1640
+ el.innerHTML = `
1641
+ <details class="metricscard">
1642
+ <summary>⚡ ${fmt(mcpTotal)} tokens · ${rounds.length} round${rounds.length === 1 ? '' : 's'} · ${totalToolCalls} tool call${totalToolCalls === 1 ? '' : 's'}</summary>
1643
+ <table class="mx-table">
1644
+ <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>
1645
+ <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>
1646
+ </table>
1647
+ <div class="mx-compare">
1648
+ <div class="mx-compare-title">Typed Agent vs Traditional RAG</div>
1649
+ ${bar('Typed', mcpTotal, 'mcp')}
1650
+ ${bar('RAG est.', ragTotal, 'rag')}
1651
+ ${badge}
1652
+ <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>
1653
+ </div>
1654
+ </details>`;
1655
+ return el;
1656
+ }
1657
+ // ── Preferences (programmatic) ─────────────────────────────────────────────
1658
+ /**
1659
+ * Stored values the agent may use to fill forms.
1660
+ *
1661
+ * Supplied by the host page, which usually knows them already, so a signed-in
1662
+ * visitor is not asked to re-enter their own details.
1663
+ *
1664
+ * Session-scoped by default. Persistence is opt-in, since these values are
1665
+ * personal data and the element offers no UI for a visitor to review or clear
1666
+ * what a page has stored. Set `persist = true` to keep them across reloads.
1667
+ *
1668
+ * @example
1669
+ * ```ts
1670
+ * const agent = document.querySelector('nxis-agent-fullscreen');
1671
+ * agent.preferences.set({ name: 'Jane Doe', email: 'jane@example.com' });
1672
+ * agent.preferences.set('phone', '555-0100');
1673
+ * agent.preferences.persist = true; // survive reloads
1674
+ * agent.preferences.clear(); // forget everything
1675
+ * ```
1676
+ */
1677
+ get preferences() {
1678
+ return this._prefsApi;
1679
+ }
1680
+ /**
1681
+ * The system prompt.
1682
+ *
1683
+ * Set it before the element connects, or call `reset()` afterwards. Two
1684
+ * instructions in {@link DEFAULT_SYSTEM_PROMPT} change how the harness
1685
+ * behaves rather than only how the model reads; see its documentation before
1686
+ * replacing them.
1687
+ */
1688
+ get systemPrompt() {
1689
+ return this._systemPrompt;
1690
+ }
1691
+ set systemPrompt(prompt) {
1692
+ this._systemPrompt = prompt;
1693
+ // The engine holds the prompt, so a change after connect needs it rebuilt.
1694
+ if (this.provider)
1695
+ this._systemPrompt = prompt;
1696
+ }
1697
+ /**
1698
+ * The line shown above the suggestion chips before a conversation starts.
1699
+ *
1700
+ * Only the host knows what this agent can actually do, so the default
1701
+ * describes the mechanism rather than the subject. Set it to say what is
1702
+ * available here.
1703
+ */
1704
+ get emptyState() {
1705
+ return this._emptyState;
1706
+ }
1707
+ set emptyState(text) {
1708
+ this._emptyState = text;
1709
+ if (this._els?.thread)
1710
+ this._renderThread();
1711
+ }
1712
+ /**
1713
+ * The agent's name, used in the header, the launcher's tooltip and the accessible
1714
+ * name of both the launcher and the dialog.
1715
+ *
1716
+ * "Nxis Agent" is a placeholder standing in for a name the host has not
1717
+ * chosen yet. On a customer's own site the agent is theirs, and it should
1718
+ * introduce itself as theirs.
1719
+ */
1720
+ get name() {
1721
+ return this._name;
1722
+ }
1723
+ set name(value) {
1724
+ this._name = value || DEFAULT_AGENT_NAME;
1725
+ this._applyIdentity();
1726
+ }
1727
+ /**
1728
+ * The mark on the launcher, in the headers, above the empty state and on
1729
+ * result cards. Inline SVG markup, or the URL of an image.
1730
+ *
1731
+ * Markup is injected as written, so pass only your own, never anything a
1732
+ * visitor can influence. The footer's "Powered by NxisAI" badge is not
1733
+ * affected by this.
1734
+ */
1735
+ get logo() {
1736
+ return this._logo;
1737
+ }
1738
+ set logo(value) {
1739
+ this._logo = value || DEFAULT_LOGO;
1740
+ this._applyIdentity();
1741
+ if (this._els?.thread)
1742
+ this._renderThread();
1743
+ }
1744
+ /**
1745
+ * Brand overrides, as custom properties on the host element.
1746
+ *
1747
+ * Merged over the defaults, not replacing them, so a theme can set one
1748
+ * colour and leave the rest of the system alone. Assigning writes inline
1749
+ * styles, which outrank a stylesheet rule targeting this element, so theme
1750
+ * here or in CSS, but not both.
1751
+ */
1752
+ get theme() {
1753
+ return { ...this._theme };
1754
+ }
1755
+ set theme(next) {
1756
+ this._theme = { ...next };
1757
+ this._applyTheme();
1758
+ }
1759
+ /** Empty-state chips. Set to [] to show none. */
1760
+ get suggestions() {
1761
+ return [...this._suggestions];
1762
+ }
1763
+ set suggestions(next) {
1764
+ this._suggestions = [...next];
1765
+ if (this._els?.thread)
1766
+ this._renderThread();
1767
+ }
1768
+ _loadProfile() {
1769
+ try {
1770
+ return JSON.parse(localStorage.getItem(PROFILE_KEY) || 'null') || {};
1771
+ }
1772
+ catch {
1773
+ return {};
1774
+ }
1775
+ }
1776
+ /** Persist only when asked; otherwise leave storage untouched. */
1777
+ _persistProfile() {
1778
+ if (!this._persistPrefs)
1779
+ return;
1780
+ try {
1781
+ localStorage.setItem(PROFILE_KEY, JSON.stringify(this._profile));
1782
+ }
1783
+ catch {
1784
+ /* storage blocked/full */
1785
+ }
1786
+ }
1787
+ /**
1788
+ * Take new values and let the rest of the agent notice.
1789
+ *
1790
+ * Two things depend on this: the system message carries the values so the
1791
+ * model can use them in prose, and the harness reads them as a slot source
1792
+ * so a field the host already supplied is never asked for.
1793
+ */
1794
+ _applyPreferences(next) {
1795
+ this._profile = next;
1796
+ this._persistProfile();
1797
+ this._syncProfileContext();
1798
+ this._saveConversations();
1799
+ this.dispatchEvent(new CustomEvent('nxis-preferences-change', {
1800
+ detail: { preferences: { ...next } },
1801
+ bubbles: true,
1802
+ composed: true,
1803
+ }));
1804
+ }
1805
+ _buildPreferencesApi() {
1806
+ const self = this;
1807
+ return {
1808
+ get persist() {
1809
+ return self._persistPrefs;
1810
+ },
1811
+ set persist(on) {
1812
+ self._persistPrefs = on;
1813
+ if (on)
1814
+ self._persistProfile();
1815
+ else {
1816
+ try {
1817
+ localStorage.removeItem(PROFILE_KEY);
1818
+ }
1819
+ catch {
1820
+ /* storage blocked */
1821
+ }
1822
+ }
1823
+ },
1824
+ // Overloaded in the interface; implemented as one function, so the
1825
+ // implementation signature is widened and the cast narrows it back.
1826
+ get: ((key) => key === undefined ? { ...self._profile } : self._profile[key]),
1827
+ set: ((keyOrValues, value) => {
1828
+ const patch = typeof keyOrValues === 'string' ? { [keyOrValues]: value ?? '' } : keyOrValues;
1829
+ const next = { ...self._profile };
1830
+ for (const [k, v] of Object.entries(patch)) {
1831
+ const trimmed = String(v ?? '').trim();
1832
+ // An empty value clears rather than storing "", so set(k, '') and
1833
+ // clear(k) mean the same thing instead of quietly differing.
1834
+ if (trimmed)
1835
+ next[k] = trimmed;
1836
+ else
1837
+ delete next[k];
1838
+ }
1839
+ self._applyPreferences(next);
1840
+ }),
1841
+ clear(key) {
1842
+ if (key === undefined) {
1843
+ self._applyPreferences({});
1844
+ try {
1845
+ localStorage.removeItem(PROFILE_KEY);
1846
+ }
1847
+ catch {
1848
+ /* storage blocked */
1849
+ }
1850
+ return;
1851
+ }
1852
+ const next = { ...self._profile };
1853
+ delete next[key];
1854
+ self._applyPreferences(next);
1855
+ },
1856
+ /** The keys the agent knows how to map onto form fields. */
1857
+ keys() {
1858
+ return Object.keys(PROFILE_ALIASES);
1859
+ },
1860
+ };
1861
+ }
1862
+ /** Map the saved profile onto a tool's schema property names. */
1863
+ _profileForSchema(schema) {
1864
+ const props = schema && schema.properties ? Object.keys(schema.properties) : [];
1865
+ if (!props.length)
1866
+ return {};
1867
+ const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
1868
+ const out = {};
1869
+ for (const [pf, val] of Object.entries(this._profile)) {
1870
+ if (!val || !String(val).trim())
1871
+ continue;
1872
+ const cands = (PROFILE_ALIASES[pf] || [pf]).map(norm);
1873
+ const match = props.find((p) => cands.includes(norm(p))) ||
1874
+ props.find((p) => cands.some((c) => norm(p).includes(c) || c.includes(norm(p))));
1875
+ if (match && out[match] === undefined)
1876
+ out[match] = val;
1877
+ }
1878
+ return out;
1879
+ }
1880
+ /** Fold the current profile into the active conversation's system message. */
1881
+ _syncProfileContext() {
1882
+ const conv = this._active();
1883
+ if (!conv || !conv.messages[0])
1884
+ return;
1885
+ const entries = Object.entries(this._profile).filter(([, v]) => v && String(v).trim());
1886
+ const note = entries.length
1887
+ ? '\n\nSaved user profile — use these to fill matching fields unless the user gives different values: ' +
1888
+ entries.map(([k, v]) => `${humanize(k)}: ${v}`).join('; ') +
1889
+ '.'
1890
+ : '';
1891
+ const formatReq = '\n\nFor fields that require a specific format (such as dates), explicitly inform the user of the required format (e.g., mm/dd/yyyy).';
1892
+ conv.messages[0] = { role: 'system', content: this._systemPrompt + note + formatReq };
1893
+ }
1894
+ // ── Misc UI ────────────────────────────────────────────────────────────────
1895
+ _setStatus(text, isError = false) {
1896
+ // Errors only.
1897
+ //
1898
+ // The empty state names the phase while loading and the pending bubble
1899
+ // names it during a turn, so echoing the same words above the composer was
1900
+ // the same sentence twice on one screen. An error has nowhere else to go.
1901
+ this._els.status.textContent = isError ? text || '' : '';
1902
+ this._els.status.classList.toggle('err', !!isError);
1903
+ // The engine names its phases through onStatus, and the empty state shows
1904
+ // that name in place of the invitation, so a phase change has to reach it.
1905
+ if (!isError && text && text !== this._stage) {
1906
+ this._stage = text;
1907
+ const stage = this._els?.thread?.querySelector('.stage');
1908
+ if (stage)
1909
+ stage.textContent = text;
1910
+ // A phase with no numbers still changes what the ring should be doing,
1911
+ // and the phase that ends the load is what brings the chips back, so
1912
+ // this repaints either way rather than only while loading.
1913
+ if (!this.provider?.ready)
1914
+ this._loadDetail = undefined;
1915
+ this._paintLoad();
1916
+ }
1917
+ }
1918
+ _setBar(pct) {
1919
+ this._els.bar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
1920
+ }
1921
+ _scroll() {
1922
+ this._els.thread.scrollTop = this._els.thread.scrollHeight;
1923
+ }
1924
+ _autosize() {
1925
+ const ta = this._els.input;
1926
+ ta.style.height = 'auto';
1927
+ ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`;
1928
+ }
1929
+ /**
1930
+ * Rebuild the specialist agents and their coordinator.
1931
+ *
1932
+ * Called on connect and whenever the host changes `agents`, `routes` or
1933
+ * `fallback`. Inexpensive: each agent is a role over the one shared provider,
1934
+ * so no additional model is loaded.
1935
+ */
1936
+ _buildAgents() {
1937
+ if (!this.provider || !this._agentDefs) {
1938
+ this._coordinator = null;
1939
+ return;
1940
+ }
1941
+ const names = Object.keys(this._agentDefs);
1942
+ const agents = {};
1943
+ for (const name of names) {
1944
+ const def = this._agentDefs[name];
1945
+ agents[name] = new AgentHarness({
1946
+ provider: this.provider,
1947
+ toolSource: this.toolSource,
1948
+ renderer: this._renderer(),
1949
+ profile: (schema) => this._profileForSchema(schema),
1950
+ onMessages: () => this._saveConversations(),
1951
+ systemPrompt: def.systemPrompt,
1952
+ tools: def.tools,
1953
+ name,
1954
+ unavailable: def.unavailable,
1955
+ });
1956
+ }
1957
+ const descriptions = {};
1958
+ for (const name of names) {
1959
+ const d = this._agentDefs[name].description;
1960
+ if (d)
1961
+ descriptions[name] = d;
1962
+ }
1963
+ const fallback = this._fallbackAgent && agents[this._fallbackAgent] ? this._fallbackAgent : names[0];
1964
+ this._coordinator = new AgentCoordinator({
1965
+ agents,
1966
+ rules: this._routes,
1967
+ fallback,
1968
+ // The model pass is the last resort, and it uses the same weights the
1969
+ // specialists do, so there is no second model to load.
1970
+ provider: this.provider,
1971
+ descriptions,
1972
+ onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
1973
+ });
1974
+ }
1975
+ /** Zero when the provider cannot count, which hides the token metrics. */
1976
+ async countTokens(text) {
1977
+ return (await this.provider.countTokens?.(text)) ?? 0;
1978
+ }
1979
+ /**
1980
+ * Add a line to the thread naming the agent that took the turn.
1981
+ *
1982
+ * Behind `debug`, like the tool traces: a misroute is otherwise invisible,
1983
+ * showing only as an unhelpful answer.
1984
+ */
1985
+ _paintRoute(agent, via) {
1986
+ if (!this.debug)
1987
+ return;
1988
+ const item = { kind: 'route', agent, via };
1989
+ this._active()?.items.push(item);
1990
+ this._els.thread.appendChild(this._routeEl(item));
1991
+ this._saveConversations();
1992
+ this._scroll();
1993
+ }
1994
+ _routeEl(item) {
1995
+ const el = document.createElement('div');
1996
+ el.className = 'routeline';
1997
+ el.textContent = `routed to ${item.agent} · ${item.via}`;
1998
+ return el;
1999
+ }
2000
+ /**
2001
+ * Bring in the site's own MCP server, once.
2002
+ *
2003
+ * The page's own action tools register themselves. Remote tools do not: they
2004
+ * live on an MCP server that must be discovered through
2005
+ * `/.well-known/ai-catalog.json` and bridged into the page's registry.
2006
+ *
2007
+ * Runs on first open rather than on connect, so an unopened launcher costs no
2008
+ * network request. Failure is neither fatal nor loud: a site with no MCP
2009
+ * server is an ordinary case, and the page's own tools work regardless.
2010
+ */
2011
+ _connectTools() {
2012
+ if (this._connect)
2013
+ return this._connect;
2014
+ if (this.getAttribute('discover') === 'false') {
2015
+ this._connect = Promise.resolve();
2016
+ return this._connect;
2017
+ }
2018
+ this._connect = connectTools(this.getAttribute('mcp-api-key') || undefined, {
2019
+ // Short, because this blocks the first turn: a provider that has not
2020
+ // appeared within a few seconds is not going to.
2021
+ timeout: 4000,
2022
+ })
2023
+ .then(({ remoteTools, warning }) => {
2024
+ if (warning)
2025
+ console.warn('[nxis-agent]', warning);
2026
+ else if (remoteTools.length) {
2027
+ console.info(`[nxis-agent] discovered remote tools: ${remoteTools.join(', ')}`);
2028
+ }
2029
+ })
2030
+ .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
2031
+ return this._connect;
2032
+ }
2033
+ /**
2034
+ * Report a failure in the thread, where it will be seen.
2035
+ *
2036
+ * Translates the underlying error into a sentence that says what to do next,
2037
+ * rather than surfacing the exception.
2038
+ */
2039
+ _reportEngineError(err) {
2040
+ const raw = String(err?.message || err || 'Unknown error');
2041
+ let human;
2042
+ if (/webgpu/i.test(raw)) {
2043
+ human =
2044
+ 'This browser cannot run the on-device model — it needs WebGPU. ' +
2045
+ 'Chrome or Edge 113+, or Safari 18+, will work.';
2046
+ }
2047
+ else if (/cache|quota|storage/i.test(raw)) {
2048
+ human =
2049
+ "The model could not be stored in this browser's cache, so it did not finish " +
2050
+ 'loading. Clearing this site\'s data usually fixes it, and the next attempt ' +
2051
+ 'will skip the cache.';
2052
+ }
2053
+ else if (/fetch|network|import|load/i.test(raw)) {
2054
+ human =
2055
+ 'The model files could not be downloaded. Check the connection and try ' +
2056
+ 'again — nothing was lost.';
2057
+ }
2058
+ else {
2059
+ human = `The on-device model could not be started: ${raw}`;
2060
+ }
2061
+ this._setStatus(raw, true);
2062
+ this._appendMsg('assistant', human);
2063
+ }
2064
+ /**
2065
+ * Draw load progress on whichever mark is currently visible.
2066
+ *
2067
+ * Exactly one ring is ever on screen: the empty state's mark until a
2068
+ * conversation replaces it, then the header's. Phases that report no totals,
2069
+ * such as the runtime fetch and the warm-up, spin rather than fill.
2070
+ */
2071
+ _paintLoad() {
2072
+ // A provider with nothing to load is ready as soon as it exists, so none
2073
+ // of the progress treatment applies. `load` is optional on the interface so
2074
+ // that this can be detected.
2075
+ const loading = (this.provider?.load ? this.provider.loading : false) ?? false;
2076
+ // The empty state was drawn before this load existed, or before it
2077
+ // finished. Redrawing it calls back here against the right DOM.
2078
+ if (this._emptyLoading !== null && this._emptyLoading !== loading) {
2079
+ this._renderThread();
2080
+ return;
2081
+ }
2082
+ const detail = this._loadDetail;
2083
+ const determinate = loading && !!detail?.total && this._loadPct > 0;
2084
+ const clamped = Math.max(0, Math.min(100, this._loadPct));
2085
+ const ring = this._els?.thread?.querySelector('.ring');
2086
+ const head = this.shadowRoot?.querySelector('.head-ring');
2087
+ for (const [node, show] of [
2088
+ [ring, loading],
2089
+ // The header only takes over once the empty state is gone.
2090
+ [head, loading && !ring],
2091
+ ]) {
2092
+ if (!node)
2093
+ continue;
2094
+ node.hidden = !show;
2095
+ node.classList.toggle('indet', show && !determinate);
2096
+ if (show && determinate)
2097
+ node.style.setProperty('--pct', String(clamped));
2098
+ }
2099
+ const byteLine = this._els?.thread?.querySelector('.stage-bytes');
2100
+ if (byteLine) {
2101
+ byteLine.textContent = determinate
2102
+ ? `${formatBytes(detail.loaded)} / ${formatBytes(detail.total)}`
2103
+ : '';
2104
+ }
2105
+ }
2106
+ /**
2107
+ * Split this element into several specialist agents.
2108
+ *
2109
+ * Each definition becomes an {@link AgentHarness} sharing this element's
2110
+ * provider, so one model in memory serves every role. With none set, the
2111
+ * element runs as a single agent with every tool and one prompt.
2112
+ *
2113
+ * @example
2114
+ * ```ts
2115
+ * agent.agents = {
2116
+ * retrieval: { systemPrompt: '…', tools: (t) => t.name.startsWith('nxis_') },
2117
+ * booking: { systemPrompt: '…', tools: (t) => /book/.test(t.name) },
2118
+ * };
2119
+ * agent.routes = [{ route: 'booking', match: /\bbook\b/ }];
2120
+ * agent.fallback = 'retrieval';
2121
+ * ```
2122
+ */
2123
+ get agents() {
2124
+ return this._agentDefs;
2125
+ }
2126
+ set agents(defs) {
2127
+ this._agentDefs = defs && Object.keys(defs).length ? defs : null;
2128
+ this._buildAgents();
2129
+ }
2130
+ /** The routing table. Ignored unless `agents` is set. */
2131
+ get routes() {
2132
+ return this._routes;
2133
+ }
2134
+ set routes(rules) {
2135
+ this._routes = rules ?? [];
2136
+ this._buildAgents();
2137
+ }
2138
+ /**
2139
+ * Where a turn goes when the rules abstain, nothing is sticky and the model
2140
+ * declined. Defaults to the first agent declared.
2141
+ */
2142
+ get fallback() {
2143
+ return this._fallbackAgent;
2144
+ }
2145
+ set fallback(name) {
2146
+ this._fallbackAgent = name;
2147
+ this._buildAgents();
2148
+ }
2149
+ /**
2150
+ * Where completions come from. Defaults to Gemma on WebGPU.
2151
+ *
2152
+ * Set a provider to run the agent on a remote model. Anything a host writes
2153
+ * here should talk to their own origin rather than a vendor directly: a key
2154
+ * in a browser is a key anyone can read from the page.
2155
+ */
2156
+ get modelProvider() {
2157
+ return this._provider;
2158
+ }
2159
+ set modelProvider(next) {
2160
+ this._provider = next;
2161
+ // Late enough to matter: rebuild so the next turn uses it.
2162
+ if (this._els?.thread) {
2163
+ this.provider = next ?? this.provider;
2164
+ this._paintProvenance();
2165
+ this._buildAgents();
2166
+ }
2167
+ }
2168
+ /**
2169
+ * Show the tool calls behind each answer.
2170
+ *
2171
+ * Off by default, and intended for whoever is building the agent. Traces
2172
+ * carry the arguments a tool was called with, which for a form tool includes
2173
+ * personal data, so while this is off they are neither rendered nor stored
2174
+ * rather than merely hidden.
2175
+ *
2176
+ * Enabling it affects subsequent turns, not those already recorded.
2177
+ */
2178
+ get debug() {
2179
+ const raw = (this.getAttribute('debug') || '').toLowerCase();
2180
+ if (!this.hasAttribute('debug'))
2181
+ return false;
2182
+ return raw !== 'false' && raw !== '0' && raw !== 'off';
2183
+ }
2184
+ set debug(on) {
2185
+ if (on)
2186
+ this.setAttribute('debug', 'true');
2187
+ else
2188
+ this.removeAttribute('debug');
2189
+ if (this._els?.thread)
2190
+ this._renderThread();
2191
+ }
2192
+ /**
2193
+ * How eagerly to fetch the model weights.
2194
+ *
2195
+ * `eager` starts on connect, `idle` waits for the browser to be free (the
2196
+ * default), and `none` waits until the panel is opened or a message is sent.
2197
+ * `preload="false"` is accepted as a synonym for `none`.
2198
+ */
2199
+ get preload() {
2200
+ const raw = (this.getAttribute('preload') || '').toLowerCase();
2201
+ if (raw === 'eager')
2202
+ return 'eager';
2203
+ if (raw === 'false' || raw === 'none')
2204
+ return 'none';
2205
+ return 'idle';
2206
+ }
2207
+ set preload(mode) {
2208
+ this.setAttribute('preload', mode);
2209
+ // Honoured when set after connect, so a host can start the load mid-session
2210
+ // without a reload.
2211
+ if (mode === 'eager' && this.provider && !this.provider.ready && !this.provider.loading) {
2212
+ this.provider.preload?.('eager');
2213
+ }
2214
+ }
2215
+ /**
2216
+ * The logo as markup.
2217
+ *
2218
+ * A value starting with `<` is used as written; anything else is treated as
2219
+ * an image URL.
2220
+ */
2221
+ _logoMarkup() {
2222
+ const logo = this._logo.trim();
2223
+ return logo.startsWith('<') ? logo : `<img src="${escapeHtml(logo)}" alt="" />`;
2224
+ }
2225
+ /**
2226
+ * Push the current name and logo into the rendered shadow tree.
2227
+ *
2228
+ * Called on connect and on every set, so hosts can configure the element
2229
+ * before or after it upgrades without having to know which happened first.
2230
+ */
2231
+ _applyIdentity() {
2232
+ const root = this.shadowRoot;
2233
+ if (!root)
2234
+ return;
2235
+ const logo = this._logoMarkup();
2236
+ root.querySelectorAll('.launcher, .brand').forEach((node) => {
2237
+ node.innerHTML = logo;
2238
+ });
2239
+ const heading = root.querySelector('.main-head h2');
2240
+ if (heading)
2241
+ heading.textContent = this._name;
2242
+ this._paintProvenance();
2243
+ const launcher = root.querySelector('.launcher');
2244
+ launcher?.setAttribute('title', `Open ${this._name}`);
2245
+ launcher?.setAttribute('aria-label', `Open ${this._name}`);
2246
+ root.querySelector('.overlay')?.setAttribute('aria-label', this._name);
2247
+ }
2248
+ /**
2249
+ * Say where the answers come from.
2250
+ *
2251
+ * The package's claim is that nothing leaves the tab, so the one case where
2252
+ * that stops being true has to be on screen. Painted separately from the rest
2253
+ * of the identity because the provider is built after the shadow tree, and
2254
+ * can be replaced later.
2255
+ */
2256
+ _paintProvenance() {
2257
+ const badge = this.shadowRoot?.querySelector('.provenance');
2258
+ if (!badge?.lastChild)
2259
+ return;
2260
+ const local = this.provider?.local ?? true;
2261
+ badge.lastChild.textContent = local ? 'On-device' : 'Remote';
2262
+ badge.setAttribute('title', local
2263
+ ? 'The model runs in this browser. Nothing you type leaves the device.'
2264
+ : 'Answers come from a model on a server, so this conversation leaves the device.');
2265
+ }
2266
+ /** Write the theme's custom properties onto the host element. */
2267
+ _applyTheme() {
2268
+ for (const props of Object.values(THEME_TOKENS)) {
2269
+ for (const prop of props)
2270
+ this.style.removeProperty(prop);
2271
+ }
2272
+ for (const [key, value] of Object.entries(this._theme)) {
2273
+ const props = THEME_TOKENS[key];
2274
+ if (!props || !value)
2275
+ continue;
2276
+ for (const prop of props)
2277
+ this.style.setProperty(prop, value);
2278
+ }
2279
+ }
2280
+ _template() {
2281
+ return `
2282
+ <style>
2283
+ /* NxisAI Visual Direction — tokens from design.md (drop-in CSS). */
2284
+ :host {
2285
+ all: initial;
2286
+ --bg:#FFFFFF; --surface:#F5F7FB; --surface-2:#EDF1F9;
2287
+ --border:#E4E9F2; --border-strong:#CFD8E8;
2288
+ --ink:#0F172A; --ink-2:#475569; --ink-3:#8A97AC;
2289
+ --blue:#2563EB; --blue-strong:#1D4ED8; --blue-tint:#EEF3FF;
2290
+ --green:#22C55E; --green-strong:#16A34A; --green-tint:#E8FAEF;
2291
+ --inline-bg:#EEF2F8; --red:#B91C1C;
2292
+ --radius:14px; --radius-sm:10px; --radius-pill:999px;
2293
+ --shadow-sm:0 1px 2px rgba(15,23,42,.05);
2294
+ --shadow:0 1px 2px rgba(15,23,42,.04), 0 10px 30px rgba(15,23,42,.06);
2295
+ --display:'Manrope', system-ui, sans-serif;
2296
+ --sans:'Inter', system-ui, sans-serif;
2297
+ --mono:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
2298
+ font-family: var(--sans);
2299
+
2300
+ /*
2301
+ * Tailwind v4 composes box-shadow, ring and border-style from --tw-*
2302
+ * custom properties, and "all: initial" above wipes them, so every
2303
+ * child inherits empty values, and shadow/border utilities resolve to
2304
+ * nothing while looking perfectly correct in the stylesheet. Restoring
2305
+ * the defaults here is what makes those utilities work inside a shadow
2306
+ * root that isolates itself from the page.
2307
+ */
2308
+ --tw-shadow: 0 0 #0000;
2309
+ --tw-inset-shadow: 0 0 #0000;
2310
+ --tw-ring-shadow: 0 0 #0000;
2311
+ --tw-inset-ring-shadow: 0 0 #0000;
2312
+ --tw-ring-offset-shadow: 0 0 #0000;
2313
+ --tw-border-style: solid;
2314
+ }
2315
+
2316
+ /* Tailwind, scoped to this shadow root. Utilities resolve to the NxisAI
2317
+ palette because agent.tw.css themes them from design.md. */
2318
+ ${TAILWIND_CSS}
2319
+
2320
+ /* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
2321
+ ${A2UI_THEME_CSS}
2322
+
2323
+ /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
2324
+ .launcher {
2325
+ position: fixed; right: 20px; bottom: 20px; z-index: 9998;
2326
+ width: 56px; height: 56px; border-radius: var(--radius); cursor: pointer; padding: 0;
2327
+ display: flex; align-items: center; justify-content: center;
2328
+ border: 1px solid var(--border); background: var(--surface);
2329
+ box-shadow: var(--shadow-sm);
2330
+ transition: transform .15s ease, box-shadow .15s ease;
2331
+ }
2332
+ .launcher:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
2333
+ .launcher svg, .launcher img { width: 34px; height: auto; display: block; }
2334
+
2335
+ .overlay { position: fixed; inset: 0; z-index: 9999; display: none; background: var(--bg); color: var(--ink); }
2336
+ .overlay.show { display: flex; animation: fade .2s ease; }
2337
+ @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
2338
+
2339
+ /* ── Side panel ─────────────────────────────────────────────── */
2340
+ .scrim { display: none; }
2341
+ .side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-right: 1px solid var(--border); }
2342
+ .side-head { display: flex; align-items: center; gap: 10px; padding: 16px 14px 12px; }
2343
+ .side-head .brand { width: 22px; height: 22px; display: block; }
2344
+ .side-head .brand svg, .side-head .brand img { width: 22px; height: auto; display: block; }
2345
+ .side-title { font-family: var(--display); font-weight: 800; font-size: 14px; }
2346
+ .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; }
2347
+ .newchat:hover { color: var(--blue-strong); border-color: var(--blue); }
2348
+ .newchat svg { width: 16px; height: 16px; }
2349
+ .history { flex: 1; overflow-y: auto; padding: 6px 10px; display: flex; flex-direction: column; gap: 3px; }
2350
+ .hist-row { display: flex; align-items: center; border-radius: 8px; }
2351
+ .hist-row:hover { background: var(--surface-2); }
2352
+ .hist-row.active { background: var(--blue-tint); }
2353
+ .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; }
2354
+ .hist-row.active .hist-item { color: var(--blue-strong); font-weight: 600; }
2355
+ .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; }
2356
+ .hist-row:hover .hist-del, .hist-row.active .hist-del { opacity: 1; }
2357
+ .hist-del:hover { color: var(--red); background: var(--bg); }
2358
+ .hist-del svg { width: 15px; height: 15px; }
2359
+
2360
+
2361
+
2362
+
2363
+
2364
+
2365
+
2366
+
2367
+
2368
+
2369
+
2370
+
2371
+
2372
+ /* ── Main view ──────────────────────────────────────────────── */
2373
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
2374
+ .main-head { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--border); }
2375
+ .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; }
2376
+ .menu svg { width: 18px; height: 18px; }
2377
+ .main-head .brand { width: 24px; height: 24px; display: block; }
2378
+ .main-head .brand svg, .main-head .brand img { width: 24px; height: auto; display: block; }
2379
+ .main-head h2 { margin: 0; font-family: var(--display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
2380
+ /* Tags & chips — Inter 500 13px pill, optional leading dot. */
2381
+ .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; }
2382
+ .tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); }
2383
+ .tag.blue { color: var(--blue-strong); background: var(--blue-tint); border-color: transparent; }
2384
+ .tag.blue .dot { background: var(--blue); }
2385
+ .tag.green { color: var(--green-strong); background: var(--green-tint); border-color: transparent; }
2386
+ .tag.green .dot { background: var(--green); }
2387
+ .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; }
2388
+ .prog { height: 2px; background: var(--surface-2); }
2389
+ .prog > i { display: block; height: 100%; width: 0; background: var(--blue); transition: width .2s ease; }
2390
+
2391
+ .thread { flex: 1; overflow-y: auto; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
2392
+ .empty { margin: auto; text-align: center; color: var(--ink-3); display: flex; flex-direction: column; align-items: center; gap: 14px; }
2393
+ .empty svg, .empty img { width: 44px; height: auto; opacity: .9; }
2394
+ .empty p { max-width: 320px; font-size: 14px; line-height: 1.5; }
2395
+ .chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 460px; }
2396
+ .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; }
2397
+ .chip:hover { border-color: var(--blue); color: var(--blue-strong); background: var(--blue-tint); }
2398
+
2399
+ /* Thinking indicator — three dots and an optional phase label. */
2400
+ .msg.thinking { display: inline-flex; align-items: center; gap: 10px; }
2401
+ .think-dots { display: inline-flex; align-items: center; gap: 4px; }
2402
+ .think-dots i {
2403
+ width: 6px; height: 6px; border-radius: 50%;
2404
+ background: var(--ink-3); display: block;
2405
+ animation: nxis-think 1.2s ease-in-out infinite;
2406
+ }
2407
+ .think-dots i:nth-child(2) { animation-delay: .15s; }
2408
+ .think-dots i:nth-child(3) { animation-delay: .3s; }
2409
+ @keyframes nxis-think {
2410
+ 0%, 80%, 100% { opacity: .28; transform: translateY(0); }
2411
+ 40% { opacity: 1; transform: translateY(-3px); }
2412
+ }
2413
+ .think-label {
2414
+ font-family: var(--mono); font-size: 11px; font-weight: 500;
2415
+ letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3);
2416
+ }
2417
+ .think-label:empty { display: none; }
2418
+
2419
+ /* ── The wait, with numbers on it ──────────────────────────────────
2420
+ A first run downloads gigabytes. Dots alone say "working"; they never
2421
+ say "for how long", which is the only question a multi-minute wait
2422
+ raises. The line appears for the phases that have bytes behind them
2423
+ and stays hidden for the ones that do not. */
2424
+ .msg.thinking { flex-direction: column; align-items: stretch; gap: 8px; min-width: 190px; }
2425
+ .think-row { display: inline-flex; align-items: center; gap: 10px; }
2426
+ .think-bytes {
2427
+ margin-left: auto; font-family: var(--mono); font-size: 10px;
2428
+ color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap;
2429
+ }
2430
+ .think-bytes:empty { display: none; }
2431
+
2432
+ /* ── The mark, charging ────────────────────────────────────────────
2433
+ Same progress, drawn on the object already sitting in the empty
2434
+ state. The ring wraps whatever the host set as their logo, so the
2435
+ thing that fills is theirs. */
2436
+ .empty-mark { position: relative; display: grid; place-items: center; }
2437
+ .empty-mark .ring {
2438
+ --pct: 0;
2439
+ position: absolute; inset: -13px;
2440
+ border-radius: 50%;
2441
+ background: conic-gradient(var(--blue) calc(var(--pct) * 1%), var(--surface-2) 0);
2442
+ transition: background .35s linear;
2443
+ }
2444
+ .empty-mark .ring::after {
2445
+ content: ""; position: absolute; inset: 3px;
2446
+ border-radius: 50%; background: var(--bg);
2447
+ }
2448
+ .empty-mark svg, .empty-mark img { position: relative; z-index: 1; }
2449
+
2450
+ /* The stage, standing in for the invitation while weights arrive. */
2451
+ .empty .stage {
2452
+ font-family: var(--mono); font-weight: 500; font-size: 11px;
2453
+ letter-spacing: .05em; color: var(--ink-2); margin: 0;
2454
+ }
2455
+ .empty .stage-bytes {
2456
+ font-family: var(--mono); font-size: 10px; color: var(--ink-3);
2457
+ font-variant-numeric: tabular-nums; margin: -8px 0 0;
2458
+ }
2459
+ .empty .stage-bytes:empty { display: none; }
2460
+ /* The suggestions have waited for the model; let them arrive, rather than
2461
+ appearing as though they had been there all along. */
2462
+ .chips.appear { animation: nxis-chips-in .32s ease both; }
2463
+ @keyframes nxis-chips-in {
2464
+ from { opacity: 0; transform: translateY(6px); }
2465
+ to { opacity: 1; transform: none; }
2466
+ }
2467
+
2468
+ /* The header mark carries what is left of the load once the thread is
2469
+ open: the same ring, smaller, out of the conversation's way. */
2470
+ .main-head .head-mark { position: relative; display: grid; place-items: center; }
2471
+ /* The ring is absolutely positioned, so it paints above a static sibling,
2472
+ and its ::after fills the middle with the panel colour. Without this the
2473
+ disc covers the logo and the header shows an empty circle. */
2474
+ .main-head .brand { position: relative; z-index: 1; }
2475
+ .main-head .head-ring {
2476
+ --pct: 0; position: absolute; inset: -4px; border-radius: 50%;
2477
+ background: conic-gradient(var(--blue) calc(var(--pct) * 1%), var(--surface-2) 0);
2478
+ transition: background .35s linear;
2479
+ }
2480
+ .main-head .head-ring::after {
2481
+ content: ""; position: absolute; inset: 2px; border-radius: 50%; background: var(--bg);
2482
+ }
2483
+ /* No numbers yet: sweep instead of sitting at zero, which reads as hung. */
2484
+ .empty-mark .ring.indet, .main-head .head-ring.indet {
2485
+ background: conic-gradient(from 0deg, transparent 0deg, var(--blue) 300deg, transparent 360deg);
2486
+ animation: nxis-ring-spin 1.1s linear infinite;
2487
+ }
2488
+ @keyframes nxis-ring-spin { to { transform: rotate(360deg); } }
2489
+ /*
2490
+ * Motion carries the signal here, so removing it needs a replacement
2491
+ * rather than a still frame: the dots hold a steady mid-opacity so the
2492
+ * bubble still reads as pending.
2493
+ */
2494
+ @media (prefers-reduced-motion: reduce) {
2495
+ /* The rings carry information, so they stay; only their easing goes. */
2496
+ .empty-mark .ring, .main-head .head-ring, .caret { transition: none; }
2497
+ .chips.appear { animation: none; }
2498
+ /* A spin with the motion removed has to still say "busy": hold a
2499
+ partial arc rather than an empty track. */
2500
+ .empty-mark .ring.indet, .main-head .head-ring.indet {
2501
+ animation: none;
2502
+ background: conic-gradient(var(--blue) 25%, var(--surface-2) 0);
2503
+ }
2504
+ .think-dots i { animation: none; opacity: .5; }
2505
+ }
2506
+
2507
+ /* Bubble styling now lives in _msgEl as Tailwind utilities. The .msg
2508
+ class is kept only as a hook for the wrappers below, which are layout
2509
+ rather than bubbles — so there is nothing here to undo. */
2510
+
2511
+
2512
+
2513
+ .msg.tool.error { border-color: var(--red); color: var(--red); }
2514
+ .msg.tool b { display: inline-flex; align-items: center; gap: 6px; }
2515
+ .msg.tool svg { width: 14px; height: 14px; }
2516
+ .msg.form, .msg.detail { align-self: flex-start; width: 100%; max-width: 380px; }
2517
+
2518
+ /* ── The receipt ───────────────────────────────────────────────────
2519
+ Muted enough to scroll past, addressable when something looks wrong. */
2520
+ /* The route, when debugging: quieter than a receipt, since it is one
2521
+ word of provenance rather than something to read. */
2522
+ .routeline {
2523
+ align-self: flex-start; font-family: var(--mono); font-size: 10px;
2524
+ letter-spacing: .06em; color: var(--ink-3); opacity: .8; padding: 1px 0;
2525
+ }
2526
+
2527
+ details.trace { align-self: flex-start; max-width: 92%; }
2528
+ details.trace > summary {
2529
+ cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 7px;
2530
+ font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
2531
+ color: var(--ink-3); padding: 2px 0;
2532
+ }
2533
+ details.trace > summary::-webkit-details-marker { display: none; }
2534
+ details.trace > summary:hover { color: var(--ink-2); }
2535
+ details.trace > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
2536
+ details.trace.err > summary { color: var(--red); }
2537
+ .caret {
2538
+ width: 0; height: 0; border-left: 4px solid currentColor;
2539
+ border-top: 3.5px solid transparent; border-bottom: 3.5px solid transparent;
2540
+ transition: transform .16s ease; flex-shrink: 0;
2541
+ }
2542
+ details.trace[open] .caret { transform: rotate(90deg); }
2543
+ .trace-body {
2544
+ margin-top: 7px; background: var(--surface); border: 1px solid var(--border);
2545
+ border-radius: 9px; padding: 9px 11px; display: flex; flex-direction: column; gap: 6px;
2546
+ }
2547
+ .trace-label {
2548
+ font-family: var(--mono); font-size: 9.5px; letter-spacing: .12em;
2549
+ text-transform: uppercase; color: var(--ink-3);
2550
+ }
2551
+ .trace-body pre {
2552
+ margin: 0; font-family: var(--mono); font-size: 10.5px; line-height: 1.5;
2553
+ color: var(--ink-2); white-space: pre-wrap; word-break: break-word;
2554
+ max-height: 160px; overflow-y: auto;
2555
+ }
2556
+ .trace-body pre.raw { color: var(--ink); }
2557
+
2558
+ .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; }
2559
+ .statusline.err { color: var(--red); }
2560
+ .composer { display: flex; align-items: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
2561
+ .composer textarea {
2562
+ flex: 1; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
2563
+ background: var(--bg); border: 1px solid var(--border-strong); border-radius: 10px; padding: 9px 12px; outline: none;
2564
+ max-height: 140px; overflow-y: auto; transition: border-color .15s ease, box-shadow .15s ease;
2565
+ }
2566
+ .composer textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
2567
+ .composer textarea::placeholder { color: var(--ink-3); }
2568
+ .composer .go { flex-shrink: 0; width: 40px; height: 40px; border-radius: var(--radius-sm); 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); }
2569
+ .composer .go:hover { background: var(--blue-strong); }
2570
+ .composer .go:disabled { opacity: .45; cursor: not-allowed; }
2571
+ /* Stopping is not a destructive act and does not get a red button. The
2572
+ shape changes (arrow to square) and the fill drops back to a neutral
2573
+ surface, which is enough to say "this does something else now" without
2574
+ implying the turn in progress was a mistake. */
2575
+ .composer .go.stop { background: var(--surface-2); color: var(--ink-2); box-shadow: none; border: 1px solid var(--border-strong); }
2576
+ .composer .go.stop:hover { background: var(--surface); color: var(--ink); }
2577
+ .composer .go.stop svg { width: 15px; height: 15px; }
2578
+ /* A message the person sent, plus the row of things they can do to it.
2579
+ The wrapper carries the alignment the bubble used to carry on its own. */
2580
+ .turn { display: flex; flex-direction: column; align-items: flex-end; }
2581
+ .turn:focus { outline: none; }
2582
+ .turn .msg.user { align-self: flex-end; }
2583
+ .actions { display: flex; gap: 2px; padding: 2px 2px 0; }
2584
+ .act {
2585
+ display: grid; place-items: center; width: 26px; height: 26px; padding: 0;
2586
+ background: none; border: none; border-radius: 7px; cursor: pointer;
2587
+ color: var(--ink-3); transition: color .12s ease, background .12s ease;
2588
+ }
2589
+ .act:hover { color: var(--ink-2); background: var(--surface); }
2590
+ .act:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
2591
+ .act svg { width: 15px; height: 15px; display: block; }
2592
+ .act.done { color: var(--green-strong); }
2593
+
2594
+ /* Split on hover capability, not on width: a touch laptop needs the row
2595
+ visible and a narrow desktop window does not. Where there is no hover,
2596
+ the actions are simply there, because no gesture will reveal them. */
2597
+ .actions { opacity: .55; transition: opacity .13s ease; }
2598
+ @media (hover: hover) {
2599
+ .actions { opacity: 0; }
2600
+ .turn:hover .actions,
2601
+ .turn:focus-within .actions { opacity: 1; }
2602
+ }
2603
+ @media (prefers-reduced-motion: reduce) {
2604
+ .actions { transition: none; }
2605
+ }
2606
+
2607
+ .editing {
2608
+ align-self: flex-end; max-width: 84%; width: 84%;
2609
+ display: flex; flex-direction: column; gap: 7px;
2610
+ background: var(--bg); border: 1px solid var(--blue); border-radius: 12px; padding: 9px 10px;
2611
+ }
2612
+ .editing textarea {
2613
+ width: 100%; border: none; outline: none; resize: none; background: transparent;
2614
+ font-family: var(--sans); font-size: 13px; line-height: 1.5; color: var(--ink);
2615
+ }
2616
+ .edit-actions { display: flex; gap: 7px; justify-content: flex-end; }
2617
+ .ebtn {
2618
+ font-family: var(--sans); font-size: 12px; font-weight: 500; border-radius: 8px;
2619
+ padding: 5px 11px; cursor: pointer; border: 1px solid var(--border-strong);
2620
+ background: var(--bg); color: var(--ink-2);
2621
+ }
2622
+ .ebtn:hover { border-color: var(--blue); color: var(--blue-strong); }
2623
+ .ebtn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
2624
+ .ebtn.primary:hover { background: var(--blue-strong); color: #fff; }
2625
+ .edit-hint {
2626
+ font-family: var(--mono); font-size: 10px; letter-spacing: .05em;
2627
+ color: var(--ink-3); align-self: flex-end; padding-top: 2px;
2628
+ }
2629
+
2630
+ .stopped-note {
2631
+ align-self: flex-start;
2632
+ font-size: 12px;
2633
+ color: var(--ink-3);
2634
+ padding: 2px 0 6px;
2635
+ letter-spacing: .01em;
2636
+ }
2637
+ .composer .go svg { width: 17px; height: 17px; }
2638
+
2639
+ /* Attribution. Quiet enough to sit under a customer's own chat surface
2640
+ without competing with it, legible enough to be worth having. */
2641
+ .foot { display: flex; justify-content: center; padding: 0 20px 12px; }
2642
+ .powered {
2643
+ display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
2644
+ font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.1em;
2645
+ text-transform: uppercase; color: var(--ink-3);
2646
+ border-radius: var(--radius-pill); padding: 4px 10px;
2647
+ transition: color .15s ease, background .15s ease;
2648
+ }
2649
+ .powered:hover { color: var(--ink-2); background: var(--surface); }
2650
+ .powered:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
2651
+ .powered svg { width: 13px; height: auto; display: block; flex-shrink: 0; }
2652
+
2653
+ /* ── Inline generated form ──────────────────────────────────── */
2654
+ .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; }
2655
+ .genform-head { display: flex; flex-direction: column; gap: 3px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
2656
+ .gf-tool { font-family: var(--display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
2657
+ .gf-desc { font-size: 13px; color: var(--ink-2); }
2658
+ .field { display: flex; flex-direction: column; gap: 5px; }
2659
+ .field label { font-family: var(--mono); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
2660
+ .field .req { color: var(--blue); }
2661
+ .field input, .field select {
2662
+ font-family: var(--sans); font-size: 14px; color: var(--ink);
2663
+ background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 11px; outline: none;
2664
+ transition: border-color .15s ease, box-shadow .15s ease;
2665
+ }
2666
+ .field input:focus, .field select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
2667
+ .field input.invalid, .field select.invalid { border-color: var(--red); box-shadow: 0 0 0 3px rgba(185,28,28,.12); }
2668
+ .field .check { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--ink); }
2669
+ .field .check input { width: auto; }
2670
+ .genform-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
2671
+ /* Buttons — radius 10, weight 600; Action confirms a positive outcome,
2672
+ Secondary (ghost class) is the alternative path. */
2673
+ .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; }
2674
+ .btn svg { width: 16px; height: 16px; }
2675
+ .btn.ghost { background: var(--bg); border-color: var(--border-strong); color: var(--ink); }
2676
+ .btn.ghost:hover { border-color: var(--ink-3); }
2677
+ /*
2678
+ * Signal Blue, not Action Green. design.md §4 gives green to "confirm a
2679
+ * positive outcome", which is the submitted card's badge and eyebrow,
2680
+ * after the fact. The button is the main path before anything has
2681
+ * happened, and that is blue. Using green on both blurred "about to
2682
+ * happen" into "has happened".
2683
+ *
2684
+ * It also has to be blue to stay consistent with the A2UI cards:
2685
+ * --a2ui-color-primary drives their primary buttons AND their
2686
+ * ChoicePicker selection, so repointing it at green would have coloured
2687
+ * "Water Heaters" as a positive outcome rather than a choice.
2688
+ */
2689
+ .btn.primary { background: var(--blue); color: #fff; }
2690
+ .btn.primary:hover { background: var(--blue-strong); }
2691
+ .btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
2692
+ .genform-result { display: none; font-size: 13px; align-items: center; gap: 8px; padding: 2px; }
2693
+ .genform-result.error { display: flex; color: var(--red); }
2694
+ .genform-result svg { width: 16px; height: 16px; flex-shrink: 0; }
2695
+ .gf-empty { color: var(--ink-3); font-size: 14px; }
2696
+
2697
+ /* ── Inline detail (confirmation) card ──────────────────────── */
2698
+ .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; }
2699
+ .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; }
2700
+ .dc-badge svg { width: 22px; height: 22px; }
2701
+ .dc-eyebrow { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--green-strong); }
2702
+ .dc-title { font-family: var(--display); font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
2703
+ .dc-sub { font-size: 13px; color: var(--ink-2); margin-bottom: 6px; }
2704
+ .dc-list { margin: 6px 0 0; border-top: 1px solid var(--border); }
2705
+ /* Review is the before-picture of the same card. Green marks a positive
2706
+ outcome (design.md §2), and nothing has happened yet — so the pending
2707
+ state carries Signal Blue, the colour of the action about to be taken. */
2708
+ .dc-badge.review { background: var(--blue-tint); color: var(--blue); }
2709
+ .dc-eyebrow.review { color: var(--blue-strong); }
2710
+ .dc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
2711
+ .dc-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
2712
+ .dc-row dt { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); flex-shrink: 0; padding-top: 2px; }
2713
+ .dc-row dd { margin: 0; font-size: 14px; color: var(--ink); text-align: right; word-break: break-word; }
2714
+
2715
+ /* ── Inline result cards — feature-card pattern: tinted icon chip,
2716
+ tags, tight Manrope headline, one supporting line. ─────── */
2717
+ .msg.results { align-self: stretch; width: 100%; }
2718
+ .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; }
2719
+ .rc-empty { font-size: 13px; color: var(--ink-2); padding: 8px 0; }
2720
+ .rc-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
2721
+ .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; }
2722
+ .resultcard:hover { box-shadow: var(--shadow); transform: translateY(-1px); }
2723
+ .rc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
2724
+ .rc-chip { width: 44px; height: 44px; border-radius: 50%; background: var(--blue-tint); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
2725
+ .rc-chip svg, .rc-chip img { width: 21px; height: auto; display: block; }
2726
+ .rc-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
2727
+ .rc-tags .tag { font-size: 12px; padding: 3px 10px; }
2728
+ .rc-title { font-family: var(--display); font-size: 16px; font-weight: 800; color: var(--ink); line-height: 1.25; letter-spacing: -0.01em; }
2729
+ .rc-sub { font-size: 13px; line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
2730
+ .rc-list { margin: 10px 0 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; }
2731
+ .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; }
2732
+ .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-shrink: 0; padding-top: 2px; }
2733
+ .rc-row dd { margin: 0; color: var(--ink); text-align: right; word-break: break-word; }
2734
+ .rc-id { margin-top: 10px; min-width: 0; }
2735
+ .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; }
2736
+ .rc-row dd a { color: var(--blue-strong); text-decoration: none; }
2737
+ .rc-row dd a:hover { text-decoration: underline; }
2738
+
2739
+ /* ── Metrics panel (technical details on demand) ─────────────── */
2740
+ .msg.metrics { align-self: stretch; width: 100%; max-width: 100%; background: none; border: none; padding: 0; white-space: normal; }
2741
+ .metricscard { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }
2742
+ .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; }
2743
+ .metricscard summary::-webkit-details-marker { display: none; }
2744
+ .metricscard summary:hover { color: var(--blue-strong); }
2745
+ .metricscard[open] summary { border-bottom: 1px solid var(--border); }
2746
+ .mx-table { width: calc(100% - 32px); margin: 12px 16px; border-collapse: collapse; font-size: 12px; }
2747
+ .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); }
2748
+ .mx-table td { padding: 5px 8px 5px 0; border-bottom: 1px solid var(--border); color: var(--ink-2); }
2749
+ .mx-table th.num, .mx-table td.num { text-align: right; font-family: var(--mono); font-size: 11px; }
2750
+ .mx-table tr.total td { color: var(--ink); font-weight: 600; border-bottom: none; }
2751
+ .mx-compare { padding: 4px 16px 16px; display: flex; flex-direction: column; gap: 8px; }
2752
+ .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; }
2753
+ .mx-bar-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
2754
+ .mx-bar-label { font-family: var(--mono); font-size: 10px; color: var(--ink-3); width: 56px; flex-shrink: 0; }
2755
+ .mx-bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
2756
+ .mx-bar-track i { display: block; height: 100%; border-radius: 4px; }
2757
+ .mx-bar-track i.mcp { background: var(--blue); }
2758
+ .mx-bar-track i.rag { background: var(--ink-3); }
2759
+ .mx-bar-value { font-family: var(--mono); font-size: 11px; color: var(--ink-2); width: 64px; text-align: right; flex-shrink: 0; }
2760
+ .mx-savings { align-self: flex-start; font-size: 12px; font-weight: 600; border-radius: var(--radius-pill); padding: 4px 12px; margin-top: 2px; }
2761
+ .mx-savings.pos { color: var(--green-strong); background: var(--green-tint); }
2762
+ .mx-savings.neg { color: var(--red); background: #FEF2F2; }
2763
+ .mx-note { font-size: 11px; line-height: 1.5; color: var(--ink-3); }
2764
+
2765
+ @media (max-width: 840px) {
2766
+ .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); }
2767
+ .overlay.nav .side { transform: none; }
2768
+ /* Under the drawer, over the conversation: the strip of thread still
2769
+ showing beside the drawer is what a tap to dismiss lands on. */
2770
+ .overlay.nav .scrim {
2771
+ display: block; position: absolute; inset: 0; z-index: 5;
2772
+ background: rgba(15,23,42,.32);
2773
+ animation: nx-scrim-in .2s ease;
2774
+ }
2775
+ @keyframes nx-scrim-in { from { opacity: 0; } to { opacity: 1; } }
2776
+ @media (prefers-reduced-motion: reduce) {
2777
+ .side { transition: none; }
2778
+ .overlay.nav .scrim { animation: none; }
2779
+ }
2780
+ .menu { display: flex; }
2781
+ .msg { max-width: 86%; }
2782
+ .hist-del { opacity: 1; }
2783
+ }
2784
+ </style>
2785
+ <button class="launcher" title="Open ${escapeHtml(this._name)}" aria-label="Open ${escapeHtml(this._name)}">${this._logoMarkup()}</button>
2786
+ <div class="overlay" role="dialog" aria-label="${escapeHtml(this._name)}">
2787
+ <div class="scrim" hidden></div>
2788
+ <aside class="side">
2789
+ <div class="side-head">
2790
+ <span class="brand">${this._logoMarkup()}</span>
2791
+ <span class="side-title">Conversations</span>
2792
+ <button class="newchat" title="New conversation" aria-label="New conversation">${ICON_PLUS}</button>
2793
+ </div>
2794
+ <div class="history"></div>
2795
+ </aside>
2796
+ <main class="main">
2797
+ <div class="main-head">
2798
+ <button class="menu" aria-label="Toggle conversations">${ICON_MENU}</button>
2799
+ <span class="head-mark"><span class="head-ring" hidden></span><span class="brand">${this._logoMarkup()}</span></span>
2800
+ <h2>${escapeHtml(this._name)}</h2>
2801
+ <span class="tag blue provenance"><i class="dot"></i>On-device</span>
2802
+ <button class="close" aria-label="Close">×</button>
2803
+ </div>
2804
+ <div class="prog"><i></i></div>
2805
+ <div class="thread"></div>
2806
+ <div class="statusline"></div>
2807
+ <div class="composer">
2808
+ <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
2809
+ <button class="go" aria-label="Send">${ICON_ARROW}</button>
2810
+ </div>
2811
+ <footer class="foot">
2812
+ <a class="powered" href="${NXIS_SITE_URL}" target="_blank" rel="noopener noreferrer">
2813
+ ${NXIS_LOGO_SVG}<span>Powered by NxisAI</span>
2814
+ </a>
2815
+ </footer>
2816
+ </main>
2817
+ </div>`;
2818
+ }
2819
+ }
2820
+ customElements.define('nxis-agent-fullscreen', NxisAgentFullscreen);
2821
+ //# sourceMappingURL=agent-fullscreen.js.map