@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.
- package/README.md +94 -6
- package/dist/a2ui/stepper.d.ts +1 -1
- package/dist/a2ui/stepper.d.ts.map +1 -1
- package/dist/a2ui/stepper.js +2 -2
- package/dist/a2ui/stepper.js.map +1 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/harness/harness.d.ts +38 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +127 -4
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/renderer.d.ts +30 -0
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/steps.d.ts +101 -0
- package/dist/harness/steps.d.ts.map +1 -0
- package/dist/harness/steps.js +102 -0
- package/dist/harness/steps.js.map +1 -0
- package/dist/harness/tools/action-tools.d.ts +213 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -0
- package/dist/harness/tools/action-tools.js +724 -0
- package/dist/harness/tools/action-tools.js.map +1 -0
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +28 -2
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/discovery.d.ts +2 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +1 -1
- package/dist/harness/tools/discovery.js.map +1 -1
- package/dist/harness/tools/pending-action.d.ts +55 -0
- package/dist/harness/tools/pending-action.d.ts.map +1 -0
- package/dist/harness/tools/pending-action.js +93 -0
- package/dist/harness/tools/pending-action.js.map +1 -0
- package/dist/harness/tools/resume.d.ts +92 -0
- package/dist/harness/tools/resume.d.ts.map +1 -0
- package/dist/harness/tools/resume.js +84 -0
- package/dist/harness/tools/resume.js.map +1 -0
- package/dist/harness/tools/schema.js +29 -2
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +69 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -0
- package/dist/harness/tools/site-actions.js +310 -0
- package/dist/harness/tools/site-actions.js.map +1 -0
- package/dist/harness/tools/source.d.ts +44 -5
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +58 -9
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/proxy.d.ts +5 -0
- package/dist/providers/proxy.d.ts.map +1 -1
- package/dist/providers/proxy.js +16 -1
- package/dist/providers/proxy.js.map +1 -1
- package/dist/providers/types.d.ts +15 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/types.d.ts +7 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +771 -36
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/journey.d.ts +33 -0
- package/dist/ui/journey.d.ts.map +1 -0
- package/dist/ui/journey.js +27 -0
- package/dist/ui/journey.js.map +1 -0
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/dist/webmcp-contract.d.ts +6 -2
- package/dist/webmcp-contract.d.ts.map +1 -1
- package/docs/remote-providers.md +196 -0
- package/docs/single-vs-multi-agent-dark.svg +78 -0
- package/docs/single-vs-multi-agent.svg +78 -0
- package/docs/turn-routing-dark.svg +52 -0
- package/docs/turn-routing.svg +52 -0
- 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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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) =>
|
|
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) =>
|
|
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"
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2563
|
-
background:
|
|
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
|
-
.
|
|
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
|
-
/*
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
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
|
-
|
|
2713
|
-
|
|
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-
|
|
2733
|
-
.rc-row dd { margin: 0; color: var(--ink); text-align: right;
|
|
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
|
-
<
|
|
2809
|
-
|
|
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">
|