@nxisai/agent-harness 0.2.4 → 0.4.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 (83) hide show
  1. package/README.md +90 -2
  2. package/dist/a2ui/stepper.d.ts +1 -1
  3. package/dist/a2ui/stepper.d.ts.map +1 -1
  4. package/dist/a2ui/stepper.js +2 -2
  5. package/dist/a2ui/stepper.js.map +1 -1
  6. package/dist/engine.d.ts +1 -0
  7. package/dist/engine.d.ts.map +1 -1
  8. package/dist/engine.js +1 -0
  9. package/dist/engine.js.map +1 -1
  10. package/dist/harness/harness.d.ts +58 -0
  11. package/dist/harness/harness.d.ts.map +1 -1
  12. package/dist/harness/harness.js +179 -4
  13. package/dist/harness/harness.js.map +1 -1
  14. package/dist/harness/renderer.d.ts +30 -0
  15. package/dist/harness/renderer.d.ts.map +1 -1
  16. package/dist/harness/steps.d.ts +101 -0
  17. package/dist/harness/steps.d.ts.map +1 -0
  18. package/dist/harness/steps.js +102 -0
  19. package/dist/harness/steps.js.map +1 -0
  20. package/dist/harness/tools/action-tools.d.ts +213 -0
  21. package/dist/harness/tools/action-tools.d.ts.map +1 -0
  22. package/dist/harness/tools/action-tools.js +724 -0
  23. package/dist/harness/tools/action-tools.js.map +1 -0
  24. package/dist/harness/tools/bridge.d.ts.map +1 -1
  25. package/dist/harness/tools/bridge.js +28 -2
  26. package/dist/harness/tools/bridge.js.map +1 -1
  27. package/dist/harness/tools/open-page.d.ts +42 -0
  28. package/dist/harness/tools/open-page.d.ts.map +1 -0
  29. package/dist/harness/tools/open-page.js +118 -0
  30. package/dist/harness/tools/open-page.js.map +1 -0
  31. package/dist/harness/tools/pending-action.d.ts +99 -0
  32. package/dist/harness/tools/pending-action.d.ts.map +1 -0
  33. package/dist/harness/tools/pending-action.js +175 -0
  34. package/dist/harness/tools/pending-action.js.map +1 -0
  35. package/dist/harness/tools/resume.d.ts +92 -0
  36. package/dist/harness/tools/resume.d.ts.map +1 -0
  37. package/dist/harness/tools/resume.js +84 -0
  38. package/dist/harness/tools/resume.js.map +1 -0
  39. package/dist/harness/tools/schema.js +29 -2
  40. package/dist/harness/tools/schema.js.map +1 -1
  41. package/dist/harness/tools/site-actions.d.ts +81 -0
  42. package/dist/harness/tools/site-actions.d.ts.map +1 -0
  43. package/dist/harness/tools/site-actions.js +314 -0
  44. package/dist/harness/tools/site-actions.js.map +1 -0
  45. package/dist/harness/tools/site-pages.d.ts +95 -0
  46. package/dist/harness/tools/site-pages.d.ts.map +1 -0
  47. package/dist/harness/tools/site-pages.js +199 -0
  48. package/dist/harness/tools/site-pages.js.map +1 -0
  49. package/dist/harness/tools/source.d.ts +58 -3
  50. package/dist/harness/tools/source.d.ts.map +1 -1
  51. package/dist/harness/tools/source.js +88 -7
  52. package/dist/harness/tools/source.js.map +1 -1
  53. package/dist/index.d.ts +14 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +7 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/providers/proxy.d.ts +5 -0
  58. package/dist/providers/proxy.d.ts.map +1 -1
  59. package/dist/providers/proxy.js +16 -1
  60. package/dist/providers/proxy.js.map +1 -1
  61. package/dist/providers/types.d.ts +15 -0
  62. package/dist/providers/types.d.ts.map +1 -1
  63. package/dist/types.d.ts +7 -2
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  66. package/dist/ui/agent-fullscreen.js +835 -36
  67. package/dist/ui/agent-fullscreen.js.map +1 -1
  68. package/dist/ui/index.d.ts +2 -0
  69. package/dist/ui/index.d.ts.map +1 -1
  70. package/dist/ui/index.js +1 -0
  71. package/dist/ui/index.js.map +1 -1
  72. package/dist/ui/journey.d.ts +33 -0
  73. package/dist/ui/journey.d.ts.map +1 -0
  74. package/dist/ui/journey.js +27 -0
  75. package/dist/ui/journey.js.map +1 -0
  76. package/dist/ui/tailwind.generated.d.ts +1 -1
  77. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  78. package/dist/ui/tailwind.generated.js +1 -1
  79. package/dist/ui/tailwind.generated.js.map +1 -1
  80. package/dist/webmcp-contract.d.ts +6 -2
  81. package/dist/webmcp-contract.d.ts.map +1 -1
  82. package/docs/remote-providers.md +7 -1
  83. package/package.json +1 -1
@@ -34,6 +34,13 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
34
34
  import { WebMcpTools } from '../harness/tools/webmcp.js';
35
35
  import { extractResult, missingRequired, toolSchema, } from '../harness/tools/schema.js';
36
36
  import { connectTools } from '../harness/tools/source.js';
37
+ import { handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
38
+ import { resumePendingAction } from '../harness/tools/resume.js';
39
+ import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
40
+ import { pageForEntity } from '../harness/tools/site-pages.js';
41
+ import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
42
+ import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
43
+ import { viewJourney } from './journey.js';
37
44
  import { AgentCoordinator } from '../harness/router/coordinator.js';
38
45
  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
46
  import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
@@ -82,6 +89,103 @@ function formatBytes(n) {
82
89
  return `${Math.round(mb)} MB`;
83
90
  }
84
91
  const uid = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
92
+ /**
93
+ * Names for the retrieval tools the package itself bridges in.
94
+ *
95
+ * `nxis_query` is a fine name for a model and a poor one for a visitor, and a
96
+ * native registry may strip any title we attach, so the few tools this package
97
+ * registers are named here.
98
+ */
99
+ const FRIENDLY_TOOL_NAMES = {
100
+ nxis_query: 'Search records',
101
+ nxis_get: 'Get a record',
102
+ nxis_list_types: 'List record types',
103
+ };
104
+ const svg = (body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
105
+ /** A plug: the tools the agent can reach. */
106
+ const ICON_TOOLS = svg('<path d="M7 3v4M13 3v4M5 7h10v3a5 5 0 0 1-10 0V7ZM10 15v3"/>');
107
+ const ICON_LOCK = svg('<rect x="4.5" y="9" width="11" height="8" rx="2"/><path d="M7 9V6.5a3 3 0 0 1 6 0V9"/>');
108
+ const ICON_FORM = svg('<rect x="4" y="3.5" width="12" height="13" rx="2"/><path d="M7 8h6M7 11h6M7 14h3"/>');
109
+ const ICON_SEARCH = svg('<circle cx="9" cy="9" r="4.8"/><path d="m13 13 3.5 3.5"/>');
110
+ const ICON_GLOBE = svg('<circle cx="10" cy="10" r="6.5"/><path d="M3.5 10h13M10 3.5c2 2 2 11 0 13M10 3.5c-2 2-2 11 0 13"/>');
111
+ /**
112
+ * Split a message into prose and code.
113
+ *
114
+ * Two things read as code: a fenced block, and a message that is nothing but
115
+ * JSON — which is what a tool result looks like when something hands one back
116
+ * raw. Wrapped as prose it becomes a wall of text that breaks at the wrong
117
+ * places, taking any URL inside it with it.
118
+ *
119
+ * @param text - The message.
120
+ * @returns The parts in order, each marked as code or not.
121
+ */
122
+ function splitCode(text) {
123
+ const fenced = text.split(/```[a-zA-Z]*\n?/);
124
+ // An opening and a closing fence produce at least three parts; the odd ones
125
+ // are what sat between them.
126
+ if (fenced.length > 2) {
127
+ return fenced.map((part, i) => ({ text: i % 2 ? part.replace(/\n$/, '') : part, code: i % 2 === 1 }));
128
+ }
129
+ const trimmed = text.trim();
130
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
131
+ try {
132
+ return [{ text: JSON.stringify(JSON.parse(trimmed), null, 2), code: true }];
133
+ }
134
+ catch {
135
+ // Starts like JSON without being it — ordinary prose.
136
+ }
137
+ }
138
+ return [{ text, code: false }];
139
+ }
140
+ /** Machine output rather than something someone wrote: JSON, most often raw. */
141
+ function looksMachine(text) {
142
+ const trimmed = text.trim();
143
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
144
+ return false;
145
+ try {
146
+ JSON.parse(trimmed);
147
+ return true;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /** Longer than this, a block is folded away rather than filling the thread. */
154
+ const FOLD_LINES = 10;
155
+ /**
156
+ * Write a message into its bubble, keeping code as code.
157
+ *
158
+ * A long block is folded behind a summary: output worth keeping, but not
159
+ * worth scrolling past every time. Everything is written as text rather than
160
+ * markup — a message may carry a tool's output, which is not ours to trust
161
+ * as HTML.
162
+ */
163
+ function fillMessage(el, text) {
164
+ for (const part of splitCode(text)) {
165
+ if (part.code) {
166
+ const pre = document.createElement('pre');
167
+ pre.className = 'codeblock';
168
+ const code = document.createElement('code');
169
+ code.textContent = part.text;
170
+ pre.appendChild(code);
171
+ const lines = part.text.split('\n').length;
172
+ if (lines > FOLD_LINES) {
173
+ const fold = document.createElement('details');
174
+ fold.className = 'codefold';
175
+ const summary = document.createElement('summary');
176
+ summary.textContent = `${lines} lines`;
177
+ fold.append(summary, pre);
178
+ el.appendChild(fold);
179
+ }
180
+ else {
181
+ el.appendChild(pre);
182
+ }
183
+ }
184
+ else if (part.text) {
185
+ el.appendChild(document.createTextNode(part.text));
186
+ }
187
+ }
188
+ }
85
189
  class NxisAgentFullscreen extends HTMLElement {
86
190
  constructor() {
87
191
  super();
@@ -95,6 +199,21 @@ class NxisAgentFullscreen extends HTMLElement {
95
199
  * on, the re-run would race `_onSend`'s own guard and be dropped.
96
200
  */
97
201
  this._turnDone = null;
202
+ /** Stops listening to the journal. */
203
+ this._offJourney = null;
204
+ /** Whether the tools menu is showing. */
205
+ this._toolsOpen = false;
206
+ /** Closes the tools menu on a press outside it. An arrow, so it can be removed. */
207
+ this._onOutsidePress = (e) => {
208
+ if (!this._toolsOpen)
209
+ return;
210
+ const path = e.composedPath();
211
+ if (path.includes(this._els.toolsPop) || path.includes(this._els.toolsBtn))
212
+ return;
213
+ this._toggleTools(false);
214
+ };
215
+ /** Watches whether the running journey's card is on screen. */
216
+ this._journeyObserver = null;
98
217
  /** Built only when the host defines agents — otherwise there is one harness. */
99
218
  this._coordinator = null;
100
219
  this._agentDefs = null;
@@ -115,6 +234,8 @@ class NxisAgentFullscreen extends HTMLElement {
115
234
  this._stage = '';
116
235
  /** The one discovery run, kicked off on first open and awaited before a turn. */
117
236
  this._connect = null;
237
+ /** The site's pages, for linking results to where they came from. */
238
+ this._sitePages = [];
118
239
  this._loadPct = 0;
119
240
  /**
120
241
  * Which mode the empty state is currently drawn in, or null when a
@@ -157,9 +278,15 @@ class NxisAgentFullscreen extends HTMLElement {
157
278
  thread: root.querySelector('.thread'),
158
279
  status: root.querySelector('.statusline'),
159
280
  bar: root.querySelector('.prog > i'),
281
+ jstatus: root.querySelector('.jstatus'),
160
282
  input: root.querySelector('.composer textarea'),
161
283
  go: root.querySelector('.composer .go'),
284
+ toolsBtn: root.querySelector('.tools-btn'),
285
+ toolsPop: root.querySelector('.tools-pop'),
286
+ toolsCount: root.querySelector('.tools-count'),
287
+ model: root.querySelector('.composer .model'),
162
288
  };
289
+ this._applyMode();
163
290
  // A host-supplied provider wins; otherwise the local model, configured from
164
291
  // the attributes that only mean anything to it.
165
292
  this.provider =
@@ -203,6 +330,34 @@ class NxisAgentFullscreen extends HTMLElement {
203
330
  this._loadConversations();
204
331
  this._renderHistory();
205
332
  this._renderThread();
333
+ // Journey cards follow the journal rather than the renderer, because the
334
+ // steps that finish a cross-page action are recorded by the resume below,
335
+ // before any turn — and so before any renderer call — exists on this page.
336
+ this._offJourney?.();
337
+ this._offJourney = onJourneyChange((_step, all) => this._onJourney(all));
338
+ this._els.toolsBtn.addEventListener('click', () => this._toggleTools());
339
+ this._els.toolsPop.addEventListener('keydown', (e) => {
340
+ if (e.key !== 'Escape')
341
+ return;
342
+ e.stopPropagation();
343
+ this._toggleTools(false);
344
+ this._els.toolsBtn.focus();
345
+ });
346
+ // Outside the menu closes it. On the document, with the composed path,
347
+ // so a press anywhere on the host page counts as outside too.
348
+ document.removeEventListener('pointerdown', this._onOutsidePress);
349
+ document.addEventListener('pointerdown', this._onOutsidePress);
350
+ this._els.jstatus.addEventListener('click', () => {
351
+ const cards = this._els.thread.querySelectorAll('.journey.is-running');
352
+ cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
353
+ });
354
+ // Whoever builds the tools, a call that leaves this page reports back to
355
+ // the thread it was made in.
356
+ setConversationSource(() => this._activeId ?? undefined);
357
+ // A form confirmed on another page submits here, now that the page it
358
+ // belongs to is the one open.
359
+ void this._resumePendingAction();
360
+ this._resumePendingVisit();
206
361
  this._els.launcher.addEventListener('click', () => this._open());
207
362
  this._els.close.addEventListener('click', () => {
208
363
  this._els.overlay.classList.remove('show');
@@ -278,6 +433,14 @@ class NxisAgentFullscreen extends HTMLElement {
278
433
  if (!open)
279
434
  this._els.menu.focus();
280
435
  }
436
+ disconnectedCallback() {
437
+ document.removeEventListener('pointerdown', this._onOutsidePress);
438
+ // Moved or removed: nothing left on screen to keep in step with the journal.
439
+ this._offJourney?.();
440
+ this._offJourney = null;
441
+ this._journeyObserver?.disconnect();
442
+ this._journeyObserver = null;
443
+ }
281
444
  /** Programmatic open (used by pages that dock the agent full-page). */
282
445
  open() {
283
446
  this._open();
@@ -457,8 +620,11 @@ class NxisAgentFullscreen extends HTMLElement {
457
620
  this._els.thread.appendChild(this._resultsEl(it));
458
621
  else if (it.kind === 'metrics')
459
622
  this._els.thread.appendChild(this._metricsEl(it));
623
+ else if (it.kind === 'journey')
624
+ this._els.thread.appendChild(this._journeyEl(it));
460
625
  }
461
626
  this._scroll();
627
+ this._watchRunningJourney();
462
628
  }
463
629
  _clearEmpty() {
464
630
  const e = this._els.thread.querySelector('.empty');
@@ -484,7 +650,7 @@ class NxisAgentFullscreen extends HTMLElement {
484
650
  ? 'self-end bg-blue text-white rounded-2xl rounded-br-md'
485
651
  : 'self-start bg-surface text-ink border border-border-hair rounded-2xl rounded-bl-md shadow-resting';
486
652
  el.className = `msg ${role} ${base} ${byRole}`;
487
- el.textContent = text;
653
+ fillMessage(el, text);
488
654
  return el;
489
655
  }
490
656
  /**
@@ -502,15 +668,27 @@ class NxisAgentFullscreen extends HTMLElement {
502
668
  (isError
503
669
  ? 'bg-surface border-red/25 text-red'
504
670
  : '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>`;
671
+ const head = document.createElement('b');
672
+ head.className =
673
+ 'flex items-center gap-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em]';
674
+ head.innerHTML = `${isError ? ICON_WARN : ICON_CHECK}${escapeHtml(name)}`;
675
+ const body = document.createElement('div');
676
+ body.className = 'mt-1 font-sans text-[13px] leading-normal text-ink-2';
677
+ // A tool's own sentence is worth showing; its raw payload is for whoever
678
+ // is building the agent, and lives in the trace, which `debug` governs.
679
+ const shown = this.debug || !looksMachine(text)
680
+ ? text
681
+ : `${humanize(name)} could not be completed.`;
682
+ fillMessage(body, shown);
683
+ el.append(head, body);
509
684
  return el;
510
685
  }
511
686
  _detailEl(item) {
512
687
  const rows = (item.rows || [])
513
- .map((r) => `<div class="dc-row"><dt>${escapeHtml(r.label)}</dt><dd>${escapeHtml(r.value)}</dd></div>`)
688
+ .map((r) =>
689
+ // Focusable, so a keyboard can open a truncated row the way a pointer does.
690
+ `<div class="dc-row" tabindex="0"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
691
+ `<dd title="${escapeHtml(r.value)}">${escapeHtml(r.value)}</dd></div>`)
514
692
  .join('');
515
693
  const el = document.createElement('div');
516
694
  el.className = 'msg detail';
@@ -727,6 +905,133 @@ class NxisAgentFullscreen extends HTMLElement {
727
905
  el.appendChild(body);
728
906
  return el;
729
907
  }
908
+ /**
909
+ * A journey as a card in the thread.
910
+ *
911
+ * Open while it runs, so the step being taken is on screen; folded to one
912
+ * line once it finishes, so the thread reads as a record rather than a wall
913
+ * of finished checklists. A `<details>` either way, so a folded journey can
914
+ * still be opened.
915
+ */
916
+ _journeyEl(item) {
917
+ const view = viewJourney(item.steps);
918
+ const el = document.createElement('details');
919
+ el.className = `msg journey is-${view?.outcome ?? 'running'}`;
920
+ el.dataset.journey = item.id;
921
+ el.open = view?.outcome === 'running';
922
+ if (view?.active)
923
+ el.dataset.active = view.active.label;
924
+ const summary = document.createElement('summary');
925
+ const mark = document.createElement('span');
926
+ mark.className = 'j-sum-mark';
927
+ mark.setAttribute('aria-hidden', 'true');
928
+ if (view?.outcome === 'done')
929
+ mark.textContent = '✓';
930
+ if (view?.outcome === 'failed')
931
+ mark.textContent = '!';
932
+ const title = document.createElement('span');
933
+ title.className = 'j-title';
934
+ title.textContent = view?.title ?? 'Working on it';
935
+ const state = document.createElement('span');
936
+ state.className = 'j-state';
937
+ // Running, the header says what is happening now; finished, it says how
938
+ // it ended, which is the one thing a folded card has to carry.
939
+ state.textContent =
940
+ view?.outcome === 'running' ? (view.active?.label ?? '') : (view?.summary.split(' · ')[1] ?? '');
941
+ summary.append(mark, title, state);
942
+ el.appendChild(summary);
943
+ const list = document.createElement('ol');
944
+ list.className = 'j-steps';
945
+ (view?.steps ?? []).forEach((step, i) => {
946
+ const li = document.createElement('li');
947
+ li.className = `j-step is-${step.status}`;
948
+ const m = document.createElement('span');
949
+ m.className = 'j-mark';
950
+ m.textContent = step.status === 'done' ? '✓' : step.status === 'failed' ? '!' : String(i + 1);
951
+ const label = document.createElement('span');
952
+ label.className = 'j-label';
953
+ // Text throughout: a label can carry a business name from the site's data.
954
+ label.textContent = step.label;
955
+ label.title = step.detail ? `${step.label} — ${step.detail}` : step.label;
956
+ if (step.detail) {
957
+ const small = document.createElement('small');
958
+ small.textContent = step.detail;
959
+ label.appendChild(small);
960
+ }
961
+ li.append(m, label);
962
+ list.appendChild(li);
963
+ });
964
+ el.appendChild(list);
965
+ return el;
966
+ }
967
+ /**
968
+ * Keep a journey's card, and its record in the conversation, up to date.
969
+ *
970
+ * Looks for the journey in every conversation before assuming it is new:
971
+ * on arrival after a page change the resume records steps before the
972
+ * conversation they came from has been reopened, and those steps belong
973
+ * with the card saved on the page before, not in whichever thread is open.
974
+ */
975
+ _onJourney(all) {
976
+ const view = viewJourney(all);
977
+ if (!view?.id)
978
+ return;
979
+ const isThis = (it) => it.kind === 'journey' && it.id === view.id;
980
+ let conv = this._conversations.find((c) => c.items.some(isThis));
981
+ let item = conv?.items.find(isThis);
982
+ if (conv && item) {
983
+ item.steps = all;
984
+ }
985
+ else {
986
+ conv = this._active() ?? undefined;
987
+ if (!conv)
988
+ return;
989
+ item = { kind: 'journey', id: view.id, steps: all };
990
+ conv.items.push(item);
991
+ }
992
+ if (conv.id === this._activeId && this._els?.thread) {
993
+ const fresh = this._journeyEl(item);
994
+ const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
995
+ if (old) {
996
+ // Keep a folded card open if someone opened it to read.
997
+ if (old.open && view.outcome !== 'running')
998
+ fresh.open = true;
999
+ old.replaceWith(fresh);
1000
+ }
1001
+ else {
1002
+ this._clearEmpty();
1003
+ this._els.thread.appendChild(fresh);
1004
+ this._scroll();
1005
+ }
1006
+ this._watchRunningJourney();
1007
+ }
1008
+ this._saveConversations();
1009
+ }
1010
+ /**
1011
+ * Show the status line only while the running journey is out of view.
1012
+ *
1013
+ * The card is where the steps are; the line exists for the moment a long
1014
+ * reply pushes it off the top of the thread mid-action, and hides again as
1015
+ * soon as the card is back.
1016
+ */
1017
+ _watchRunningJourney() {
1018
+ this._journeyObserver?.disconnect();
1019
+ this._journeyObserver = null;
1020
+ const bar = this._els?.jstatus;
1021
+ if (!bar)
1022
+ return;
1023
+ const cards = this._els.thread.querySelectorAll('.journey.is-running');
1024
+ const card = cards[cards.length - 1];
1025
+ if (!card || typeof IntersectionObserver === 'undefined') {
1026
+ bar.hidden = true;
1027
+ return;
1028
+ }
1029
+ bar.querySelector('.js-label').textContent = card.dataset.active ?? '';
1030
+ this._journeyObserver = new IntersectionObserver((entries) => {
1031
+ bar.hidden = entries[entries.length - 1].isIntersecting;
1032
+ }, { root: this._els.thread, threshold: 0.2 });
1033
+ this._journeyObserver.observe(card);
1034
+ }
730
1035
  _paintToolResult(name, text, isError) {
731
1036
  this._clearEmpty();
732
1037
  const el = this._toolEl(name, text, isError);
@@ -771,6 +1076,8 @@ class NxisAgentFullscreen extends HTMLElement {
771
1076
  go.classList.toggle('stop', canStop);
772
1077
  go.innerHTML = canStop ? ICON_STOP : ICON_ARROW;
773
1078
  go.setAttribute('aria-label', canStop ? 'Stop' : 'Send');
1079
+ // The model's dot pulses while it is working, so the two controls agree.
1080
+ this._els.model.classList.toggle('busy', canStop);
774
1081
  // Enabled either way: disabling it during a turn would disable the very
775
1082
  // control needed to stop that turn.
776
1083
  go.disabled = false;
@@ -901,6 +1208,9 @@ class NxisAgentFullscreen extends HTMLElement {
901
1208
  this._syncProfileContext();
902
1209
  // One agent, or several with a router in front: the signatures match.
903
1210
  await (this._coordinator ?? this._harness).turn(goal, conv.messages);
1211
+ // A remote endpoint reports which model answered; the name beside the
1212
+ // input follows it rather than keeping whatever was configured.
1213
+ this._paintProvenance();
904
1214
  }
905
1215
  catch (err) {
906
1216
  // A cancelled turn has already reported itself in the thread, and is not
@@ -1059,8 +1369,17 @@ class NxisAgentFullscreen extends HTMLElement {
1059
1369
  this._clearEmpty();
1060
1370
  this._dismissPrompt();
1061
1371
  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>`)
1372
+ .map((sl) => {
1373
+ const label = escapeHtml(sl.schema.description || humanize(sl.name));
1374
+ const value = escapeHtml(String(sl.value ?? ''));
1375
+ return `<div class="dc-row" tabindex="0"><dt title="${label}">${label}</dt><dd title="${value}">${value}</dd></div>`;
1376
+ })
1063
1377
  .join('');
1378
+ // Where pressing submit leads, when that is another page. Said here in a
1379
+ // sentence so the button itself can stay short enough for a narrow card.
1380
+ const sub = req.destination
1381
+ ? `Submitting opens the page for ${escapeHtml(req.destination)} and sends it from there.`
1382
+ : 'Review these details, then submit.';
1064
1383
  const wrap = document.createElement('div');
1065
1384
  wrap.className = 'msg detail';
1066
1385
  wrap.innerHTML = `
@@ -1068,12 +1387,12 @@ class NxisAgentFullscreen extends HTMLElement {
1068
1387
  <div class="dc-badge review">${ICON_ARROW}</div>
1069
1388
  <div class="dc-eyebrow review">Ready to send</div>
1070
1389
  <div class="dc-title">${escapeHtml(humanize(req.tool.name))}</div>
1071
- <div class="dc-sub">Review these details, then submit.</div>
1390
+ <div class="dc-sub">${sub}</div>
1072
1391
  <dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
1073
1392
  <div class="dc-actions">
1074
1393
  <button type="button" class="btn ghost" data-act="cancel">Cancel</button>
1075
1394
  <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>
1395
+ <button type="button" class="btn primary" data-act="submit">${escapeHtml(req.submitLabel ?? 'Submit')} ${ICON_ARROW}</button>
1077
1396
  </div>
1078
1397
  </div>`;
1079
1398
  wrap.querySelector('[data-act="cancel"]').addEventListener('click', () => req.onCancel());
@@ -1281,6 +1600,9 @@ class NxisAgentFullscreen extends HTMLElement {
1281
1600
  submitBtn.disabled = true;
1282
1601
  result.className = 'genform-result';
1283
1602
  result.textContent = 'Submitting…';
1603
+ // Read before the call: a registry may return only the response's text,
1604
+ // and the annotations already say whether this call leaves the page.
1605
+ const leaving = handoffFor(tool, values);
1284
1606
  try {
1285
1607
  this._setStatus(`Running ${tool.name}…`);
1286
1608
  const res = await this.toolSource.execTool(tool, tool.name, values);
@@ -1293,6 +1615,18 @@ class NxisAgentFullscreen extends HTMLElement {
1293
1615
  submitBtn.disabled = false;
1294
1616
  return;
1295
1617
  }
1618
+ // A call that left for another page has filled in nothing yet, so it
1619
+ // must not become a "Submitted" card. The page it is opening finishes
1620
+ // the job and draws the card there.
1621
+ if (leaving || res?.structuredContent?.[HANDOFF_RESULT]) {
1622
+ this._dismissPrompt();
1623
+ // From what we know, not from the response: a registry may hand back
1624
+ // the whole response object as JSON.
1625
+ this._appendMsg('assistant', leaving
1626
+ ? `Opening ${leaving.label} to submit this…`
1627
+ : 'Opening the page that can submit this…');
1628
+ return;
1629
+ }
1296
1630
  // Success → the form transitions inline. A write tool (form/action)
1297
1631
  // becomes a "Submitted" detail card; a read-only tool (typed retrieval)
1298
1632
  // becomes result cards for the records it returned.
@@ -1493,7 +1827,9 @@ class NxisAgentFullscreen extends HTMLElement {
1493
1827
  }
1494
1828
  }
1495
1829
  const id = entity['@id'] != null ? str(entity['@id']) : undefined;
1496
- return { title, type, rating, emergency, sub, rows, id };
1830
+ const found = pageForEntity(entity, this._sitePages, location.origin);
1831
+ const page = found ? { url: found.url, label: found.name } : undefined;
1832
+ return { title, type, rating, emergency, sub, rows, id, ...(page ? { page } : {}) };
1497
1833
  }
1498
1834
  _buildResultsItem(tool, res) {
1499
1835
  const items = this._extractItems(res);
@@ -1575,7 +1911,8 @@ class NxisAgentFullscreen extends HTMLElement {
1575
1911
  const cardsHtml = item.cards
1576
1912
  .map((c) => {
1577
1913
  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>`)
1914
+ .map((r) => `<div class="rc-row"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
1915
+ `<dd title="${escapeHtml(r.value)}">${r.href ? `<a href="${escapeHtml(r.href)}">${escapeHtml(r.value)}</a>` : escapeHtml(r.value)}</dd></div>`)
1579
1916
  .join('');
1580
1917
  const tags = (c.type ? `<span class="tag blue"><i class="dot"></i>${escapeHtml(c.type)}</span>` : '') +
1581
1918
  (c.rating ? `<span class="tag green"><i class="dot"></i>${escapeHtml(c.rating)}</span>` : '') +
@@ -1585,14 +1922,26 @@ class NxisAgentFullscreen extends HTMLElement {
1585
1922
  <div class="rc-chip">${this._logoMarkup()}</div>
1586
1923
  <div class="rc-tags">${tags}</div>
1587
1924
  </div>
1588
- <div class="rc-title">${escapeHtml(c.title)}</div>
1589
- ${c.sub ? `<div class="rc-sub">${escapeHtml(c.sub)}</div>` : ''}
1925
+ <div class="rc-title" title="${escapeHtml(c.title)}">${escapeHtml(c.title)}</div>
1926
+ ${c.sub ? `<div class="rc-sub" title="${escapeHtml(c.sub)}">${escapeHtml(c.sub)}</div>` : ''}
1590
1927
  ${rows ? `<dl class="rc-list">${rows}</dl>` : ''}
1591
1928
  ${c.id ? `<div class="rc-id"><code>${escapeHtml(c.id)}</code></div>` : ''}
1929
+ ${c.page
1930
+ ? `<a class="rc-open" href="${escapeHtml(c.page.url)}" data-label="${escapeHtml(c.page.label)}" ` +
1931
+ `title="${escapeHtml(c.page.url)}">Open page<span aria-hidden="true">→</span></a>`
1932
+ : ''}
1592
1933
  </div>`;
1593
1934
  })
1594
1935
  .join('');
1595
1936
  wrap.innerHTML = `<div class="rc-head">${escapeHtml(item.note)}</div><div class="rc-cards">${cardsHtml}</div>`;
1937
+ // An ordinary link, so it opens in a new tab when asked to. Followed in
1938
+ // this one, the conversation goes with it.
1939
+ wrap.addEventListener('click', (e) => {
1940
+ const link = e.target.closest('a.rc-open');
1941
+ if (!link || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
1942
+ return;
1943
+ savePendingVisit({ pageUrl: link.href, label: link.dataset.label ?? '', conversationId: this._activeId ?? undefined });
1944
+ });
1596
1945
  return wrap;
1597
1946
  }
1598
1947
  // ── Metrics panel (real Gemma tokenizer counts vs a RAG estimate) ──────────
@@ -1997,12 +2346,118 @@ class NxisAgentFullscreen extends HTMLElement {
1997
2346
  el.textContent = `routed to ${item.agent} · ${item.via}`;
1998
2347
  return el;
1999
2348
  }
2349
+ /**
2350
+ * Submit a form that was confirmed on another page.
2351
+ *
2352
+ * An action runs by filling the form on its own page, so a call made
2353
+ * elsewhere was confirmed there and carried here. The values were already
2354
+ * reviewed and pressed, which is why this submits rather than asking again —
2355
+ * the button that started it said it would open this page and submit.
2356
+ *
2357
+ * Nothing waiting is the ordinary case, and costs a single storage read.
2358
+ */
2359
+ /**
2360
+ * Continue the conversation that opened this page, and say it is open.
2361
+ *
2362
+ * Reached when the agent opened it or a result card's link was followed.
2363
+ * The thread is the one the visitor was in: by id, or failing that the one
2364
+ * holding the journey that left, so a visit saved without an id still
2365
+ * lands where it started rather than in whichever thread loaded first.
2366
+ */
2367
+ _resumePendingVisit() {
2368
+ const visit = takePendingVisit();
2369
+ if (!visit)
2370
+ return;
2371
+ const journey = visit.steps?.find((s) => s.journey)?.journey;
2372
+ const conv = this._conversations.find((c) => c.id === visit.conversationId) ??
2373
+ (journey
2374
+ ? this._conversations.find((c) => c.items.some((it) => it.kind === 'journey' && it.id === journey))
2375
+ : undefined) ??
2376
+ this._active();
2377
+ if (!conv)
2378
+ return;
2379
+ this._activeId = conv.id;
2380
+ this._renderHistory();
2381
+ this._renderThread();
2382
+ // Finished before the line below, so the card reads done above it.
2383
+ if (visit.steps?.length) {
2384
+ restoreJourney(visit.steps);
2385
+ recordStep({ id: 'navigate', label: `Opened ${visit.label}`, status: 'done' });
2386
+ recordStep({ id: 'submit', label: 'Arrived', status: 'done' });
2387
+ }
2388
+ const label = visit.label || 'the page';
2389
+ this._appendMsg('assistant', `Opened ${label}. You are on its page now.`);
2390
+ // What the model needs to know to answer "what is on this page".
2391
+ conv.messages.push({ role: 'user', content: `[${OPEN_PAGE_TOOL} opened ${label}: ${visit.pageUrl}]` });
2392
+ this._saveConversations();
2393
+ this._scroll();
2394
+ // Covering the page just opened would hide what was asked for.
2395
+ if (this.mode !== 'fullscreen')
2396
+ this._open();
2397
+ }
2398
+ async _resumePendingAction() {
2399
+ const outcome = await resumePendingAction({
2400
+ onStart: (pending) => {
2401
+ // Back to the conversation it was started in, so the result lands
2402
+ // under the question that prompted it rather than in a fresh thread.
2403
+ const conv = this._conversations.find((c) => c.id === pending.conversationId);
2404
+ if (conv) {
2405
+ this._activeId = conv.id;
2406
+ this._renderHistory();
2407
+ this._renderThread();
2408
+ }
2409
+ this._open();
2410
+ this._setStatus('Submitting the details you confirmed…');
2411
+ },
2412
+ exec: (tool, values) => this.toolSource.execTool(tool, tool.name, values),
2413
+ });
2414
+ if (outcome.status !== 'none')
2415
+ this._setStatus('');
2416
+ switch (outcome.status) {
2417
+ case 'none':
2418
+ return;
2419
+ // Either way the form is out of reach, and saying which of the two
2420
+ // failed would describe our plumbing rather than their situation.
2421
+ case 'no-registry':
2422
+ case 'no-tool':
2423
+ this._appendMsg('assistant', `I could not find the ${humanize(outcome.pending.pageToolName)} on this page, so ` +
2424
+ 'nothing was submitted. Ask me again and I will fill it in from here.');
2425
+ return;
2426
+ case 'incomplete':
2427
+ this._appendMsg('assistant', `I brought your details over. This form also needs ${outcome.missing
2428
+ .map((m) => humanize(m).toLowerCase())
2429
+ .join(', ')}.`);
2430
+ this._renderForm(outcome.tool, outcome.pending.values);
2431
+ return;
2432
+ case 'rejected':
2433
+ this._appendMsg('assistant', outcome.text || 'That form did not accept the details.');
2434
+ return;
2435
+ case 'failed':
2436
+ this._appendMsg('assistant', `That form could not be submitted: ${outcome.error}`);
2437
+ return;
2438
+ case 'submitted': {
2439
+ const active = this._active();
2440
+ const item = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text);
2441
+ active?.items.push(item);
2442
+ active?.messages.push({
2443
+ role: 'user',
2444
+ content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
2445
+ 'Keep these unless I ask to change them.',
2446
+ });
2447
+ this._els.thread.appendChild(this._detailEl(item));
2448
+ this._saveConversations();
2449
+ this._scroll();
2450
+ }
2451
+ }
2452
+ }
2000
2453
  /**
2001
2454
  * Bring in the site's own MCP server, once.
2002
2455
  *
2003
2456
  * The page's own action tools register themselves. Remote tools do not: they
2004
2457
  * live on an MCP server that must be discovered through
2005
- * `/.well-known/ai-catalog.json` and bridged into the page's registry.
2458
+ * `/.well-known/ai-catalog.json` and bridged into the page's registry. The
2459
+ * same connection lists the actions on the site's other pages, unless
2460
+ * `site-actions="false"`.
2006
2461
  *
2007
2462
  * Runs on first open rather than on connect, so an unopened launcher costs no
2008
2463
  * network request. Failure is neither fatal nor loud: a site with no MCP
@@ -2023,17 +2478,127 @@ class NxisAgentFullscreen extends HTMLElement {
2023
2478
  // Short, because this blocks the first turn: a provider that has not
2024
2479
  // appeared within a few seconds is not going to.
2025
2480
  timeout: 4000,
2481
+ siteActions: this.getAttribute('site-actions') !== 'false',
2026
2482
  })
2027
- .then(({ remoteTools, warning }) => {
2483
+ .then(({ remoteTools, siteActions, siteTools, sitePages, warning }) => {
2484
+ this._sitePages = sitePages;
2028
2485
  if (warning)
2029
2486
  console.warn('[nxis-agent]', warning);
2030
- else if (remoteTools.length) {
2487
+ if (remoteTools.length) {
2031
2488
  console.info(`[nxis-agent] discovered remote tools: ${remoteTools.join(', ')}`);
2032
2489
  }
2490
+ if (siteActions.length) {
2491
+ console.info(`[nxis-agent] ${siteActions.length} site action(s) → ` +
2492
+ `${siteTools.length} tool(s): ${siteTools.join(', ')}`);
2493
+ }
2494
+ void this._refreshToolCount();
2495
+ // The page's own script can register after we connect, and a native
2496
+ // registry announces it. Keep the count honest as it does.
2497
+ this.toolSource.modelContext()?.addEventListener('toolchange', () => {
2498
+ void this._refreshToolCount();
2499
+ if (this._toolsOpen)
2500
+ void this._renderTools();
2501
+ });
2033
2502
  })
2034
2503
  .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
2035
2504
  return this._connect;
2036
2505
  }
2506
+ // ── Tools menu ─────────────────────────────────────────────────────────────
2507
+ /** Open or close the list of tools the agent can use. */
2508
+ _toggleTools(open = !this._toolsOpen) {
2509
+ this._toolsOpen = open;
2510
+ this._els.toolsBtn.setAttribute('aria-expanded', String(open));
2511
+ this._els.toolsPop.hidden = !open;
2512
+ if (open)
2513
+ void this._renderTools();
2514
+ }
2515
+ /** The number on the tools button, from what the agent would be offered now. */
2516
+ async _refreshToolCount() {
2517
+ const tools = await this._harness.offeredTools();
2518
+ this._els.toolsCount.textContent = String(tools.length);
2519
+ this._els.toolsCount.hidden = tools.length === 0;
2520
+ this._els.toolsBtn.setAttribute('aria-label', tools.length ? `Tools on this page, ${tools.length}` : 'Tools on this page');
2521
+ }
2522
+ /**
2523
+ * Draw the tools menu.
2524
+ *
2525
+ * Lists what the agent would be offered, not every tool registered: that
2526
+ * is the honest answer to "what can it do here". Grouped by where a tool
2527
+ * reaches — this page's own forms, then what works across the site — which
2528
+ * is what explains why a form from another listing is on the list. Every
2529
+ * row is disabled: the site provides these, and they are not the visitor's
2530
+ * to switch off.
2531
+ */
2532
+ async _renderTools() {
2533
+ const pop = this._els.toolsPop;
2534
+ const head = '<div class="tp-head"><div class="tp-title">What the agent can use here</div>' +
2535
+ '<div class="tp-sub">This site provides these tools. They load with the page and cannot be turned off.</div></div>';
2536
+ pop.innerHTML = `${head}<div class="tp-empty">Looking for tools…</div>`;
2537
+ const tools = await this._harness.offeredTools();
2538
+ if (!this._toolsOpen)
2539
+ return;
2540
+ this._els.toolsCount.textContent = String(tools.length);
2541
+ this._els.toolsCount.hidden = tools.length === 0;
2542
+ pop.innerHTML = head;
2543
+ if (!tools.length) {
2544
+ pop.insertAdjacentHTML('beforeend', '<div class="tp-empty">No tools on this page yet.</div>');
2545
+ return;
2546
+ }
2547
+ const readOnly = (t) => !!t.annotations?.readOnlyHint;
2548
+ const onPage = tools.filter((t) => !isSiteTool(t) && !readOnly(t));
2549
+ const acrossSite = tools.filter((t) => isSiteTool(t) || readOnly(t));
2550
+ const group = (label, list) => {
2551
+ if (!list.length)
2552
+ return;
2553
+ const title = document.createElement('div');
2554
+ title.className = 'tp-grp';
2555
+ title.textContent = label;
2556
+ pop.appendChild(title);
2557
+ for (const tool of list)
2558
+ pop.appendChild(this._toolRow(tool));
2559
+ };
2560
+ group('On this page', onPage);
2561
+ group('Across the site', acrossSite);
2562
+ if (tools.some((t) => !readOnly(t))) {
2563
+ pop.insertAdjacentHTML('beforeend', '<div class="tp-foot">Anything that submits a form asks you first.</div>');
2564
+ }
2565
+ }
2566
+ /** One tool in the menu: what it is, whether it writes, and a lock. */
2567
+ _toolRow(tool) {
2568
+ const site = isSiteTool(tool);
2569
+ const reads = !!tool.annotations?.readOnlyHint;
2570
+ const base = FRIENDLY_TOOL_NAMES[tool.name] ?? humanize(withoutSiteSuffix(tool.name));
2571
+ const name = (base.charAt(0).toUpperCase() + base.slice(1)) + (site ? ' · any page' : '');
2572
+ const desc = (tool.description ?? '').split(/(?<=[.!?])\s/)[0] ?? '';
2573
+ const row = document.createElement('div');
2574
+ row.className = 'tp-tool';
2575
+ row.setAttribute('aria-disabled', 'true');
2576
+ row.title = desc ? `${name} — ${desc}` : name;
2577
+ const icon = document.createElement('span');
2578
+ icon.className = 'tp-ti';
2579
+ icon.innerHTML = site ? ICON_GLOBE : reads ? ICON_SEARCH : ICON_FORM;
2580
+ const text = document.createElement('span');
2581
+ text.className = 'tp-tn';
2582
+ const n = document.createElement('span');
2583
+ n.className = 'tp-name';
2584
+ // Text, not markup: names and descriptions come from the site's data.
2585
+ n.textContent = name;
2586
+ text.appendChild(n);
2587
+ if (desc) {
2588
+ const d = document.createElement('span');
2589
+ d.className = 'tp-desc';
2590
+ d.textContent = desc;
2591
+ text.appendChild(d);
2592
+ }
2593
+ const kind = document.createElement('span');
2594
+ kind.className = 'tp-kind';
2595
+ kind.textContent = reads ? 'reads' : 'asks first';
2596
+ const lock = document.createElement('span');
2597
+ lock.className = 'tp-lock';
2598
+ lock.innerHTML = ICON_LOCK;
2599
+ row.append(icon, text, kind, lock);
2600
+ return row;
2601
+ }
2037
2602
  /**
2038
2603
  * Report a failure in the thread, where it will be seen.
2039
2604
  *
@@ -2179,6 +2744,43 @@ class NxisAgentFullscreen extends HTMLElement {
2179
2744
  *
2180
2745
  * Enabling it affects subsequent turns, not those already recorded.
2181
2746
  */
2747
+ /**
2748
+ * How much of the page the agent takes when open.
2749
+ *
2750
+ * `'fullscreen'` covers the page, which suits a site where the agent is the
2751
+ * experience. `'window'` docks it in the corner at
2752
+ * `--window-w` × `--window-h` (400 × 620 by default), for a site whose own
2753
+ * content should stay visible and usable beside it. Below 520px a window
2754
+ * covers the page anyway, since at that width the two are the same thing.
2755
+ *
2756
+ * `'inline'` fills the box the host puts the element in and is always
2757
+ * open — for a copilot rail or a sidebar the host lays out itself. The
2758
+ * element does not position itself or move the page; the host does both.
2759
+ */
2760
+ get mode() {
2761
+ const raw = this.getAttribute('mode');
2762
+ return raw === 'window' || raw === 'inline' ? raw : 'fullscreen';
2763
+ }
2764
+ set mode(next) {
2765
+ this.setAttribute('mode', next === 'window' || next === 'inline' ? next : 'fullscreen');
2766
+ this._applyMode();
2767
+ }
2768
+ /** Put the overlay in the shape {@link mode} asks for. */
2769
+ _applyMode() {
2770
+ const overlay = this._els?.overlay;
2771
+ if (!overlay)
2772
+ return;
2773
+ const mode = this.mode;
2774
+ overlay.classList.toggle('window', mode === 'window');
2775
+ overlay.classList.toggle('inline', mode === 'inline');
2776
+ if (mode === 'inline') {
2777
+ // Always on screen, so it has to be ready without an open. Tools are
2778
+ // cheap to connect; the model still waits for `preload` or a first
2779
+ // message, so a rail on every page does not cost every visitor 3.4 GB.
2780
+ overlay.classList.add('show');
2781
+ void this._connectTools();
2782
+ }
2783
+ }
2182
2784
  get debug() {
2183
2785
  const raw = (this.getAttribute('debug') || '').toLowerCase();
2184
2786
  if (!this.hasAttribute('debug'))
@@ -2258,10 +2860,18 @@ class NxisAgentFullscreen extends HTMLElement {
2258
2860
  * can be replaced later.
2259
2861
  */
2260
2862
  _paintProvenance() {
2863
+ const local = this.provider?.local ?? true;
2864
+ // Beside the input: which model is answering, and where it runs.
2865
+ const model = this.shadowRoot?.querySelector('.composer .model');
2866
+ if (model) {
2867
+ const label = this.provider?.label || this.provider?.name || 'Model';
2868
+ model.querySelector('.model-name').textContent = label;
2869
+ model.querySelector('.model-where').textContent = local ? 'on-device' : 'remote';
2870
+ model.setAttribute('title', `${label}, ${local ? 'running in this browser' : 'running on a server'}`);
2871
+ }
2261
2872
  const badge = this.shadowRoot?.querySelector('.provenance');
2262
2873
  if (!badge?.lastChild)
2263
2874
  return;
2264
- const local = this.provider?.local ?? true;
2265
2875
  badge.lastChild.textContent = local ? 'On-device' : 'Remote';
2266
2876
  badge.setAttribute('title', local
2267
2877
  ? 'The model runs in this browser. Nothing you type leaves the device.'
@@ -2292,7 +2902,7 @@ class NxisAgentFullscreen extends HTMLElement {
2292
2902
  --ink:#0F172A; --ink-2:#475569; --ink-3:#8A97AC;
2293
2903
  --blue:#2563EB; --blue-strong:#1D4ED8; --blue-tint:#EEF3FF;
2294
2904
  --green:#22C55E; --green-strong:#16A34A; --green-tint:#E8FAEF;
2295
- --inline-bg:#EEF2F8; --red:#B91C1C;
2905
+ --inline-bg:#EEF2F8; --red:#B91C1C; --stop:#DC2626; --stop-strong:#B91C1C;
2296
2906
  --radius:14px; --radius-sm:10px; --radius-pill:999px;
2297
2907
  --shadow-sm:0 1px 2px rgba(15,23,42,.05);
2298
2908
  --shadow:0 1px 2px rgba(15,23,42,.04), 0 10px 30px rgba(15,23,42,.06);
@@ -2340,6 +2950,101 @@ class NxisAgentFullscreen extends HTMLElement {
2340
2950
  .overlay.show { display: flex; animation: fade .2s ease; }
2341
2951
  @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
2342
2952
 
2953
+ /* ── Window mode ────────────────────────────────────────────
2954
+ The same panel, docked in a corner rather than covering the
2955
+ page — for a site that wants an assistant beside its content
2956
+ instead of in front of it. The host sets the size through
2957
+ --window-w and --window-h. */
2958
+ .overlay.window {
2959
+ inset: auto; right: 20px; bottom: 20px;
2960
+ width: min(var(--window-w, 400px), calc(100vw - 32px));
2961
+ height: min(var(--window-h, 620px), calc(100vh - 32px));
2962
+ border: 1px solid var(--border-strong);
2963
+ border-radius: var(--radius);
2964
+ box-shadow: 0 24px 60px rgba(15,23,42,.22);
2965
+ overflow: hidden;
2966
+ }
2967
+ /* ── Inline mode ────────────────────────────────────────────
2968
+ Fills whatever box the host puts it in — a copilot rail, a sidebar,
2969
+ a column — and leaves where that box goes, and what the page does
2970
+ to make room, to the host. Always open: there is no launcher to
2971
+ press and nothing to close back to. */
2972
+ :host([mode="inline"]) { display: block; position: relative; height: 100%; }
2973
+ :host([mode="inline"]) .launcher { display: none; }
2974
+ /* overflow: hidden is what keeps the conversation drawer inside the
2975
+ host's box. Closed, the drawer waits just off the left edge; without
2976
+ the clip it sits on top of the host's page, and pressing the menu
2977
+ only slides it between two visible places. */
2978
+ .overlay.inline { position: absolute; inset: 0; z-index: auto; animation: none; overflow: hidden; }
2979
+ .overlay.inline .close { display: none; }
2980
+
2981
+ /* The conversation list cannot sit beside a narrow thread, so in a
2982
+ window or a rail it behaves as the drawer the narrow layout already
2983
+ defines, whatever the viewport is doing. */
2984
+ .overlay.window .side, .overlay.inline .side {
2985
+ position: absolute; inset: 0 auto 0 0; z-index: 6;
2986
+ transform: translateX(-100%); transition: transform .2s ease;
2987
+ box-shadow: 0 18px 48px rgba(15,23,42,.18);
2988
+ }
2989
+ .overlay.window.nav .side, .overlay.inline.nav .side { transform: none; }
2990
+ .overlay.window.nav .scrim, .overlay.inline.nav .scrim {
2991
+ display: block; position: absolute; inset: 0; z-index: 5;
2992
+ background: rgba(15,23,42,.32);
2993
+ }
2994
+ .overlay.window .menu, .overlay.inline .menu { display: flex; }
2995
+ .overlay.window .msg, .overlay.inline .msg { max-width: 88%; }
2996
+ .overlay.window .hist-del, .overlay.inline .hist-del { opacity: 1; }
2997
+ .overlay.window .main-head, .overlay.inline .main-head { padding: 12px 14px; }
2998
+ .overlay.window .thread, .overlay.inline .thread { padding: 14px; }
2999
+ @media (prefers-reduced-motion: reduce) {
3000
+ .overlay.window .side, .overlay.inline .side { transition: none; }
3001
+ }
3002
+
3003
+ /* ── Journeys ───────────────────────────────────────────────
3004
+ What the agent did about a request, as a card under it. */
3005
+ .msg.journey { align-self: stretch; max-width: 100%; padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--bg); overflow: hidden; }
3006
+ .msg.journey > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 9px 12px; font-size: 12.5px; color: var(--ink-2); }
3007
+ .msg.journey > summary::-webkit-details-marker { display: none; }
3008
+ .msg.journey > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
3009
+ .msg.journey[open] > summary { background: var(--surface); border-bottom: 1px solid var(--border); }
3010
+ .j-title { font-family: var(--display); font-weight: 700; color: var(--ink); white-space: nowrap; }
3011
+ .j-state { margin-left: auto; min-width: 0; font-size: 11.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3012
+ .j-sum-mark { width: 16px; height: 16px; flex: none; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; }
3013
+ .is-running .j-sum-mark { border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
3014
+ .is-done .j-sum-mark { background: var(--green-tint); color: var(--green-strong); }
3015
+ .is-failed .j-sum-mark { background: rgba(185,28,28,.1); color: var(--red); }
3016
+ .is-failed .j-state { color: var(--red); }
3017
+ @keyframes jspin { to { transform: rotate(360deg); } }
3018
+ .j-steps { list-style: none; margin: 0; padding: 12px; display: grid; }
3019
+ .j-step { display: grid; grid-template-columns: 20px 1fr; gap: 10px; position: relative; padding-bottom: 10px; font-size: 12.5px; }
3020
+ .j-step:last-child { padding-bottom: 0; }
3021
+ .j-step:not(:last-child)::before { content: ''; position: absolute; left: 9px; top: 21px; bottom: 1px; width: 1px; background: var(--border-strong); }
3022
+ .j-mark { width: 19px; height: 19px; border-radius: 50%; border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink-3); font-size: 10px; display: grid; place-items: center; z-index: 1; }
3023
+ .j-step.is-done .j-mark { background: var(--green-tint); border-color: var(--green-strong); color: var(--green-strong); }
3024
+ .j-step.is-active .j-mark { background: var(--blue); border-color: var(--blue); color: #fff; }
3025
+ .j-step.is-failed .j-mark { background: rgba(185,28,28,.1); border-color: var(--red); color: var(--red); }
3026
+ .j-label { color: var(--ink-2); line-height: 1.35; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3027
+ .j-label small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3028
+ .j-step:hover .j-label, .j-step:hover .j-label small { white-space: normal; overflow: visible; }
3029
+ .j-step.is-done .j-label, .j-step.is-active .j-label { color: var(--ink); }
3030
+ .j-step.is-active .j-label { font-weight: 600; }
3031
+ .j-label small { display: block; font-size: 11px; font-weight: 400; color: var(--ink-3); margin-top: 1px; }
3032
+
3033
+ /* The running step, while its card is scrolled out of view. */
3034
+ .jstatus { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; border-bottom: 1px solid var(--border); background: var(--blue-tint); color: var(--ink); font-family: var(--sans); font-size: 12.5px; font-weight: 600; padding: 8px 20px; cursor: pointer; text-align: left; }
3035
+ .jstatus[hidden] { display: none; }
3036
+ .jstatus:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
3037
+ .js-spin { width: 12px; height: 12px; flex: none; border-radius: 50%; border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
3038
+ .js-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3039
+ .js-go { margin-left: auto; font-weight: 500; color: var(--blue-strong); }
3040
+ .overlay.window .jstatus, .overlay.inline .jstatus { padding: 8px 14px; }
3041
+ @media (prefers-reduced-motion: reduce) { .is-running .j-sum-mark, .js-spin { animation: none; } }
3042
+ /* Narrower than the card, a card is the viewport: drop the chrome
3043
+ rather than leaving a 16px frame around a full-height panel. */
3044
+ @media (max-width: 520px) {
3045
+ .overlay.window { inset: 0; width: auto; height: auto; border: 0; border-radius: 0; box-shadow: none; }
3046
+ }
3047
+
2343
3048
  /* ── Side panel ─────────────────────────────────────────────── */
2344
3049
  .scrim { display: none; }
2345
3050
  .side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-right: 1px solid var(--border); }
@@ -2561,24 +3266,66 @@ class NxisAgentFullscreen extends HTMLElement {
2561
3266
 
2562
3267
  .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; }
2563
3268
  .statusline.err { color: var(--red); }
2564
- .composer { display: flex; align-items: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
3269
+ /* ── Composer: the input, with a bar beneath it ─────────────
3270
+ Tools on the left, the model beside them, send or stop on the
3271
+ right. One box around both, so the bar reads as part of the input
3272
+ rather than a second control strip. */
3273
+ .composer { padding: 12px 20px 14px; border-top: 1px solid var(--border); }
3274
+ .overlay.window .composer, .overlay.inline .composer { padding: 10px 12px 12px; }
3275
+ .cbox { border: 1px solid var(--border-strong); border-radius: 14px; background: var(--bg); padding: 8px 8px 8px 12px; transition: border-color .15s ease, box-shadow .15s ease; }
3276
+ .cbox:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
2565
3277
  .composer textarea {
2566
- flex: 1; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
2567
- background: var(--bg); border: 1px solid var(--border-strong); border-radius: 10px; padding: 9px 12px; outline: none;
2568
- max-height: 140px; overflow-y: auto; transition: border-color .15s ease, box-shadow .15s ease;
3278
+ display: block; width: 100%; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
3279
+ background: transparent; border: 0; padding: 2px 2px 8px; outline: none; max-height: 140px; overflow-y: auto;
2569
3280
  }
2570
- .composer textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
2571
3281
  .composer textarea::placeholder { color: var(--ink-3); }
2572
- .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); }
3282
+ .cbar { display: flex; align-items: center; gap: 8px; min-width: 0; }
3283
+ .composer .go { margin-left: auto; flex-shrink: 0; width: 34px; height: 34px; border-radius: 10px; border: none; background: var(--blue); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 2px rgba(37,99,235,.3); }
2573
3284
  .composer .go:hover { background: var(--blue-strong); }
2574
3285
  .composer .go:disabled { opacity: .45; cursor: not-allowed; }
2575
- /* Stopping is not a destructive act and does not get a red button. The
2576
- shape changes (arrow to square) and the fill drops back to a neutral
2577
- surface, which is enough to say "this does something else now" without
2578
- implying the turn in progress was a mistake. */
2579
- .composer .go.stop { background: var(--surface-2); color: var(--ink-2); box-shadow: none; border: 1px solid var(--border-strong); }
2580
- .composer .go.stop:hover { background: var(--surface); color: var(--ink); }
3286
+ /* Stop is red, with a filled white square: while a prompt runs this is
3287
+ the one control that matters, and it has to be found at a glance. */
3288
+ .composer .go.stop { background: var(--stop); color: #fff; border: 0; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
3289
+ .composer .go.stop:hover { background: var(--stop-strong); }
2581
3290
  .composer .go.stop svg { width: 15px; height: 15px; }
3291
+ .composer .go.stop svg path { fill: currentColor; }
3292
+ .composer .go:focus-visible, .tools-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
3293
+
3294
+ .model { display: inline-flex; align-items: center; gap: 6px; min-width: 0; margin-left: 2px; font-size: 12px; font-weight: 500; color: var(--ink-2); white-space: nowrap; overflow: hidden; }
3295
+ .model-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--green); }
3296
+ .model.busy .model-dot { background: var(--blue); animation: mpulse 1.2s ease-in-out infinite; }
3297
+ @keyframes mpulse { 50% { opacity: .35; } }
3298
+ .model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
3299
+ .model-where { flex: none; font-family: var(--mono); font-size: 10px; letter-spacing: .04em; color: var(--ink-3); }
3300
+
3301
+ /* ── Tools menu ── */
3302
+ .tools { position: relative; flex: none; }
3303
+ .tools-btn { position: relative; width: 32px; height: 32px; border: 1px solid var(--border-strong); border-radius: 9px; background: var(--bg); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; padding: 0; }
3304
+ .tools-btn:hover { color: var(--ink); border-color: var(--ink-3); }
3305
+ .tools-btn[aria-expanded="true"] { border-color: var(--blue); color: var(--blue); background: var(--blue-tint); }
3306
+ .tools-btn svg { width: 17px; height: 17px; }
3307
+ .tools-count { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; padding: 0 4px; border-radius: 9px; background: var(--ink); color: var(--bg); font-size: 10px; font-weight: 600; display: grid; place-items: center; border: 2px solid var(--bg); }
3308
+ .tools-count[hidden] { display: none; }
3309
+ .tools-pop { position: absolute; left: -4px; bottom: calc(100% + 12px); z-index: 20; width: 320px; max-width: calc(100vw - 48px); max-height: 380px; overflow-y: auto; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 13px; box-shadow: 0 14px 36px rgba(15,23,42,.16), 0 2px 6px rgba(15,23,42,.06); padding: 6px; }
3310
+ .tools-pop[hidden] { display: none; }
3311
+ .overlay.window .tools-pop, .overlay.inline .tools-pop { width: 296px; }
3312
+ .tp-head { padding: 8px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
3313
+ .tp-title { font-family: var(--display); font-weight: 700; font-size: 13px; color: var(--ink); }
3314
+ .tp-sub { font-size: 11.5px; line-height: 1.4; color: var(--ink-3); margin-top: 2px; }
3315
+ .tp-grp { font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); padding: 8px 10px 3px; }
3316
+ /* Greyed and locked: visible, not the visitor's to change. */
3317
+ .tp-tool { display: flex; align-items: center; gap: 9px; padding: 6px 10px; border-radius: 8px; cursor: default; }
3318
+ .tp-ti { width: 24px; height: 24px; flex: none; border-radius: 6px; background: var(--surface-2); color: var(--ink-3); display: grid; place-items: center; opacity: .75; }
3319
+ .tp-ti svg { width: 13px; height: 13px; }
3320
+ .tp-tn { min-width: 0; flex: 1; }
3321
+ .tp-name { display: block; font-size: 12.5px; font-weight: 500; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3322
+ .tp-desc { display: block; font-size: 11px; color: var(--ink-3); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3323
+ .tp-kind { flex: none; font-family: var(--mono); font-size: 9px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
3324
+ .tp-lock { flex: none; width: 13px; height: 13px; color: var(--ink-3); opacity: .7; display: grid; }
3325
+ .tp-lock svg { width: 13px; height: 13px; }
3326
+ .tp-empty { padding: 12px 10px; font-size: 12px; color: var(--ink-3); }
3327
+ .tp-foot { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--border); font-size: 11px; color: var(--ink-3); }
3328
+ @media (prefers-reduced-motion: reduce) { .model.busy .model-dot { animation: none; } }
2582
3329
  /* A message the person sent, plus the row of things they can do to it.
2583
3330
  The wrapper carries the alignment the bubble used to carry on its own. */
2584
3331
  .turn { display: flex; flex-direction: column; align-items: flex-end; }
@@ -2713,8 +3460,36 @@ class NxisAgentFullscreen extends HTMLElement {
2713
3460
  .dc-eyebrow.review { color: var(--blue-strong); }
2714
3461
  .dc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
2715
3462
  .dc-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
2716
- .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; }
2717
- .dc-row dd { margin: 0; font-size: 14px; color: var(--ink); text-align: right; word-break: break-word; }
3463
+ /* One line per row, truncated. A card in a window or a rail is too
3464
+ narrow for a long value to wrap without becoming a column of
3465
+ fragments. The whole row comes back on hover or keyboard focus, and
3466
+ every cell carries its full text as a title too. */
3467
+ .dc-row dt { font-family: var(--mono); font-weight: 500; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); padding-top: 2px; flex: 0 1 auto; min-width: 0; max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3468
+ .dc-row dd { margin: 0; flex: 1 1 auto; min-width: 0; font-size: 14px; color: var(--ink); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3469
+ .dc-row:hover dt, .dc-row:hover dd, .dc-row:focus-within dt, .dc-row:focus-within dd { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
3470
+ .dc-row:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 4px; }
3471
+ /* Buttons keep their words on one line and wrap as whole buttons. */
3472
+ .dc-actions { flex-wrap: wrap; }
3473
+ .dc-actions .btn { white-space: nowrap; }
3474
+ .overlay.window .dc-actions .btn, .overlay.inline .dc-actions .btn { padding: 9px 14px; font-size: 13px; }
3475
+
3476
+ /* Code inside a message: monospace, and scrolled rather than wrapped,
3477
+ so a long line does not break at arbitrary places. */
3478
+ .msg .codeblock { margin: 6px 0 0; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); overflow-x: auto; max-width: 100%; }
3479
+ .msg .codeblock:first-child { margin-top: 0; }
3480
+ .msg .codeblock code { font-family: var(--mono); font-size: 12.5px; line-height: 1.55; white-space: pre; color: var(--ink); }
3481
+ .msg.user .codeblock { background: rgba(255,255,255,.16); border-color: rgba(255,255,255,.28); }
3482
+ .msg.user .codeblock code { color: #fff; }
3483
+
3484
+ /* A long block folds away: kept, but not filling the thread. */
3485
+ .codefold { margin-top: 6px; }
3486
+ .codefold > summary { cursor: pointer; list-style: none; font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); padding: 2px 0; }
3487
+ .codefold > summary::-webkit-details-marker { display: none; }
3488
+ .codefold > summary::before { content: '▸ '; }
3489
+ .codefold[open] > summary::before { content: '▾ '; }
3490
+ .codefold > summary:hover { color: var(--ink-2); }
3491
+ .codefold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
3492
+ .codefold .codeblock { margin-top: 4px; }
2718
3493
 
2719
3494
  /* ── Inline result cards — feature-card pattern: tinted icon chip,
2720
3495
  tags, tight Manrope headline, one supporting line. ─────── */
@@ -2733,9 +3508,21 @@ class NxisAgentFullscreen extends HTMLElement {
2733
3508
  .rc-sub { font-size: 13px; line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
2734
3509
  .rc-list { margin: 10px 0 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; }
2735
3510
  .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; }
2736
- .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; }
2737
- .rc-row dd { margin: 0; color: var(--ink); text-align: right; word-break: break-word; }
3511
+ .rc-row dt { font-family: var(--mono); font-weight: 500; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); flex: 0 1 auto; min-width: 0; max-width: 45%; padding-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3512
+ .rc-row dd { margin: 0; flex: 1 1 auto; min-width: 0; color: var(--ink); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3513
+ .rc-row:hover dt, .rc-row:hover dd, .rc-row:focus-within dt, .rc-row:focus-within dd { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
3514
+ /* The headline and its supporting line truncate too, and come back
3515
+ when the card is hovered or focused. */
3516
+ .rc-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3517
+ .rc-sub { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
3518
+ .resultcard:hover .rc-title, .resultcard:focus-within .rc-title { white-space: normal; overflow: visible; }
3519
+ .resultcard:hover .rc-sub, .resultcard:focus-within .rc-sub { -webkit-line-clamp: unset; overflow: visible; }
2738
3520
  .rc-id { margin-top: 10px; min-width: 0; }
3521
+ .rc-open { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 13px; font-weight: 600; color: var(--blue-strong); text-decoration: none; }
3522
+ .rc-open span { transition: transform 120ms ease; }
3523
+ .rc-open:hover { text-decoration: underline; }
3524
+ .rc-open:hover span { transform: translateX(2px); }
3525
+ .rc-open:focus-visible { outline: 2px solid var(--blue-strong); outline-offset: 2px; border-radius: 4px; }
2739
3526
  .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; }
2740
3527
  .rc-row dd a { color: var(--blue-strong); text-decoration: none; }
2741
3528
  .rc-row dd a:hover { text-decoration: underline; }
@@ -2806,11 +3593,23 @@ class NxisAgentFullscreen extends HTMLElement {
2806
3593
  <button class="close" aria-label="Close">×</button>
2807
3594
  </div>
2808
3595
  <div class="prog"><i></i></div>
3596
+ <button class="jstatus" type="button" hidden>
3597
+ <span class="js-spin" aria-hidden="true"></span><span class="js-label"></span><span class="js-go">Show</span>
3598
+ </button>
2809
3599
  <div class="thread"></div>
2810
3600
  <div class="statusline"></div>
2811
3601
  <div class="composer">
2812
- <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
2813
- <button class="go" aria-label="Send">${ICON_ARROW}</button>
3602
+ <div class="cbox">
3603
+ <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
3604
+ <div class="cbar">
3605
+ <div class="tools">
3606
+ <button class="tools-btn" type="button" aria-haspopup="dialog" aria-expanded="false" aria-label="Tools on this page">${ICON_TOOLS}<span class="tools-count" hidden></span></button>
3607
+ <div class="tools-pop" role="dialog" aria-label="What the agent can use here" hidden></div>
3608
+ </div>
3609
+ <span class="model"><i class="model-dot" aria-hidden="true"></i><span class="model-name"></span><span class="model-where"></span></span>
3610
+ <button class="go" aria-label="Send">${ICON_ARROW}</button>
3611
+ </div>
3612
+ </div>
2814
3613
  </div>
2815
3614
  <footer class="foot">
2816
3615
  <a class="powered" href="${NXIS_SITE_URL}" target="_blank" rel="noopener noreferrer">