@nxisai/agent-harness 0.2.3 → 0.3.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 +94 -6
  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 +38 -0
  11. package/dist/harness/harness.d.ts.map +1 -1
  12. package/dist/harness/harness.js +127 -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/discovery.d.ts +2 -1
  28. package/dist/harness/tools/discovery.d.ts.map +1 -1
  29. package/dist/harness/tools/discovery.js +1 -1
  30. package/dist/harness/tools/discovery.js.map +1 -1
  31. package/dist/harness/tools/pending-action.d.ts +55 -0
  32. package/dist/harness/tools/pending-action.d.ts.map +1 -0
  33. package/dist/harness/tools/pending-action.js +93 -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 +69 -0
  42. package/dist/harness/tools/site-actions.d.ts.map +1 -0
  43. package/dist/harness/tools/site-actions.js +310 -0
  44. package/dist/harness/tools/site-actions.js.map +1 -0
  45. package/dist/harness/tools/source.d.ts +44 -5
  46. package/dist/harness/tools/source.d.ts.map +1 -1
  47. package/dist/harness/tools/source.js +58 -9
  48. package/dist/harness/tools/source.js.map +1 -1
  49. package/dist/index.d.ts +10 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +5 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/providers/proxy.d.ts +5 -0
  54. package/dist/providers/proxy.d.ts.map +1 -1
  55. package/dist/providers/proxy.js +16 -1
  56. package/dist/providers/proxy.js.map +1 -1
  57. package/dist/providers/types.d.ts +15 -0
  58. package/dist/providers/types.d.ts.map +1 -1
  59. package/dist/types.d.ts +7 -4
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  62. package/dist/ui/agent-fullscreen.js +771 -36
  63. package/dist/ui/agent-fullscreen.js.map +1 -1
  64. package/dist/ui/index.d.ts +2 -0
  65. package/dist/ui/index.d.ts.map +1 -1
  66. package/dist/ui/index.js +1 -0
  67. package/dist/ui/index.js.map +1 -1
  68. package/dist/ui/journey.d.ts +33 -0
  69. package/dist/ui/journey.d.ts.map +1 -0
  70. package/dist/ui/journey.js +27 -0
  71. package/dist/ui/journey.js.map +1 -0
  72. package/dist/ui/tailwind.generated.d.ts +1 -1
  73. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  74. package/dist/ui/tailwind.generated.js +1 -1
  75. package/dist/ui/tailwind.generated.js.map +1 -1
  76. package/dist/webmcp-contract.d.ts +6 -2
  77. package/dist/webmcp-contract.d.ts.map +1 -1
  78. package/docs/remote-providers.md +196 -0
  79. package/docs/single-vs-multi-agent-dark.svg +78 -0
  80. package/docs/single-vs-multi-agent.svg +78 -0
  81. package/docs/turn-routing-dark.svg +52 -0
  82. package/docs/turn-routing.svg +52 -0
  83. package/package.json +3 -1
@@ -34,6 +34,10 @@ 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 { onJourneyChange } from '../harness/steps.js';
40
+ import { viewJourney } from './journey.js';
37
41
  import { AgentCoordinator } from '../harness/router/coordinator.js';
38
42
  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
43
  import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
@@ -82,6 +86,103 @@ function formatBytes(n) {
82
86
  return `${Math.round(mb)} MB`;
83
87
  }
84
88
  const uid = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
89
+ /**
90
+ * Names for the retrieval tools the package itself bridges in.
91
+ *
92
+ * `nxis_query` is a fine name for a model and a poor one for a visitor, and a
93
+ * native registry may strip any title we attach, so the few tools this package
94
+ * registers are named here.
95
+ */
96
+ const FRIENDLY_TOOL_NAMES = {
97
+ nxis_query: 'Search records',
98
+ nxis_get: 'Get a record',
99
+ nxis_list_types: 'List record types',
100
+ };
101
+ 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>`;
102
+ /** A plug: the tools the agent can reach. */
103
+ const ICON_TOOLS = svg('<path d="M7 3v4M13 3v4M5 7h10v3a5 5 0 0 1-10 0V7ZM10 15v3"/>');
104
+ 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"/>');
105
+ const ICON_FORM = svg('<rect x="4" y="3.5" width="12" height="13" rx="2"/><path d="M7 8h6M7 11h6M7 14h3"/>');
106
+ const ICON_SEARCH = svg('<circle cx="9" cy="9" r="4.8"/><path d="m13 13 3.5 3.5"/>');
107
+ 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"/>');
108
+ /**
109
+ * Split a message into prose and code.
110
+ *
111
+ * Two things read as code: a fenced block, and a message that is nothing but
112
+ * JSON — which is what a tool result looks like when something hands one back
113
+ * raw. Wrapped as prose it becomes a wall of text that breaks at the wrong
114
+ * places, taking any URL inside it with it.
115
+ *
116
+ * @param text - The message.
117
+ * @returns The parts in order, each marked as code or not.
118
+ */
119
+ function splitCode(text) {
120
+ const fenced = text.split(/```[a-zA-Z]*\n?/);
121
+ // An opening and a closing fence produce at least three parts; the odd ones
122
+ // are what sat between them.
123
+ if (fenced.length > 2) {
124
+ return fenced.map((part, i) => ({ text: i % 2 ? part.replace(/\n$/, '') : part, code: i % 2 === 1 }));
125
+ }
126
+ const trimmed = text.trim();
127
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
128
+ try {
129
+ return [{ text: JSON.stringify(JSON.parse(trimmed), null, 2), code: true }];
130
+ }
131
+ catch {
132
+ // Starts like JSON without being it — ordinary prose.
133
+ }
134
+ }
135
+ return [{ text, code: false }];
136
+ }
137
+ /** Machine output rather than something someone wrote: JSON, most often raw. */
138
+ function looksMachine(text) {
139
+ const trimmed = text.trim();
140
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
141
+ return false;
142
+ try {
143
+ JSON.parse(trimmed);
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ /** Longer than this, a block is folded away rather than filling the thread. */
151
+ const FOLD_LINES = 10;
152
+ /**
153
+ * Write a message into its bubble, keeping code as code.
154
+ *
155
+ * A long block is folded behind a summary: output worth keeping, but not
156
+ * worth scrolling past every time. Everything is written as text rather than
157
+ * markup — a message may carry a tool's output, which is not ours to trust
158
+ * as HTML.
159
+ */
160
+ function fillMessage(el, text) {
161
+ for (const part of splitCode(text)) {
162
+ if (part.code) {
163
+ const pre = document.createElement('pre');
164
+ pre.className = 'codeblock';
165
+ const code = document.createElement('code');
166
+ code.textContent = part.text;
167
+ pre.appendChild(code);
168
+ const lines = part.text.split('\n').length;
169
+ if (lines > FOLD_LINES) {
170
+ const fold = document.createElement('details');
171
+ fold.className = 'codefold';
172
+ const summary = document.createElement('summary');
173
+ summary.textContent = `${lines} lines`;
174
+ fold.append(summary, pre);
175
+ el.appendChild(fold);
176
+ }
177
+ else {
178
+ el.appendChild(pre);
179
+ }
180
+ }
181
+ else if (part.text) {
182
+ el.appendChild(document.createTextNode(part.text));
183
+ }
184
+ }
185
+ }
85
186
  class NxisAgentFullscreen extends HTMLElement {
86
187
  constructor() {
87
188
  super();
@@ -95,6 +196,21 @@ class NxisAgentFullscreen extends HTMLElement {
95
196
  * on, the re-run would race `_onSend`'s own guard and be dropped.
96
197
  */
97
198
  this._turnDone = null;
199
+ /** Stops listening to the journal. */
200
+ this._offJourney = null;
201
+ /** Whether the tools menu is showing. */
202
+ this._toolsOpen = false;
203
+ /** Closes the tools menu on a press outside it. An arrow, so it can be removed. */
204
+ this._onOutsidePress = (e) => {
205
+ if (!this._toolsOpen)
206
+ return;
207
+ const path = e.composedPath();
208
+ if (path.includes(this._els.toolsPop) || path.includes(this._els.toolsBtn))
209
+ return;
210
+ this._toggleTools(false);
211
+ };
212
+ /** Watches whether the running journey's card is on screen. */
213
+ this._journeyObserver = null;
98
214
  /** Built only when the host defines agents — otherwise there is one harness. */
99
215
  this._coordinator = null;
100
216
  this._agentDefs = null;
@@ -157,9 +273,15 @@ class NxisAgentFullscreen extends HTMLElement {
157
273
  thread: root.querySelector('.thread'),
158
274
  status: root.querySelector('.statusline'),
159
275
  bar: root.querySelector('.prog > i'),
276
+ jstatus: root.querySelector('.jstatus'),
160
277
  input: root.querySelector('.composer textarea'),
161
278
  go: root.querySelector('.composer .go'),
279
+ toolsBtn: root.querySelector('.tools-btn'),
280
+ toolsPop: root.querySelector('.tools-pop'),
281
+ toolsCount: root.querySelector('.tools-count'),
282
+ model: root.querySelector('.composer .model'),
162
283
  };
284
+ this._applyMode();
163
285
  // A host-supplied provider wins; otherwise the local model, configured from
164
286
  // the attributes that only mean anything to it.
165
287
  this.provider =
@@ -203,6 +325,30 @@ class NxisAgentFullscreen extends HTMLElement {
203
325
  this._loadConversations();
204
326
  this._renderHistory();
205
327
  this._renderThread();
328
+ // Journey cards follow the journal rather than the renderer, because the
329
+ // steps that finish a cross-page action are recorded by the resume below,
330
+ // before any turn — and so before any renderer call — exists on this page.
331
+ this._offJourney?.();
332
+ this._offJourney = onJourneyChange((_step, all) => this._onJourney(all));
333
+ this._els.toolsBtn.addEventListener('click', () => this._toggleTools());
334
+ this._els.toolsPop.addEventListener('keydown', (e) => {
335
+ if (e.key !== 'Escape')
336
+ return;
337
+ e.stopPropagation();
338
+ this._toggleTools(false);
339
+ this._els.toolsBtn.focus();
340
+ });
341
+ // Outside the menu closes it. On the document, with the composed path,
342
+ // so a press anywhere on the host page counts as outside too.
343
+ document.removeEventListener('pointerdown', this._onOutsidePress);
344
+ document.addEventListener('pointerdown', this._onOutsidePress);
345
+ this._els.jstatus.addEventListener('click', () => {
346
+ const cards = this._els.thread.querySelectorAll('.journey.is-running');
347
+ cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
348
+ });
349
+ // A form confirmed on another page submits here, now that the page it
350
+ // belongs to is the one open.
351
+ void this._resumePendingAction();
206
352
  this._els.launcher.addEventListener('click', () => this._open());
207
353
  this._els.close.addEventListener('click', () => {
208
354
  this._els.overlay.classList.remove('show');
@@ -278,6 +424,14 @@ class NxisAgentFullscreen extends HTMLElement {
278
424
  if (!open)
279
425
  this._els.menu.focus();
280
426
  }
427
+ disconnectedCallback() {
428
+ document.removeEventListener('pointerdown', this._onOutsidePress);
429
+ // Moved or removed: nothing left on screen to keep in step with the journal.
430
+ this._offJourney?.();
431
+ this._offJourney = null;
432
+ this._journeyObserver?.disconnect();
433
+ this._journeyObserver = null;
434
+ }
281
435
  /** Programmatic open (used by pages that dock the agent full-page). */
282
436
  open() {
283
437
  this._open();
@@ -457,8 +611,11 @@ class NxisAgentFullscreen extends HTMLElement {
457
611
  this._els.thread.appendChild(this._resultsEl(it));
458
612
  else if (it.kind === 'metrics')
459
613
  this._els.thread.appendChild(this._metricsEl(it));
614
+ else if (it.kind === 'journey')
615
+ this._els.thread.appendChild(this._journeyEl(it));
460
616
  }
461
617
  this._scroll();
618
+ this._watchRunningJourney();
462
619
  }
463
620
  _clearEmpty() {
464
621
  const e = this._els.thread.querySelector('.empty');
@@ -484,7 +641,7 @@ class NxisAgentFullscreen extends HTMLElement {
484
641
  ? 'self-end bg-blue text-white rounded-2xl rounded-br-md'
485
642
  : 'self-start bg-surface text-ink border border-border-hair rounded-2xl rounded-bl-md shadow-resting';
486
643
  el.className = `msg ${role} ${base} ${byRole}`;
487
- el.textContent = text;
644
+ fillMessage(el, text);
488
645
  return el;
489
646
  }
490
647
  /**
@@ -502,15 +659,27 @@ class NxisAgentFullscreen extends HTMLElement {
502
659
  (isError
503
660
  ? 'bg-surface border-red/25 text-red'
504
661
  : '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>`;
662
+ const head = document.createElement('b');
663
+ head.className =
664
+ 'flex items-center gap-1.5 font-mono text-[11px] font-medium uppercase tracking-[0.14em]';
665
+ head.innerHTML = `${isError ? ICON_WARN : ICON_CHECK}${escapeHtml(name)}`;
666
+ const body = document.createElement('div');
667
+ body.className = 'mt-1 font-sans text-[13px] leading-normal text-ink-2';
668
+ // A tool's own sentence is worth showing; its raw payload is for whoever
669
+ // is building the agent, and lives in the trace, which `debug` governs.
670
+ const shown = this.debug || !looksMachine(text)
671
+ ? text
672
+ : `${humanize(name)} could not be completed.`;
673
+ fillMessage(body, shown);
674
+ el.append(head, body);
509
675
  return el;
510
676
  }
511
677
  _detailEl(item) {
512
678
  const rows = (item.rows || [])
513
- .map((r) => `<div class="dc-row"><dt>${escapeHtml(r.label)}</dt><dd>${escapeHtml(r.value)}</dd></div>`)
679
+ .map((r) =>
680
+ // Focusable, so a keyboard can open a truncated row the way a pointer does.
681
+ `<div class="dc-row" tabindex="0"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
682
+ `<dd title="${escapeHtml(r.value)}">${escapeHtml(r.value)}</dd></div>`)
514
683
  .join('');
515
684
  const el = document.createElement('div');
516
685
  el.className = 'msg detail';
@@ -727,6 +896,133 @@ class NxisAgentFullscreen extends HTMLElement {
727
896
  el.appendChild(body);
728
897
  return el;
729
898
  }
899
+ /**
900
+ * A journey as a card in the thread.
901
+ *
902
+ * Open while it runs, so the step being taken is on screen; folded to one
903
+ * line once it finishes, so the thread reads as a record rather than a wall
904
+ * of finished checklists. A `<details>` either way, so a folded journey can
905
+ * still be opened.
906
+ */
907
+ _journeyEl(item) {
908
+ const view = viewJourney(item.steps);
909
+ const el = document.createElement('details');
910
+ el.className = `msg journey is-${view?.outcome ?? 'running'}`;
911
+ el.dataset.journey = item.id;
912
+ el.open = view?.outcome === 'running';
913
+ if (view?.active)
914
+ el.dataset.active = view.active.label;
915
+ const summary = document.createElement('summary');
916
+ const mark = document.createElement('span');
917
+ mark.className = 'j-sum-mark';
918
+ mark.setAttribute('aria-hidden', 'true');
919
+ if (view?.outcome === 'done')
920
+ mark.textContent = '✓';
921
+ if (view?.outcome === 'failed')
922
+ mark.textContent = '!';
923
+ const title = document.createElement('span');
924
+ title.className = 'j-title';
925
+ title.textContent = view?.title ?? 'Working on it';
926
+ const state = document.createElement('span');
927
+ state.className = 'j-state';
928
+ // Running, the header says what is happening now; finished, it says how
929
+ // it ended, which is the one thing a folded card has to carry.
930
+ state.textContent =
931
+ view?.outcome === 'running' ? (view.active?.label ?? '') : (view?.summary.split(' · ')[1] ?? '');
932
+ summary.append(mark, title, state);
933
+ el.appendChild(summary);
934
+ const list = document.createElement('ol');
935
+ list.className = 'j-steps';
936
+ (view?.steps ?? []).forEach((step, i) => {
937
+ const li = document.createElement('li');
938
+ li.className = `j-step is-${step.status}`;
939
+ const m = document.createElement('span');
940
+ m.className = 'j-mark';
941
+ m.textContent = step.status === 'done' ? '✓' : step.status === 'failed' ? '!' : String(i + 1);
942
+ const label = document.createElement('span');
943
+ label.className = 'j-label';
944
+ // Text throughout: a label can carry a business name from the site's data.
945
+ label.textContent = step.label;
946
+ label.title = step.detail ? `${step.label} — ${step.detail}` : step.label;
947
+ if (step.detail) {
948
+ const small = document.createElement('small');
949
+ small.textContent = step.detail;
950
+ label.appendChild(small);
951
+ }
952
+ li.append(m, label);
953
+ list.appendChild(li);
954
+ });
955
+ el.appendChild(list);
956
+ return el;
957
+ }
958
+ /**
959
+ * Keep a journey's card, and its record in the conversation, up to date.
960
+ *
961
+ * Looks for the journey in every conversation before assuming it is new:
962
+ * on arrival after a page change the resume records steps before the
963
+ * conversation they came from has been reopened, and those steps belong
964
+ * with the card saved on the page before, not in whichever thread is open.
965
+ */
966
+ _onJourney(all) {
967
+ const view = viewJourney(all);
968
+ if (!view?.id)
969
+ return;
970
+ const isThis = (it) => it.kind === 'journey' && it.id === view.id;
971
+ let conv = this._conversations.find((c) => c.items.some(isThis));
972
+ let item = conv?.items.find(isThis);
973
+ if (conv && item) {
974
+ item.steps = all;
975
+ }
976
+ else {
977
+ conv = this._active() ?? undefined;
978
+ if (!conv)
979
+ return;
980
+ item = { kind: 'journey', id: view.id, steps: all };
981
+ conv.items.push(item);
982
+ }
983
+ if (conv.id === this._activeId && this._els?.thread) {
984
+ const fresh = this._journeyEl(item);
985
+ const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
986
+ if (old) {
987
+ // Keep a folded card open if someone opened it to read.
988
+ if (old.open && view.outcome !== 'running')
989
+ fresh.open = true;
990
+ old.replaceWith(fresh);
991
+ }
992
+ else {
993
+ this._clearEmpty();
994
+ this._els.thread.appendChild(fresh);
995
+ this._scroll();
996
+ }
997
+ this._watchRunningJourney();
998
+ }
999
+ this._saveConversations();
1000
+ }
1001
+ /**
1002
+ * Show the status line only while the running journey is out of view.
1003
+ *
1004
+ * The card is where the steps are; the line exists for the moment a long
1005
+ * reply pushes it off the top of the thread mid-action, and hides again as
1006
+ * soon as the card is back.
1007
+ */
1008
+ _watchRunningJourney() {
1009
+ this._journeyObserver?.disconnect();
1010
+ this._journeyObserver = null;
1011
+ const bar = this._els?.jstatus;
1012
+ if (!bar)
1013
+ return;
1014
+ const cards = this._els.thread.querySelectorAll('.journey.is-running');
1015
+ const card = cards[cards.length - 1];
1016
+ if (!card || typeof IntersectionObserver === 'undefined') {
1017
+ bar.hidden = true;
1018
+ return;
1019
+ }
1020
+ bar.querySelector('.js-label').textContent = card.dataset.active ?? '';
1021
+ this._journeyObserver = new IntersectionObserver((entries) => {
1022
+ bar.hidden = entries[entries.length - 1].isIntersecting;
1023
+ }, { root: this._els.thread, threshold: 0.2 });
1024
+ this._journeyObserver.observe(card);
1025
+ }
730
1026
  _paintToolResult(name, text, isError) {
731
1027
  this._clearEmpty();
732
1028
  const el = this._toolEl(name, text, isError);
@@ -771,6 +1067,8 @@ class NxisAgentFullscreen extends HTMLElement {
771
1067
  go.classList.toggle('stop', canStop);
772
1068
  go.innerHTML = canStop ? ICON_STOP : ICON_ARROW;
773
1069
  go.setAttribute('aria-label', canStop ? 'Stop' : 'Send');
1070
+ // The model's dot pulses while it is working, so the two controls agree.
1071
+ this._els.model.classList.toggle('busy', canStop);
774
1072
  // Enabled either way: disabling it during a turn would disable the very
775
1073
  // control needed to stop that turn.
776
1074
  go.disabled = false;
@@ -901,6 +1199,9 @@ class NxisAgentFullscreen extends HTMLElement {
901
1199
  this._syncProfileContext();
902
1200
  // One agent, or several with a router in front: the signatures match.
903
1201
  await (this._coordinator ?? this._harness).turn(goal, conv.messages);
1202
+ // A remote endpoint reports which model answered; the name beside the
1203
+ // input follows it rather than keeping whatever was configured.
1204
+ this._paintProvenance();
904
1205
  }
905
1206
  catch (err) {
906
1207
  // A cancelled turn has already reported itself in the thread, and is not
@@ -1059,8 +1360,17 @@ class NxisAgentFullscreen extends HTMLElement {
1059
1360
  this._clearEmpty();
1060
1361
  this._dismissPrompt();
1061
1362
  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>`)
1363
+ .map((sl) => {
1364
+ const label = escapeHtml(sl.schema.description || humanize(sl.name));
1365
+ const value = escapeHtml(String(sl.value ?? ''));
1366
+ return `<div class="dc-row" tabindex="0"><dt title="${label}">${label}</dt><dd title="${value}">${value}</dd></div>`;
1367
+ })
1063
1368
  .join('');
1369
+ // Where pressing submit leads, when that is another page. Said here in a
1370
+ // sentence so the button itself can stay short enough for a narrow card.
1371
+ const sub = req.destination
1372
+ ? `Submitting opens the page for ${escapeHtml(req.destination)} and sends it from there.`
1373
+ : 'Review these details, then submit.';
1064
1374
  const wrap = document.createElement('div');
1065
1375
  wrap.className = 'msg detail';
1066
1376
  wrap.innerHTML = `
@@ -1068,12 +1378,12 @@ class NxisAgentFullscreen extends HTMLElement {
1068
1378
  <div class="dc-badge review">${ICON_ARROW}</div>
1069
1379
  <div class="dc-eyebrow review">Ready to send</div>
1070
1380
  <div class="dc-title">${escapeHtml(humanize(req.tool.name))}</div>
1071
- <div class="dc-sub">Review these details, then submit.</div>
1381
+ <div class="dc-sub">${sub}</div>
1072
1382
  <dl class="dc-list">${rows || '<div class="dc-row"><dd>No details captured.</dd></div>'}</dl>
1073
1383
  <div class="dc-actions">
1074
1384
  <button type="button" class="btn ghost" data-act="cancel">Cancel</button>
1075
1385
  <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>
1386
+ <button type="button" class="btn primary" data-act="submit">${escapeHtml(req.submitLabel ?? 'Submit')} ${ICON_ARROW}</button>
1077
1387
  </div>
1078
1388
  </div>`;
1079
1389
  wrap.querySelector('[data-act="cancel"]').addEventListener('click', () => req.onCancel());
@@ -1281,6 +1591,9 @@ class NxisAgentFullscreen extends HTMLElement {
1281
1591
  submitBtn.disabled = true;
1282
1592
  result.className = 'genform-result';
1283
1593
  result.textContent = 'Submitting…';
1594
+ // Read before the call: a registry may return only the response's text,
1595
+ // and the annotations already say whether this call leaves the page.
1596
+ const leaving = handoffFor(tool, values);
1284
1597
  try {
1285
1598
  this._setStatus(`Running ${tool.name}…`);
1286
1599
  const res = await this.toolSource.execTool(tool, tool.name, values);
@@ -1293,6 +1606,18 @@ class NxisAgentFullscreen extends HTMLElement {
1293
1606
  submitBtn.disabled = false;
1294
1607
  return;
1295
1608
  }
1609
+ // A call that left for another page has filled in nothing yet, so it
1610
+ // must not become a "Submitted" card. The page it is opening finishes
1611
+ // the job and draws the card there.
1612
+ if (leaving || res?.structuredContent?.[HANDOFF_RESULT]) {
1613
+ this._dismissPrompt();
1614
+ // From what we know, not from the response: a registry may hand back
1615
+ // the whole response object as JSON.
1616
+ this._appendMsg('assistant', leaving
1617
+ ? `Opening ${leaving.label} to submit this…`
1618
+ : 'Opening the page that can submit this…');
1619
+ return;
1620
+ }
1296
1621
  // Success → the form transitions inline. A write tool (form/action)
1297
1622
  // becomes a "Submitted" detail card; a read-only tool (typed retrieval)
1298
1623
  // becomes result cards for the records it returned.
@@ -1575,7 +1900,8 @@ class NxisAgentFullscreen extends HTMLElement {
1575
1900
  const cardsHtml = item.cards
1576
1901
  .map((c) => {
1577
1902
  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>`)
1903
+ .map((r) => `<div class="rc-row"><dt title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</dt>` +
1904
+ `<dd title="${escapeHtml(r.value)}">${r.href ? `<a href="${escapeHtml(r.href)}">${escapeHtml(r.value)}</a>` : escapeHtml(r.value)}</dd></div>`)
1579
1905
  .join('');
1580
1906
  const tags = (c.type ? `<span class="tag blue"><i class="dot"></i>${escapeHtml(c.type)}</span>` : '') +
1581
1907
  (c.rating ? `<span class="tag green"><i class="dot"></i>${escapeHtml(c.rating)}</span>` : '') +
@@ -1585,8 +1911,8 @@ class NxisAgentFullscreen extends HTMLElement {
1585
1911
  <div class="rc-chip">${this._logoMarkup()}</div>
1586
1912
  <div class="rc-tags">${tags}</div>
1587
1913
  </div>
1588
- <div class="rc-title">${escapeHtml(c.title)}</div>
1589
- ${c.sub ? `<div class="rc-sub">${escapeHtml(c.sub)}</div>` : ''}
1914
+ <div class="rc-title" title="${escapeHtml(c.title)}">${escapeHtml(c.title)}</div>
1915
+ ${c.sub ? `<div class="rc-sub" title="${escapeHtml(c.sub)}">${escapeHtml(c.sub)}</div>` : ''}
1590
1916
  ${rows ? `<dl class="rc-list">${rows}</dl>` : ''}
1591
1917
  ${c.id ? `<div class="rc-id"><code>${escapeHtml(c.id)}</code></div>` : ''}
1592
1918
  </div>`;
@@ -1997,12 +2323,79 @@ class NxisAgentFullscreen extends HTMLElement {
1997
2323
  el.textContent = `routed to ${item.agent} · ${item.via}`;
1998
2324
  return el;
1999
2325
  }
2326
+ /**
2327
+ * Submit a form that was confirmed on another page.
2328
+ *
2329
+ * An action runs by filling the form on its own page, so a call made
2330
+ * elsewhere was confirmed there and carried here. The values were already
2331
+ * reviewed and pressed, which is why this submits rather than asking again —
2332
+ * the button that started it said it would open this page and submit.
2333
+ *
2334
+ * Nothing waiting is the ordinary case, and costs a single storage read.
2335
+ */
2336
+ async _resumePendingAction() {
2337
+ const outcome = await resumePendingAction({
2338
+ onStart: (pending) => {
2339
+ // Back to the conversation it was started in, so the result lands
2340
+ // under the question that prompted it rather than in a fresh thread.
2341
+ const conv = this._conversations.find((c) => c.id === pending.conversationId);
2342
+ if (conv) {
2343
+ this._activeId = conv.id;
2344
+ this._renderHistory();
2345
+ this._renderThread();
2346
+ }
2347
+ this._open();
2348
+ this._setStatus('Submitting the details you confirmed…');
2349
+ },
2350
+ exec: (tool, values) => this.toolSource.execTool(tool, tool.name, values),
2351
+ });
2352
+ if (outcome.status !== 'none')
2353
+ this._setStatus('');
2354
+ switch (outcome.status) {
2355
+ case 'none':
2356
+ return;
2357
+ // Either way the form is out of reach, and saying which of the two
2358
+ // failed would describe our plumbing rather than their situation.
2359
+ case 'no-registry':
2360
+ case 'no-tool':
2361
+ this._appendMsg('assistant', `I could not find the ${humanize(outcome.pending.pageToolName)} on this page, so ` +
2362
+ 'nothing was submitted. Ask me again and I will fill it in from here.');
2363
+ return;
2364
+ case 'incomplete':
2365
+ this._appendMsg('assistant', `I brought your details over. This form also needs ${outcome.missing
2366
+ .map((m) => humanize(m).toLowerCase())
2367
+ .join(', ')}.`);
2368
+ this._renderForm(outcome.tool, outcome.pending.values);
2369
+ return;
2370
+ case 'rejected':
2371
+ this._appendMsg('assistant', outcome.text || 'That form did not accept the details.');
2372
+ return;
2373
+ case 'failed':
2374
+ this._appendMsg('assistant', `That form could not be submitted: ${outcome.error}`);
2375
+ return;
2376
+ case 'submitted': {
2377
+ const active = this._active();
2378
+ const item = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text);
2379
+ active?.items.push(item);
2380
+ active?.messages.push({
2381
+ role: 'user',
2382
+ content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
2383
+ 'Keep these unless I ask to change them.',
2384
+ });
2385
+ this._els.thread.appendChild(this._detailEl(item));
2386
+ this._saveConversations();
2387
+ this._scroll();
2388
+ }
2389
+ }
2390
+ }
2000
2391
  /**
2001
2392
  * Bring in the site's own MCP server, once.
2002
2393
  *
2003
2394
  * The page's own action tools register themselves. Remote tools do not: they
2004
2395
  * live on an MCP server that must be discovered through
2005
- * `/.well-known/ai-catalog.json` and bridged into the page's registry.
2396
+ * `/.well-known/ai-catalog.json` and bridged into the page's registry. The
2397
+ * same connection lists the actions on the site's other pages, unless
2398
+ * `site-actions="false"`.
2006
2399
  *
2007
2400
  * Runs on first open rather than on connect, so an unopened launcher costs no
2008
2401
  * network request. Failure is neither fatal nor loud: a site with no MCP
@@ -2015,21 +2408,134 @@ class NxisAgentFullscreen extends HTMLElement {
2015
2408
  this._connect = Promise.resolve();
2016
2409
  return this._connect;
2017
2410
  }
2018
- this._connect = connectTools(this.getAttribute('mcp-api-key') || undefined, {
2411
+ // No credential is passed, and the element takes none: a key in markup or
2412
+ // in a page global is served to every visitor and readable by every script
2413
+ // on the origin. Private tools belong behind an endpoint you host — see
2414
+ // `createProxyProvider` for the same decision on the model key.
2415
+ this._connect = connectTools(undefined, {
2019
2416
  // Short, because this blocks the first turn: a provider that has not
2020
2417
  // appeared within a few seconds is not going to.
2021
2418
  timeout: 4000,
2419
+ siteActions: this.getAttribute('site-actions') !== 'false',
2022
2420
  })
2023
- .then(({ remoteTools, warning }) => {
2421
+ .then(({ remoteTools, siteActions, siteTools, warning }) => {
2024
2422
  if (warning)
2025
2423
  console.warn('[nxis-agent]', warning);
2026
- else if (remoteTools.length) {
2424
+ if (remoteTools.length) {
2027
2425
  console.info(`[nxis-agent] discovered remote tools: ${remoteTools.join(', ')}`);
2028
2426
  }
2427
+ if (siteActions.length) {
2428
+ console.info(`[nxis-agent] ${siteActions.length} site action(s) → ` +
2429
+ `${siteTools.length} tool(s): ${siteTools.join(', ')}`);
2430
+ }
2431
+ void this._refreshToolCount();
2432
+ // The page's own script can register after we connect, and a native
2433
+ // registry announces it. Keep the count honest as it does.
2434
+ this.toolSource.modelContext()?.addEventListener('toolchange', () => {
2435
+ void this._refreshToolCount();
2436
+ if (this._toolsOpen)
2437
+ void this._renderTools();
2438
+ });
2029
2439
  })
2030
2440
  .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
2031
2441
  return this._connect;
2032
2442
  }
2443
+ // ── Tools menu ─────────────────────────────────────────────────────────────
2444
+ /** Open or close the list of tools the agent can use. */
2445
+ _toggleTools(open = !this._toolsOpen) {
2446
+ this._toolsOpen = open;
2447
+ this._els.toolsBtn.setAttribute('aria-expanded', String(open));
2448
+ this._els.toolsPop.hidden = !open;
2449
+ if (open)
2450
+ void this._renderTools();
2451
+ }
2452
+ /** The number on the tools button, from what the agent would be offered now. */
2453
+ async _refreshToolCount() {
2454
+ const tools = await this._harness.offeredTools();
2455
+ this._els.toolsCount.textContent = String(tools.length);
2456
+ this._els.toolsCount.hidden = tools.length === 0;
2457
+ this._els.toolsBtn.setAttribute('aria-label', tools.length ? `Tools on this page, ${tools.length}` : 'Tools on this page');
2458
+ }
2459
+ /**
2460
+ * Draw the tools menu.
2461
+ *
2462
+ * Lists what the agent would be offered, not every tool registered: that
2463
+ * is the honest answer to "what can it do here". Grouped by where a tool
2464
+ * reaches — this page's own forms, then what works across the site — which
2465
+ * is what explains why a form from another listing is on the list. Every
2466
+ * row is disabled: the site provides these, and they are not the visitor's
2467
+ * to switch off.
2468
+ */
2469
+ async _renderTools() {
2470
+ const pop = this._els.toolsPop;
2471
+ const head = '<div class="tp-head"><div class="tp-title">What the agent can use here</div>' +
2472
+ '<div class="tp-sub">This site provides these tools. They load with the page and cannot be turned off.</div></div>';
2473
+ pop.innerHTML = `${head}<div class="tp-empty">Looking for tools…</div>`;
2474
+ const tools = await this._harness.offeredTools();
2475
+ if (!this._toolsOpen)
2476
+ return;
2477
+ this._els.toolsCount.textContent = String(tools.length);
2478
+ this._els.toolsCount.hidden = tools.length === 0;
2479
+ pop.innerHTML = head;
2480
+ if (!tools.length) {
2481
+ pop.insertAdjacentHTML('beforeend', '<div class="tp-empty">No tools on this page yet.</div>');
2482
+ return;
2483
+ }
2484
+ const readOnly = (t) => !!t.annotations?.readOnlyHint;
2485
+ const onPage = tools.filter((t) => !isSiteTool(t) && !readOnly(t));
2486
+ const acrossSite = tools.filter((t) => isSiteTool(t) || readOnly(t));
2487
+ const group = (label, list) => {
2488
+ if (!list.length)
2489
+ return;
2490
+ const title = document.createElement('div');
2491
+ title.className = 'tp-grp';
2492
+ title.textContent = label;
2493
+ pop.appendChild(title);
2494
+ for (const tool of list)
2495
+ pop.appendChild(this._toolRow(tool));
2496
+ };
2497
+ group('On this page', onPage);
2498
+ group('Across the site', acrossSite);
2499
+ if (tools.some((t) => !readOnly(t))) {
2500
+ pop.insertAdjacentHTML('beforeend', '<div class="tp-foot">Anything that submits a form asks you first.</div>');
2501
+ }
2502
+ }
2503
+ /** One tool in the menu: what it is, whether it writes, and a lock. */
2504
+ _toolRow(tool) {
2505
+ const site = isSiteTool(tool);
2506
+ const reads = !!tool.annotations?.readOnlyHint;
2507
+ const base = FRIENDLY_TOOL_NAMES[tool.name] ?? humanize(withoutSiteSuffix(tool.name));
2508
+ const name = (base.charAt(0).toUpperCase() + base.slice(1)) + (site ? ' · any page' : '');
2509
+ const desc = (tool.description ?? '').split(/(?<=[.!?])\s/)[0] ?? '';
2510
+ const row = document.createElement('div');
2511
+ row.className = 'tp-tool';
2512
+ row.setAttribute('aria-disabled', 'true');
2513
+ row.title = desc ? `${name} — ${desc}` : name;
2514
+ const icon = document.createElement('span');
2515
+ icon.className = 'tp-ti';
2516
+ icon.innerHTML = site ? ICON_GLOBE : reads ? ICON_SEARCH : ICON_FORM;
2517
+ const text = document.createElement('span');
2518
+ text.className = 'tp-tn';
2519
+ const n = document.createElement('span');
2520
+ n.className = 'tp-name';
2521
+ // Text, not markup: names and descriptions come from the site's data.
2522
+ n.textContent = name;
2523
+ text.appendChild(n);
2524
+ if (desc) {
2525
+ const d = document.createElement('span');
2526
+ d.className = 'tp-desc';
2527
+ d.textContent = desc;
2528
+ text.appendChild(d);
2529
+ }
2530
+ const kind = document.createElement('span');
2531
+ kind.className = 'tp-kind';
2532
+ kind.textContent = reads ? 'reads' : 'asks first';
2533
+ const lock = document.createElement('span');
2534
+ lock.className = 'tp-lock';
2535
+ lock.innerHTML = ICON_LOCK;
2536
+ row.append(icon, text, kind, lock);
2537
+ return row;
2538
+ }
2033
2539
  /**
2034
2540
  * Report a failure in the thread, where it will be seen.
2035
2541
  *
@@ -2175,6 +2681,43 @@ class NxisAgentFullscreen extends HTMLElement {
2175
2681
  *
2176
2682
  * Enabling it affects subsequent turns, not those already recorded.
2177
2683
  */
2684
+ /**
2685
+ * How much of the page the agent takes when open.
2686
+ *
2687
+ * `'fullscreen'` covers the page, which suits a site where the agent is the
2688
+ * experience. `'window'` docks it in the corner at
2689
+ * `--window-w` × `--window-h` (400 × 620 by default), for a site whose own
2690
+ * content should stay visible and usable beside it. Below 520px a window
2691
+ * covers the page anyway, since at that width the two are the same thing.
2692
+ *
2693
+ * `'inline'` fills the box the host puts the element in and is always
2694
+ * open — for a copilot rail or a sidebar the host lays out itself. The
2695
+ * element does not position itself or move the page; the host does both.
2696
+ */
2697
+ get mode() {
2698
+ const raw = this.getAttribute('mode');
2699
+ return raw === 'window' || raw === 'inline' ? raw : 'fullscreen';
2700
+ }
2701
+ set mode(next) {
2702
+ this.setAttribute('mode', next === 'window' || next === 'inline' ? next : 'fullscreen');
2703
+ this._applyMode();
2704
+ }
2705
+ /** Put the overlay in the shape {@link mode} asks for. */
2706
+ _applyMode() {
2707
+ const overlay = this._els?.overlay;
2708
+ if (!overlay)
2709
+ return;
2710
+ const mode = this.mode;
2711
+ overlay.classList.toggle('window', mode === 'window');
2712
+ overlay.classList.toggle('inline', mode === 'inline');
2713
+ if (mode === 'inline') {
2714
+ // Always on screen, so it has to be ready without an open. Tools are
2715
+ // cheap to connect; the model still waits for `preload` or a first
2716
+ // message, so a rail on every page does not cost every visitor 3.4 GB.
2717
+ overlay.classList.add('show');
2718
+ void this._connectTools();
2719
+ }
2720
+ }
2178
2721
  get debug() {
2179
2722
  const raw = (this.getAttribute('debug') || '').toLowerCase();
2180
2723
  if (!this.hasAttribute('debug'))
@@ -2254,10 +2797,18 @@ class NxisAgentFullscreen extends HTMLElement {
2254
2797
  * can be replaced later.
2255
2798
  */
2256
2799
  _paintProvenance() {
2800
+ const local = this.provider?.local ?? true;
2801
+ // Beside the input: which model is answering, and where it runs.
2802
+ const model = this.shadowRoot?.querySelector('.composer .model');
2803
+ if (model) {
2804
+ const label = this.provider?.label || this.provider?.name || 'Model';
2805
+ model.querySelector('.model-name').textContent = label;
2806
+ model.querySelector('.model-where').textContent = local ? 'on-device' : 'remote';
2807
+ model.setAttribute('title', `${label}, ${local ? 'running in this browser' : 'running on a server'}`);
2808
+ }
2257
2809
  const badge = this.shadowRoot?.querySelector('.provenance');
2258
2810
  if (!badge?.lastChild)
2259
2811
  return;
2260
- const local = this.provider?.local ?? true;
2261
2812
  badge.lastChild.textContent = local ? 'On-device' : 'Remote';
2262
2813
  badge.setAttribute('title', local
2263
2814
  ? 'The model runs in this browser. Nothing you type leaves the device.'
@@ -2288,7 +2839,7 @@ class NxisAgentFullscreen extends HTMLElement {
2288
2839
  --ink:#0F172A; --ink-2:#475569; --ink-3:#8A97AC;
2289
2840
  --blue:#2563EB; --blue-strong:#1D4ED8; --blue-tint:#EEF3FF;
2290
2841
  --green:#22C55E; --green-strong:#16A34A; --green-tint:#E8FAEF;
2291
- --inline-bg:#EEF2F8; --red:#B91C1C;
2842
+ --inline-bg:#EEF2F8; --red:#B91C1C; --stop:#DC2626; --stop-strong:#B91C1C;
2292
2843
  --radius:14px; --radius-sm:10px; --radius-pill:999px;
2293
2844
  --shadow-sm:0 1px 2px rgba(15,23,42,.05);
2294
2845
  --shadow:0 1px 2px rgba(15,23,42,.04), 0 10px 30px rgba(15,23,42,.06);
@@ -2336,6 +2887,101 @@ class NxisAgentFullscreen extends HTMLElement {
2336
2887
  .overlay.show { display: flex; animation: fade .2s ease; }
2337
2888
  @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
2338
2889
 
2890
+ /* ── Window mode ────────────────────────────────────────────
2891
+ The same panel, docked in a corner rather than covering the
2892
+ page — for a site that wants an assistant beside its content
2893
+ instead of in front of it. The host sets the size through
2894
+ --window-w and --window-h. */
2895
+ .overlay.window {
2896
+ inset: auto; right: 20px; bottom: 20px;
2897
+ width: min(var(--window-w, 400px), calc(100vw - 32px));
2898
+ height: min(var(--window-h, 620px), calc(100vh - 32px));
2899
+ border: 1px solid var(--border-strong);
2900
+ border-radius: var(--radius);
2901
+ box-shadow: 0 24px 60px rgba(15,23,42,.22);
2902
+ overflow: hidden;
2903
+ }
2904
+ /* ── Inline mode ────────────────────────────────────────────
2905
+ Fills whatever box the host puts it in — a copilot rail, a sidebar,
2906
+ a column — and leaves where that box goes, and what the page does
2907
+ to make room, to the host. Always open: there is no launcher to
2908
+ press and nothing to close back to. */
2909
+ :host([mode="inline"]) { display: block; position: relative; height: 100%; }
2910
+ :host([mode="inline"]) .launcher { display: none; }
2911
+ /* overflow: hidden is what keeps the conversation drawer inside the
2912
+ host's box. Closed, the drawer waits just off the left edge; without
2913
+ the clip it sits on top of the host's page, and pressing the menu
2914
+ only slides it between two visible places. */
2915
+ .overlay.inline { position: absolute; inset: 0; z-index: auto; animation: none; overflow: hidden; }
2916
+ .overlay.inline .close { display: none; }
2917
+
2918
+ /* The conversation list cannot sit beside a narrow thread, so in a
2919
+ window or a rail it behaves as the drawer the narrow layout already
2920
+ defines, whatever the viewport is doing. */
2921
+ .overlay.window .side, .overlay.inline .side {
2922
+ position: absolute; inset: 0 auto 0 0; z-index: 6;
2923
+ transform: translateX(-100%); transition: transform .2s ease;
2924
+ box-shadow: 0 18px 48px rgba(15,23,42,.18);
2925
+ }
2926
+ .overlay.window.nav .side, .overlay.inline.nav .side { transform: none; }
2927
+ .overlay.window.nav .scrim, .overlay.inline.nav .scrim {
2928
+ display: block; position: absolute; inset: 0; z-index: 5;
2929
+ background: rgba(15,23,42,.32);
2930
+ }
2931
+ .overlay.window .menu, .overlay.inline .menu { display: flex; }
2932
+ .overlay.window .msg, .overlay.inline .msg { max-width: 88%; }
2933
+ .overlay.window .hist-del, .overlay.inline .hist-del { opacity: 1; }
2934
+ .overlay.window .main-head, .overlay.inline .main-head { padding: 12px 14px; }
2935
+ .overlay.window .thread, .overlay.inline .thread { padding: 14px; }
2936
+ @media (prefers-reduced-motion: reduce) {
2937
+ .overlay.window .side, .overlay.inline .side { transition: none; }
2938
+ }
2939
+
2940
+ /* ── Journeys ───────────────────────────────────────────────
2941
+ What the agent did about a request, as a card under it. */
2942
+ .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; }
2943
+ .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); }
2944
+ .msg.journey > summary::-webkit-details-marker { display: none; }
2945
+ .msg.journey > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
2946
+ .msg.journey[open] > summary { background: var(--surface); border-bottom: 1px solid var(--border); }
2947
+ .j-title { font-family: var(--display); font-weight: 700; color: var(--ink); white-space: nowrap; }
2948
+ .j-state { margin-left: auto; min-width: 0; font-size: 11.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2949
+ .j-sum-mark { width: 16px; height: 16px; flex: none; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; }
2950
+ .is-running .j-sum-mark { border: 2px solid var(--blue); border-right-color: transparent; animation: jspin 1s linear infinite; }
2951
+ .is-done .j-sum-mark { background: var(--green-tint); color: var(--green-strong); }
2952
+ .is-failed .j-sum-mark { background: rgba(185,28,28,.1); color: var(--red); }
2953
+ .is-failed .j-state { color: var(--red); }
2954
+ @keyframes jspin { to { transform: rotate(360deg); } }
2955
+ .j-steps { list-style: none; margin: 0; padding: 12px; display: grid; }
2956
+ .j-step { display: grid; grid-template-columns: 20px 1fr; gap: 10px; position: relative; padding-bottom: 10px; font-size: 12.5px; }
2957
+ .j-step:last-child { padding-bottom: 0; }
2958
+ .j-step:not(:last-child)::before { content: ''; position: absolute; left: 9px; top: 21px; bottom: 1px; width: 1px; background: var(--border-strong); }
2959
+ .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; }
2960
+ .j-step.is-done .j-mark { background: var(--green-tint); border-color: var(--green-strong); color: var(--green-strong); }
2961
+ .j-step.is-active .j-mark { background: var(--blue); border-color: var(--blue); color: #fff; }
2962
+ .j-step.is-failed .j-mark { background: rgba(185,28,28,.1); border-color: var(--red); color: var(--red); }
2963
+ .j-label { color: var(--ink-2); line-height: 1.35; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2964
+ .j-label small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2965
+ .j-step:hover .j-label, .j-step:hover .j-label small { white-space: normal; overflow: visible; }
2966
+ .j-step.is-done .j-label, .j-step.is-active .j-label { color: var(--ink); }
2967
+ .j-step.is-active .j-label { font-weight: 600; }
2968
+ .j-label small { display: block; font-size: 11px; font-weight: 400; color: var(--ink-3); margin-top: 1px; }
2969
+
2970
+ /* The running step, while its card is scrolled out of view. */
2971
+ .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; }
2972
+ .jstatus[hidden] { display: none; }
2973
+ .jstatus:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
2974
+ .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; }
2975
+ .js-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2976
+ .js-go { margin-left: auto; font-weight: 500; color: var(--blue-strong); }
2977
+ .overlay.window .jstatus, .overlay.inline .jstatus { padding: 8px 14px; }
2978
+ @media (prefers-reduced-motion: reduce) { .is-running .j-sum-mark, .js-spin { animation: none; } }
2979
+ /* Narrower than the card, a card is the viewport: drop the chrome
2980
+ rather than leaving a 16px frame around a full-height panel. */
2981
+ @media (max-width: 520px) {
2982
+ .overlay.window { inset: 0; width: auto; height: auto; border: 0; border-radius: 0; box-shadow: none; }
2983
+ }
2984
+
2339
2985
  /* ── Side panel ─────────────────────────────────────────────── */
2340
2986
  .scrim { display: none; }
2341
2987
  .side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-right: 1px solid var(--border); }
@@ -2557,24 +3203,66 @@ class NxisAgentFullscreen extends HTMLElement {
2557
3203
 
2558
3204
  .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
3205
  .statusline.err { color: var(--red); }
2560
- .composer { display: flex; align-items: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border); }
3206
+ /* ── Composer: the input, with a bar beneath it ─────────────
3207
+ Tools on the left, the model beside them, send or stop on the
3208
+ right. One box around both, so the bar reads as part of the input
3209
+ rather than a second control strip. */
3210
+ .composer { padding: 12px 20px 14px; border-top: 1px solid var(--border); }
3211
+ .overlay.window .composer, .overlay.inline .composer { padding: 10px 12px 12px; }
3212
+ .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; }
3213
+ .cbox:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
2561
3214
  .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;
3215
+ display: block; width: 100%; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); resize: none;
3216
+ background: transparent; border: 0; padding: 2px 2px 8px; outline: none; max-height: 140px; overflow-y: auto;
2565
3217
  }
2566
- .composer textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-tint); }
2567
3218
  .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); }
3219
+ .cbar { display: flex; align-items: center; gap: 8px; min-width: 0; }
3220
+ .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); }
2569
3221
  .composer .go:hover { background: var(--blue-strong); }
2570
3222
  .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); }
3223
+ /* Stop is red, with a filled white square: while a prompt runs this is
3224
+ the one control that matters, and it has to be found at a glance. */
3225
+ .composer .go.stop { background: var(--stop); color: #fff; border: 0; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
3226
+ .composer .go.stop:hover { background: var(--stop-strong); }
2577
3227
  .composer .go.stop svg { width: 15px; height: 15px; }
3228
+ .composer .go.stop svg path { fill: currentColor; }
3229
+ .composer .go:focus-visible, .tools-btn:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
3230
+
3231
+ .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; }
3232
+ .model-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--green); }
3233
+ .model.busy .model-dot { background: var(--blue); animation: mpulse 1.2s ease-in-out infinite; }
3234
+ @keyframes mpulse { 50% { opacity: .35; } }
3235
+ .model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
3236
+ .model-where { flex: none; font-family: var(--mono); font-size: 10px; letter-spacing: .04em; color: var(--ink-3); }
3237
+
3238
+ /* ── Tools menu ── */
3239
+ .tools { position: relative; flex: none; }
3240
+ .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; }
3241
+ .tools-btn:hover { color: var(--ink); border-color: var(--ink-3); }
3242
+ .tools-btn[aria-expanded="true"] { border-color: var(--blue); color: var(--blue); background: var(--blue-tint); }
3243
+ .tools-btn svg { width: 17px; height: 17px; }
3244
+ .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); }
3245
+ .tools-count[hidden] { display: none; }
3246
+ .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; }
3247
+ .tools-pop[hidden] { display: none; }
3248
+ .overlay.window .tools-pop, .overlay.inline .tools-pop { width: 296px; }
3249
+ .tp-head { padding: 8px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
3250
+ .tp-title { font-family: var(--display); font-weight: 700; font-size: 13px; color: var(--ink); }
3251
+ .tp-sub { font-size: 11.5px; line-height: 1.4; color: var(--ink-3); margin-top: 2px; }
3252
+ .tp-grp { font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); padding: 8px 10px 3px; }
3253
+ /* Greyed and locked: visible, not the visitor's to change. */
3254
+ .tp-tool { display: flex; align-items: center; gap: 9px; padding: 6px 10px; border-radius: 8px; cursor: default; }
3255
+ .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; }
3256
+ .tp-ti svg { width: 13px; height: 13px; }
3257
+ .tp-tn { min-width: 0; flex: 1; }
3258
+ .tp-name { display: block; font-size: 12.5px; font-weight: 500; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3259
+ .tp-desc { display: block; font-size: 11px; color: var(--ink-3); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3260
+ .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; }
3261
+ .tp-lock { flex: none; width: 13px; height: 13px; color: var(--ink-3); opacity: .7; display: grid; }
3262
+ .tp-lock svg { width: 13px; height: 13px; }
3263
+ .tp-empty { padding: 12px 10px; font-size: 12px; color: var(--ink-3); }
3264
+ .tp-foot { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--border); font-size: 11px; color: var(--ink-3); }
3265
+ @media (prefers-reduced-motion: reduce) { .model.busy .model-dot { animation: none; } }
2578
3266
  /* A message the person sent, plus the row of things they can do to it.
2579
3267
  The wrapper carries the alignment the bubble used to carry on its own. */
2580
3268
  .turn { display: flex; flex-direction: column; align-items: flex-end; }
@@ -2709,8 +3397,36 @@ class NxisAgentFullscreen extends HTMLElement {
2709
3397
  .dc-eyebrow.review { color: var(--blue-strong); }
2710
3398
  .dc-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
2711
3399
  .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; }
3400
+ /* One line per row, truncated. A card in a window or a rail is too
3401
+ narrow for a long value to wrap without becoming a column of
3402
+ fragments. The whole row comes back on hover or keyboard focus, and
3403
+ every cell carries its full text as a title too. */
3404
+ .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; }
3405
+ .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; }
3406
+ .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; }
3407
+ .dc-row:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 4px; }
3408
+ /* Buttons keep their words on one line and wrap as whole buttons. */
3409
+ .dc-actions { flex-wrap: wrap; }
3410
+ .dc-actions .btn { white-space: nowrap; }
3411
+ .overlay.window .dc-actions .btn, .overlay.inline .dc-actions .btn { padding: 9px 14px; font-size: 13px; }
3412
+
3413
+ /* Code inside a message: monospace, and scrolled rather than wrapped,
3414
+ so a long line does not break at arbitrary places. */
3415
+ .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%; }
3416
+ .msg .codeblock:first-child { margin-top: 0; }
3417
+ .msg .codeblock code { font-family: var(--mono); font-size: 12.5px; line-height: 1.55; white-space: pre; color: var(--ink); }
3418
+ .msg.user .codeblock { background: rgba(255,255,255,.16); border-color: rgba(255,255,255,.28); }
3419
+ .msg.user .codeblock code { color: #fff; }
3420
+
3421
+ /* A long block folds away: kept, but not filling the thread. */
3422
+ .codefold { margin-top: 6px; }
3423
+ .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; }
3424
+ .codefold > summary::-webkit-details-marker { display: none; }
3425
+ .codefold > summary::before { content: '▸ '; }
3426
+ .codefold[open] > summary::before { content: '▾ '; }
3427
+ .codefold > summary:hover { color: var(--ink-2); }
3428
+ .codefold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }
3429
+ .codefold .codeblock { margin-top: 4px; }
2714
3430
 
2715
3431
  /* ── Inline result cards — feature-card pattern: tinted icon chip,
2716
3432
  tags, tight Manrope headline, one supporting line. ─────── */
@@ -2729,8 +3445,15 @@ class NxisAgentFullscreen extends HTMLElement {
2729
3445
  .rc-sub { font-size: 13px; line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
2730
3446
  .rc-list { margin: 10px 0 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; }
2731
3447
  .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; }
3448
+ .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; }
3449
+ .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; }
3450
+ .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; }
3451
+ /* The headline and its supporting line truncate too, and come back
3452
+ when the card is hovered or focused. */
3453
+ .rc-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3454
+ .rc-sub { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
3455
+ .resultcard:hover .rc-title, .resultcard:focus-within .rc-title { white-space: normal; overflow: visible; }
3456
+ .resultcard:hover .rc-sub, .resultcard:focus-within .rc-sub { -webkit-line-clamp: unset; overflow: visible; }
2734
3457
  .rc-id { margin-top: 10px; min-width: 0; }
2735
3458
  .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
3459
  .rc-row dd a { color: var(--blue-strong); text-decoration: none; }
@@ -2802,11 +3525,23 @@ class NxisAgentFullscreen extends HTMLElement {
2802
3525
  <button class="close" aria-label="Close">×</button>
2803
3526
  </div>
2804
3527
  <div class="prog"><i></i></div>
3528
+ <button class="jstatus" type="button" hidden>
3529
+ <span class="js-spin" aria-hidden="true"></span><span class="js-label"></span><span class="js-go">Show</span>
3530
+ </button>
2805
3531
  <div class="thread"></div>
2806
3532
  <div class="statusline"></div>
2807
3533
  <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>
3534
+ <div class="cbox">
3535
+ <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
3536
+ <div class="cbar">
3537
+ <div class="tools">
3538
+ <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>
3539
+ <div class="tools-pop" role="dialog" aria-label="What the agent can use here" hidden></div>
3540
+ </div>
3541
+ <span class="model"><i class="model-dot" aria-hidden="true"></i><span class="model-name"></span><span class="model-where"></span></span>
3542
+ <button class="go" aria-label="Send">${ICON_ARROW}</button>
3543
+ </div>
3544
+ </div>
2810
3545
  </div>
2811
3546
  <footer class="foot">
2812
3547
  <a class="powered" href="${NXIS_SITE_URL}" target="_blank" rel="noopener noreferrer">