@nxisai/agent-harness 0.5.0 → 0.7.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 +38 -4
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +155 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +53 -12
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/renderer.d.ts +20 -0
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +29 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +64 -4
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +27 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +47 -0
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +5 -2
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/action-card.d.ts +141 -0
- package/dist/ui/action-card.d.ts.map +1 -0
- package/dist/ui/action-card.js +384 -0
- package/dist/ui/action-card.js.map +1 -0
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +795 -17
- package/dist/ui/agent-fullscreen.js.map +1 -1
- 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/package.json +1 -1
- package/src/handoff/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +182 -0
- package/src/harness/harness.ts +55 -14
- package/src/harness/renderer.ts +22 -0
- package/src/harness/router/coordinator.ts +21 -0
- package/src/harness/tools/action-tools.ts +74 -4
- package/src/harness/tools/discovery.ts +20 -2
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/source.ts +5 -2
- package/src/index.ts +4 -1
- package/src/ui/action-card.ts +496 -0
- package/src/ui/agent-fullscreen.ts +842 -16
- package/src/ui/tailwind.generated.ts +1 -1
|
@@ -41,18 +41,40 @@ import {
|
|
|
41
41
|
import type { ModelProvider } from '../providers/types.js';
|
|
42
42
|
import { connectTools } from '../harness/tools/source.js';
|
|
43
43
|
import {
|
|
44
|
+
chooserOf,
|
|
45
|
+
formOptions,
|
|
44
46
|
handoffFor,
|
|
45
47
|
isSiteTool,
|
|
46
48
|
withoutSiteSuffix,
|
|
47
49
|
HANDOFF_RESULT,
|
|
48
50
|
} from '../harness/tools/action-tools.js';
|
|
51
|
+
import { resolveSlots, type Slot } from '../harness/slots.js';
|
|
52
|
+
import { isHandoffTool } from '../handoff/tools.js';
|
|
53
|
+
import {
|
|
54
|
+
actionCard,
|
|
55
|
+
fieldLabel,
|
|
56
|
+
fieldsFromSlots,
|
|
57
|
+
ACTION_CARD_CSS,
|
|
58
|
+
type ActionHandlers,
|
|
59
|
+
type ActionItem,
|
|
60
|
+
type ActionStep,
|
|
61
|
+
} from './action-card.js';
|
|
49
62
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
50
63
|
import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
|
|
51
64
|
import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
|
|
52
65
|
import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
53
|
-
import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
|
|
66
|
+
import { currentJourney, onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
|
|
54
67
|
import { viewJourney } from './journey.js';
|
|
55
68
|
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
69
|
+
import {
|
|
70
|
+
HandoffController,
|
|
71
|
+
isHandoffOver,
|
|
72
|
+
type HandoffConfig,
|
|
73
|
+
type HandoffEvent,
|
|
74
|
+
type HandoffSession,
|
|
75
|
+
type HandoffTarget,
|
|
76
|
+
} from '../handoff/controller.js';
|
|
77
|
+
import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
|
|
56
78
|
import type { RouteRule } from '../harness/router/rules.js';
|
|
57
79
|
import type { AskRequest, ConfirmRequest, HarnessRenderer, StreamingBubble as HStreamingBubble } from '../harness/renderer.js';
|
|
58
80
|
import {
|
|
@@ -143,6 +165,12 @@ const fmt = (n: number) => n.toLocaleString();
|
|
|
143
165
|
* be disabled under `prefers-reduced-motion` while the bubble still reads as
|
|
144
166
|
* pending. The label and byte spans are filled per phase and hidden while empty.
|
|
145
167
|
*/
|
|
168
|
+
/**
|
|
169
|
+
* How long the running-step line stays once the conversation moves on past
|
|
170
|
+
* its card. A glance's worth: long enough to notice and open it.
|
|
171
|
+
*/
|
|
172
|
+
const JSTATUS_TIMEOUT_MS = 10_000;
|
|
173
|
+
|
|
146
174
|
const THINKING_MARKUP =
|
|
147
175
|
'<span class="think-row">' +
|
|
148
176
|
'<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
|
@@ -319,7 +347,44 @@ type ThreadItem =
|
|
|
319
347
|
* so a second request does not erase the first, and so a card survives the
|
|
320
348
|
* page change a cross-page action makes.
|
|
321
349
|
*/
|
|
322
|
-
| { kind: 'journey'; id: string; steps: AgentStep[] }
|
|
350
|
+
| { kind: 'journey'; id: string; steps: AgentStep[] }
|
|
351
|
+
/** Something a handoff target said: an agent, or a person at a desk. */
|
|
352
|
+
| { kind: 'remote'; from: string; fromKind: 'agent' | 'person'; text: string }
|
|
353
|
+
/** Where a handoff stands, as a quiet line in the thread. */
|
|
354
|
+
| { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' }
|
|
355
|
+
/** A form or a handoff, asked for, confirmed and sent in one card. See ./action-card.ts. */
|
|
356
|
+
| ActionItem;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* A handoff's state as a line in the thread, or '' for a state the bar alone
|
|
360
|
+
* shows.
|
|
361
|
+
*/
|
|
362
|
+
function handoffLine(session: HandoffSession): string {
|
|
363
|
+
const who = session.label;
|
|
364
|
+
switch (session.state) {
|
|
365
|
+
case 'connecting':
|
|
366
|
+
return `Connecting you to ${who}…`;
|
|
367
|
+
case 'queued':
|
|
368
|
+
// The bar says where they are in line; a desk usually says it in words too.
|
|
369
|
+
return '';
|
|
370
|
+
case 'active':
|
|
371
|
+
return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
|
|
372
|
+
case 'awaiting-visitor':
|
|
373
|
+
return '';
|
|
374
|
+
case 'handed-back':
|
|
375
|
+
return 'Back with the assistant';
|
|
376
|
+
case 'ended':
|
|
377
|
+
return `${who} has finished. Back with the assistant.`;
|
|
378
|
+
case 'failed':
|
|
379
|
+
return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function ordinal(n: number): string {
|
|
384
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
385
|
+
const v = n % 100;
|
|
386
|
+
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
|
|
387
|
+
}
|
|
323
388
|
|
|
324
389
|
interface Conversation {
|
|
325
390
|
id: string;
|
|
@@ -344,6 +409,10 @@ interface FullscreenEls {
|
|
|
344
409
|
bar: HTMLElement;
|
|
345
410
|
/** Names the running step while its card is scrolled out of view. */
|
|
346
411
|
jstatus: HTMLButtonElement;
|
|
412
|
+
/** Shown while the conversation is handed off: who to, and the way back. */
|
|
413
|
+
handoffBar: HTMLElement;
|
|
414
|
+
handoffLabel: HTMLElement;
|
|
415
|
+
handoffBack: HTMLButtonElement;
|
|
347
416
|
input: HTMLTextAreaElement;
|
|
348
417
|
go: HTMLButtonElement;
|
|
349
418
|
/** Opens the list of tools the agent can use. */
|
|
@@ -410,6 +479,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
410
479
|
};
|
|
411
480
|
/** Watches whether the running journey's card is on screen. */
|
|
412
481
|
private _journeyObserver: IntersectionObserver | null = null;
|
|
482
|
+
/** The journey the status line is about, while one is running. */
|
|
483
|
+
private _jstatusJourney: string | null = null;
|
|
484
|
+
/** Journeys whose status line was opened once or timed out: it does not come back. */
|
|
485
|
+
private _jstatusDismissed = new Set<string>();
|
|
486
|
+
/** Dismisses the status line a while after the thread moves on past the card. */
|
|
487
|
+
private _jstatusTimer: ReturnType<typeof setTimeout> | null = null;
|
|
413
488
|
/** The turn loop. This element is only its renderer. */
|
|
414
489
|
private _harness!: AgentHarness;
|
|
415
490
|
/** Built only when the host defines agents — otherwise there is one harness. */
|
|
@@ -419,6 +494,22 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
419
494
|
private _fallbackAgent = '';
|
|
420
495
|
/** The live ask/confirm surface, if one is open. */
|
|
421
496
|
private _prompt: MountedSurface | null = null;
|
|
497
|
+
/**
|
|
498
|
+
* The action card still waiting on the visitor or a result, and what it can
|
|
499
|
+
* do: the harness's ask or confirm request, or, after a page change, a call
|
|
500
|
+
* of the widget's own.
|
|
501
|
+
*/
|
|
502
|
+
private _live: {
|
|
503
|
+
item: ActionItem;
|
|
504
|
+
tool: ModelContextToolInfo;
|
|
505
|
+
ask?: AskRequest;
|
|
506
|
+
confirm?: ConfirmRequest;
|
|
507
|
+
run?: (values: Record<string, unknown>) => Promise<void>;
|
|
508
|
+
sent: boolean;
|
|
509
|
+
declining: boolean;
|
|
510
|
+
} | null = null;
|
|
511
|
+
/** Handoff targets as the relay last reported them, for a card's availability line. */
|
|
512
|
+
private _handoffTargets = new Map<string, HandoffTarget>();
|
|
422
513
|
private _profile: Record<string, string>;
|
|
423
514
|
private _persistPrefs = false;
|
|
424
515
|
private _systemPrompt: string = DEFAULT_SYSTEM_PROMPT;
|
|
@@ -455,6 +546,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
455
546
|
private toolSource!: WebMcpTools;
|
|
456
547
|
/** A provider the host supplied, used instead of the local model. */
|
|
457
548
|
private _provider: ModelProvider | null = null;
|
|
549
|
+
/** Runs handoffs to agents and people, when the host configured any. */
|
|
550
|
+
private _handoff: HandoffController | null = null;
|
|
551
|
+
private _offHandoff: (() => void) | null = null;
|
|
458
552
|
|
|
459
553
|
constructor() {
|
|
460
554
|
super();
|
|
@@ -489,6 +583,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
489
583
|
status: root.querySelector('.statusline')!,
|
|
490
584
|
bar: root.querySelector('.prog > i')!,
|
|
491
585
|
jstatus: root.querySelector('.jstatus')!,
|
|
586
|
+
handoffBar: root.querySelector('.handoff-bar')!,
|
|
587
|
+
handoffLabel: root.querySelector('.handoff-bar .hb-label')!,
|
|
588
|
+
handoffBack: root.querySelector('.handoff-bar .hb-back')!,
|
|
492
589
|
input: root.querySelector('.composer textarea')!,
|
|
493
590
|
go: root.querySelector('.composer .go')!,
|
|
494
591
|
toolsBtn: root.querySelector('.tools-btn')!,
|
|
@@ -526,13 +623,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
526
623
|
|
|
527
624
|
// The element renders; the harness decides. Everything from choosing a
|
|
528
625
|
// tool to running it happens behind harness.turn().
|
|
529
|
-
this._harness =
|
|
530
|
-
provider: this.provider,
|
|
531
|
-
toolSource: this.toolSource,
|
|
532
|
-
renderer: this._renderer(),
|
|
533
|
-
profile: (schema) => this._profileForSchema(schema as JsonSchema),
|
|
534
|
-
onMessages: () => this._saveConversations(),
|
|
535
|
-
});
|
|
626
|
+
this._harness = this._singleHarness();
|
|
536
627
|
|
|
537
628
|
// Preferences are supplied by the host page through `element.preferences`.
|
|
538
629
|
// Anything a previous build persisted is picked up so upgrading does not
|
|
@@ -567,6 +658,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
567
658
|
this._els.jstatus.addEventListener('click', () => {
|
|
568
659
|
const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
|
|
569
660
|
cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
661
|
+
// One look is what it is for; after that it would only be in the way.
|
|
662
|
+
this._dismissJourneyStatus();
|
|
570
663
|
});
|
|
571
664
|
|
|
572
665
|
// Whoever builds the tools, a call that leaves this page reports back to
|
|
@@ -579,6 +672,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
579
672
|
this._resumePendingVisit();
|
|
580
673
|
|
|
581
674
|
this._els.launcher.addEventListener('click', () => this._open());
|
|
675
|
+
this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
|
|
676
|
+
// A handoff left open on the last page carries on here.
|
|
677
|
+
this._paintHandoffBar();
|
|
678
|
+
if (this._activeId) void this._handoff?.resume(this._activeId);
|
|
582
679
|
this._els.close.addEventListener('click', () => {
|
|
583
680
|
this._els.overlay.classList.remove('show');
|
|
584
681
|
// A remote turn can outlive the panel, and its answer would go unread.
|
|
@@ -658,6 +755,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
658
755
|
this._offJourney = null;
|
|
659
756
|
this._journeyObserver?.disconnect();
|
|
660
757
|
this._journeyObserver = null;
|
|
758
|
+
this._clearJourneyStatusTimer();
|
|
759
|
+
// Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
|
|
760
|
+
this._handoff?.dispose();
|
|
661
761
|
}
|
|
662
762
|
|
|
663
763
|
/** Programmatic open (used by pages that dock the agent full-page). */
|
|
@@ -729,6 +829,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
729
829
|
this._renderHistory();
|
|
730
830
|
this._renderThread();
|
|
731
831
|
this._saveConversations();
|
|
832
|
+
this._paintHandoffBar();
|
|
732
833
|
}
|
|
733
834
|
if (this._els.input) this._els.input.focus();
|
|
734
835
|
}
|
|
@@ -742,6 +843,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
742
843
|
this._setNav(false);
|
|
743
844
|
this._renderHistory();
|
|
744
845
|
this._renderThread();
|
|
846
|
+
this._paintHandoffBar();
|
|
847
|
+
void this._handoff?.resume(id);
|
|
745
848
|
}
|
|
746
849
|
|
|
747
850
|
private _renderHistory(): void {
|
|
@@ -841,7 +944,11 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
841
944
|
else if (it.kind === 'detail') this._els.thread.appendChild(this._detailEl(it));
|
|
842
945
|
else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
|
|
843
946
|
else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
|
|
844
|
-
else if (it.kind === 'journey')
|
|
947
|
+
else if (it.kind === 'journey') {
|
|
948
|
+
// A request drawn as an action card shows its own steps.
|
|
949
|
+
if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id)) this._els.thread.appendChild(this._journeyEl(it));
|
|
950
|
+
} else if (it.kind === 'action') this._paintAction(it);
|
|
951
|
+
else if (it.kind === 'remote' || it.kind === 'handoff') this._paintHandoffItem(it);
|
|
845
952
|
}
|
|
846
953
|
this._scroll();
|
|
847
954
|
this._watchRunningJourney();
|
|
@@ -1225,6 +1332,15 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1225
1332
|
conv.items.push(item);
|
|
1226
1333
|
}
|
|
1227
1334
|
|
|
1335
|
+
// A request with an action card: the steps belong in the card.
|
|
1336
|
+
const action = conv.items.find((it): it is ActionItem => it.kind === 'action' && it.id === view.id);
|
|
1337
|
+
if (action) {
|
|
1338
|
+
this._actionFollows(action, all);
|
|
1339
|
+
this._watchRunningJourney();
|
|
1340
|
+
this._saveConversations();
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1228
1344
|
if (conv.id === this._activeId && this._els?.thread) {
|
|
1229
1345
|
const fresh = this._journeyEl(item);
|
|
1230
1346
|
const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
|
|
@@ -1256,7 +1372,12 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1256
1372
|
if (!bar) return;
|
|
1257
1373
|
const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
|
|
1258
1374
|
const card = cards[cards.length - 1];
|
|
1259
|
-
|
|
1375
|
+
const id = card?.dataset.journey ?? null;
|
|
1376
|
+
if (id !== this._jstatusJourney) {
|
|
1377
|
+
this._clearJourneyStatusTimer();
|
|
1378
|
+
this._jstatusJourney = id;
|
|
1379
|
+
}
|
|
1380
|
+
if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
|
|
1260
1381
|
bar.hidden = true;
|
|
1261
1382
|
return;
|
|
1262
1383
|
}
|
|
@@ -1270,6 +1391,36 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1270
1391
|
this._journeyObserver.observe(card);
|
|
1271
1392
|
}
|
|
1272
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* The thread grew: start the clock on the status line.
|
|
1396
|
+
*
|
|
1397
|
+
* A journey can stay running while the conversation goes on without it, and a
|
|
1398
|
+
* line that spins through all of that says nothing. So once new messages
|
|
1399
|
+
* arrive, the line gets a while longer, then goes.
|
|
1400
|
+
*/
|
|
1401
|
+
private _noteThreadGrew(): void {
|
|
1402
|
+
const id = this._jstatusJourney;
|
|
1403
|
+
if (!id || this._jstatusTimer || this._jstatusDismissed.has(id)) return;
|
|
1404
|
+
this._jstatusTimer = setTimeout(() => {
|
|
1405
|
+
this._jstatusTimer = null;
|
|
1406
|
+
this._dismissJourneyStatus();
|
|
1407
|
+
}, JSTATUS_TIMEOUT_MS);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** Hide the status line for the running journey, for good. */
|
|
1411
|
+
private _dismissJourneyStatus(): void {
|
|
1412
|
+
this._clearJourneyStatusTimer();
|
|
1413
|
+
if (this._jstatusJourney) this._jstatusDismissed.add(this._jstatusJourney);
|
|
1414
|
+
this._journeyObserver?.disconnect();
|
|
1415
|
+
this._journeyObserver = null;
|
|
1416
|
+
if (this._els?.jstatus) this._els.jstatus.hidden = true;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
private _clearJourneyStatusTimer(): void {
|
|
1420
|
+
if (this._jstatusTimer) clearTimeout(this._jstatusTimer);
|
|
1421
|
+
this._jstatusTimer = null;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1273
1424
|
private _paintToolResult(name: string, text: string, isError: boolean): HTMLElement {
|
|
1274
1425
|
this._clearEmpty();
|
|
1275
1426
|
const el = this._toolEl(name, text, isError);
|
|
@@ -1328,6 +1479,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1328
1479
|
const el = this._paintMsg(role, text, conv.items.length - 1);
|
|
1329
1480
|
this._saveConversations();
|
|
1330
1481
|
this._scroll();
|
|
1482
|
+
this._noteThreadGrew();
|
|
1331
1483
|
return el;
|
|
1332
1484
|
}
|
|
1333
1485
|
|
|
@@ -1348,6 +1500,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1348
1500
|
// without the focus moving to it.
|
|
1349
1501
|
el.setAttribute('aria-live', 'polite');
|
|
1350
1502
|
this._scroll();
|
|
1503
|
+
this._noteThreadGrew();
|
|
1351
1504
|
|
|
1352
1505
|
const setLabel = (label?: string) => {
|
|
1353
1506
|
const node = el.querySelector('.think-label');
|
|
@@ -1438,6 +1591,18 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1438
1591
|
this._saveConversations();
|
|
1439
1592
|
}
|
|
1440
1593
|
|
|
1594
|
+
// Handed off: this message is for the target, not the assistant.
|
|
1595
|
+
const handed = this._handoff?.active(this._activeId ?? undefined);
|
|
1596
|
+
if (handed && this._activeId) {
|
|
1597
|
+
try {
|
|
1598
|
+
await this._handoff!.send(this._activeId, goal);
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
const why = this._handoff!.session(this._activeId)?.error ?? String((err as Error)?.message || err);
|
|
1601
|
+
this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
|
|
1602
|
+
}
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1441
1606
|
this._turnActive = true;
|
|
1442
1607
|
this._setStoppable(true);
|
|
1443
1608
|
let settle!: () => void;
|
|
@@ -1477,8 +1642,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1477
1642
|
message: (role, text) => this._appendMsg(role, text),
|
|
1478
1643
|
streaming: (): HStreamingBubble => this._appendStreaming(),
|
|
1479
1644
|
status: (text, isError) => this._setStatus(text, isError),
|
|
1480
|
-
|
|
1481
|
-
|
|
1645
|
+
inlinePrompts: !this._classicPrompts,
|
|
1646
|
+
ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
|
|
1647
|
+
confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
|
|
1648
|
+
left: (tool, destination) => this._cardLeft(tool, destination),
|
|
1482
1649
|
results: (tool, res) => {
|
|
1483
1650
|
const conv = this._active();
|
|
1484
1651
|
const item = this._buildResultsItem(tool, res);
|
|
@@ -1500,6 +1667,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1500
1667
|
this._scroll();
|
|
1501
1668
|
},
|
|
1502
1669
|
detail: (tool, values, text) => {
|
|
1670
|
+
if (this._cardDone(tool, values, text)) return;
|
|
1503
1671
|
const conv = this._active();
|
|
1504
1672
|
const item = this._buildDetailItem(tool, values, text);
|
|
1505
1673
|
conv?.items.push(item);
|
|
@@ -1523,8 +1691,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1523
1691
|
this._saveConversations();
|
|
1524
1692
|
this._scroll();
|
|
1525
1693
|
},
|
|
1526
|
-
toolError: (tool, message) =>
|
|
1527
|
-
|
|
1694
|
+
toolError: (tool, message) => {
|
|
1695
|
+
if (this._cardFailed(tool, message)) return;
|
|
1696
|
+
this._paintToolResult(tool.name, message, true);
|
|
1697
|
+
},
|
|
1698
|
+
dismissPrompt: () => {
|
|
1699
|
+
// A sent card stays, and shows the steps; one never sent says so.
|
|
1700
|
+
const live = this._live;
|
|
1701
|
+
if (live) {
|
|
1702
|
+
if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
|
|
1703
|
+
live.item.stage = 'working';
|
|
1704
|
+
this._paintAction(live.item);
|
|
1705
|
+
this._saveConversations();
|
|
1706
|
+
}
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
this._dismissPrompt();
|
|
1710
|
+
},
|
|
1528
1711
|
// The harness says when stopping stops being possible, because it is the
|
|
1529
1712
|
// part that knows a tool has been invoked. Once a call is out, offering
|
|
1530
1713
|
// to stop would be offering to un-send a booking.
|
|
@@ -1673,6 +1856,390 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1673
1856
|
this._scroll();
|
|
1674
1857
|
}
|
|
1675
1858
|
|
|
1859
|
+
// ── Action cards ─────────────────────────────────────────────────────────────
|
|
1860
|
+
|
|
1861
|
+
/** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
|
|
1862
|
+
private get _classicPrompts(): boolean {
|
|
1863
|
+
return this.getAttribute('prompt-style') === 'classic';
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
|
|
1867
|
+
private _usesCard(tool: ModelContextToolInfo): boolean {
|
|
1868
|
+
return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** Details are missing: the card asks for them, beside what it already has. */
|
|
1872
|
+
private _cardAsk(req: AskRequest): void {
|
|
1873
|
+
const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
|
|
1874
|
+
const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
|
|
1875
|
+
this._live!.ask = req;
|
|
1876
|
+
this._live!.confirm = undefined;
|
|
1877
|
+
item.stage = 'collect';
|
|
1878
|
+
this._paintAction(item);
|
|
1879
|
+
this._saveConversations();
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** Everything is known: the card says what will happen, and waits for Send. */
|
|
1883
|
+
private _cardConfirm(req: ConfirmRequest): void {
|
|
1884
|
+
const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
|
|
1885
|
+
const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
|
|
1886
|
+
this._live!.confirm = req;
|
|
1887
|
+
item.stage = 'review';
|
|
1888
|
+
this._paintAction(item);
|
|
1889
|
+
this._saveConversations();
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* The live card for a tool's prompt: the one already open for this request,
|
|
1894
|
+
* or a new one, which takes over from the journey card of the same request.
|
|
1895
|
+
*/
|
|
1896
|
+
private _liveCard(
|
|
1897
|
+
tool: ModelContextToolInfo,
|
|
1898
|
+
slots: Slot[],
|
|
1899
|
+
values: Record<string, unknown>,
|
|
1900
|
+
needed: Set<string>,
|
|
1901
|
+
destination?: string,
|
|
1902
|
+
): ActionItem {
|
|
1903
|
+
const conv = this._active()!;
|
|
1904
|
+
const id = currentJourney() ?? `a${Date.now().toString(36)}`;
|
|
1905
|
+
const live = this._live;
|
|
1906
|
+
const described = this._describeAction(tool, values, destination);
|
|
1907
|
+
const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
|
|
1908
|
+
|
|
1909
|
+
if (live && live.item.id === id && live.tool.name === tool.name) {
|
|
1910
|
+
// The same request, a step on: keep what was typed, and anything the harness has since learned.
|
|
1911
|
+
live.item.fields = fields.map((f) => {
|
|
1912
|
+
const had = live.item.fields.find((o) => o.name === f.name);
|
|
1913
|
+
return had && f.value === '' ? { ...f, value: had.value } : f;
|
|
1914
|
+
});
|
|
1915
|
+
Object.assign(live.item, described.view);
|
|
1916
|
+
live.tool = tool;
|
|
1917
|
+
return live.item;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// A request left waiting is replaced by the new one.
|
|
1921
|
+
if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
|
|
1922
|
+
live.item.stage = 'declined';
|
|
1923
|
+
this._paintAction(live.item);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const item: ActionItem = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
|
|
1927
|
+
conv.items.push(item);
|
|
1928
|
+
this._live = { item, tool, sent: false, declining: false };
|
|
1929
|
+
this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
|
|
1930
|
+
this._watchRunningJourney();
|
|
1931
|
+
if (isHandoffTool(tool.name)) void this._refreshHandoffTargets(item);
|
|
1932
|
+
return item;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* The slots with each field's options cut to the form the call means: one
|
|
1937
|
+
* business's services rather than every business's.
|
|
1938
|
+
*/
|
|
1939
|
+
private _narrowed(tool: ModelContextToolInfo, slots: Slot[], values: Record<string, unknown>): Slot[] {
|
|
1940
|
+
const options = formOptions(tool, values);
|
|
1941
|
+
return slots.map((slot) => {
|
|
1942
|
+
const only = options[slot.name];
|
|
1943
|
+
if (!only) return slot;
|
|
1944
|
+
// Keep a value the call already gave, even one the form does not list: the form decides.
|
|
1945
|
+
const current = values[slot.name];
|
|
1946
|
+
const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
|
|
1947
|
+
return { ...slot, schema: { ...slot.schema, enum: list } };
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/** Who a request goes to, and how the card words it. */
|
|
1952
|
+
private _describeAction(
|
|
1953
|
+
tool: ModelContextToolInfo,
|
|
1954
|
+
values: Record<string, unknown>,
|
|
1955
|
+
destination?: string,
|
|
1956
|
+
): {
|
|
1957
|
+
view: Pick<ActionItem, 'variant' | 'noun' | 'identity' | 'sentence' | 'note' | 'sendLabel'>;
|
|
1958
|
+
hidden: Set<string>;
|
|
1959
|
+
labels: Record<string, string>;
|
|
1960
|
+
} {
|
|
1961
|
+
const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
|
|
1962
|
+
|
|
1963
|
+
if (isHandoffTool(tool.name)) {
|
|
1964
|
+
const name = String(values.target ?? '');
|
|
1965
|
+
const config = this._handoff?.config?.targets[name];
|
|
1966
|
+
const known = this._handoffTargets.get(name);
|
|
1967
|
+
const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
|
|
1968
|
+
const person = (known?.kind ?? config?.kind) !== 'agent';
|
|
1969
|
+
const away = known ? !known.available : false;
|
|
1970
|
+
// "The directory team" leads a line, but mid-sentence it is "the directory team".
|
|
1971
|
+
const inline = label.replace(/^The\s/, 'the ');
|
|
1972
|
+
const about = known?.description ?? config?.description;
|
|
1973
|
+
const status = !known
|
|
1974
|
+
? undefined
|
|
1975
|
+
: away
|
|
1976
|
+
? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
|
|
1977
|
+
: person
|
|
1978
|
+
? known.waitMinutes
|
|
1979
|
+
? `Available · replies in ~${known.waitMinutes} min`
|
|
1980
|
+
: 'Available'
|
|
1981
|
+
: 'Available';
|
|
1982
|
+
const sentence = !person
|
|
1983
|
+
? `I'll hand this conversation to ${inline}. They'll take it from here.`
|
|
1984
|
+
: away
|
|
1985
|
+
? email
|
|
1986
|
+
? `${label} is away right now. They'll reply to ${email}.`
|
|
1987
|
+
: `${label} is away right now. Leave an email and they'll reply there.`
|
|
1988
|
+
: `I'll pass this to ${inline} so you don't have to repeat yourself.`;
|
|
1989
|
+
return {
|
|
1990
|
+
view: {
|
|
1991
|
+
variant: person ? 'handoff-person' : 'handoff-agent',
|
|
1992
|
+
noun: person ? 'handoff' : 'handover',
|
|
1993
|
+
identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
|
|
1994
|
+
sentence,
|
|
1995
|
+
note: undefined,
|
|
1996
|
+
sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
|
|
1997
|
+
},
|
|
1998
|
+
hidden: new Set(['target']),
|
|
1999
|
+
labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
|
|
2004
|
+
// Which business or place it is for: the site tool's own chooser, else a field the form names it in.
|
|
2005
|
+
const chooser = chooserOf(tool) ?? Object.keys(values).find((k) => /^(business|company|organi[sz]ation|provider|vendor|store|restaurant|practice|venue)(_name)?$/i.test(k)) ?? null;
|
|
2006
|
+
const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
|
|
2007
|
+
const leaving = destination ?? handoffFor(tool, values)?.label;
|
|
2008
|
+
const name = chosen || leaving || this._pageName();
|
|
2009
|
+
// What the conversation already showed about them: their card from a search.
|
|
2010
|
+
const card = this._conversations
|
|
2011
|
+
.find((c) => c.id === this._activeId)
|
|
2012
|
+
?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
|
|
2013
|
+
.find((c) => c.title.toLowerCase() === name.toLowerCase());
|
|
2014
|
+
const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
|
|
2015
|
+
.filter(Boolean)
|
|
2016
|
+
.join(' · ');
|
|
2017
|
+
return {
|
|
2018
|
+
view: {
|
|
2019
|
+
variant: 'form',
|
|
2020
|
+
noun,
|
|
2021
|
+
identity: { name, sub },
|
|
2022
|
+
sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
|
|
2023
|
+
note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
|
|
2024
|
+
sendLabel: 'Send',
|
|
2025
|
+
},
|
|
2026
|
+
hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
|
|
2027
|
+
labels: {},
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** The site's name for this page: its title, without the site's own name after it. */
|
|
2032
|
+
private _pageName(): string {
|
|
2033
|
+
const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
|
|
2034
|
+
return title || 'this page';
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/** Read availability for a handoff card, and redraw it once known. */
|
|
2038
|
+
private async _refreshHandoffTargets(item: ActionItem): Promise<void> {
|
|
2039
|
+
const targets = await this._handoff?.targets().catch(() => []);
|
|
2040
|
+
for (const t of targets ?? []) this._handoffTargets.set(t.name, t);
|
|
2041
|
+
if (item.stage !== 'collect' && item.stage !== 'review') return;
|
|
2042
|
+
const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
|
|
2043
|
+
Object.assign(item, this._describeAction({ name: item.tool } as ModelContextToolInfo, values).view);
|
|
2044
|
+
this._paintAction(item);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** What the live card's buttons do. */
|
|
2048
|
+
private _actionHandlers(): ActionHandlers {
|
|
2049
|
+
return {
|
|
2050
|
+
continue: (values) => {
|
|
2051
|
+
const live = this._live;
|
|
2052
|
+
if (!live) return;
|
|
2053
|
+
if (live.ask && !live.confirm) {
|
|
2054
|
+
live.ask.onComplete(values);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
// Back from a change, or a card of the widget's own: review again, here.
|
|
2058
|
+
Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
|
|
2059
|
+
live.item.stage = 'review';
|
|
2060
|
+
this._paintAction(live.item);
|
|
2061
|
+
this._saveConversations();
|
|
2062
|
+
},
|
|
2063
|
+
change: () => {
|
|
2064
|
+
const live = this._live;
|
|
2065
|
+
if (!live) return;
|
|
2066
|
+
live.item.fields.forEach((f) => {
|
|
2067
|
+
if (f.group === 'needed' && f.value.trim() !== '') f.group = 'known';
|
|
2068
|
+
});
|
|
2069
|
+
live.sent = false;
|
|
2070
|
+
live.item.stage = 'collect';
|
|
2071
|
+
live.item.open = true;
|
|
2072
|
+
this._paintAction(live.item);
|
|
2073
|
+
this._saveConversations();
|
|
2074
|
+
},
|
|
2075
|
+
send: (values) => {
|
|
2076
|
+
const live = this._live;
|
|
2077
|
+
if (!live) return;
|
|
2078
|
+
live.sent = true;
|
|
2079
|
+
if (live.confirm) live.confirm.onSubmit(values);
|
|
2080
|
+
else if (live.run) void live.run(values);
|
|
2081
|
+
},
|
|
2082
|
+
decline: () => {
|
|
2083
|
+
const live = this._live;
|
|
2084
|
+
if (!live) return;
|
|
2085
|
+
live.declining = true;
|
|
2086
|
+
if (live.confirm) live.confirm.onCancel();
|
|
2087
|
+
else if (live.ask) live.ask.onCancel();
|
|
2088
|
+
// A card the harness is not waiting on declines itself.
|
|
2089
|
+
if (this._live === live) {
|
|
2090
|
+
live.item.stage = 'declined';
|
|
2091
|
+
this._paintAction(live.item);
|
|
2092
|
+
this._live = null;
|
|
2093
|
+
this._saveConversations();
|
|
2094
|
+
}
|
|
2095
|
+
},
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/** Draw a card where it was, or at the end of the thread. */
|
|
2100
|
+
private _paintAction(item: ActionItem): void {
|
|
2101
|
+
if (!this._els?.thread) return;
|
|
2102
|
+
const conv = this._active();
|
|
2103
|
+
if (!conv?.items.includes(item)) return;
|
|
2104
|
+
const live = this._live?.item === item;
|
|
2105
|
+
const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
|
|
2106
|
+
const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
|
|
2107
|
+
if (old) old.replaceWith(fresh);
|
|
2108
|
+
else {
|
|
2109
|
+
this._clearEmpty();
|
|
2110
|
+
this._els.thread.appendChild(fresh);
|
|
2111
|
+
this._scroll();
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/** A request's steps, as its card shows them once sent. */
|
|
2116
|
+
private _actionFollows(item: ActionItem, all: AgentStep[]): void {
|
|
2117
|
+
const live = this._live?.item === item ? this._live : null;
|
|
2118
|
+
const failed = [...all].reverse().find((st) => st.status === 'failed');
|
|
2119
|
+
const done = all.some((st) => st.id === 'submit' && st.status === 'done');
|
|
2120
|
+
const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
|
|
2121
|
+
item.steps = all
|
|
2122
|
+
.filter((st) => running.includes(st.id))
|
|
2123
|
+
.map((st): ActionStep => ({
|
|
2124
|
+
label:
|
|
2125
|
+
st.id === 'fill' && st.status === 'active' && item.variant === 'form'
|
|
2126
|
+
? `Filling in the ${item.noun}`
|
|
2127
|
+
: st.id === 'submit' && st.status === 'done' && item.variant === 'form'
|
|
2128
|
+
? 'Sent'
|
|
2129
|
+
: st.label,
|
|
2130
|
+
status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
|
|
2131
|
+
}));
|
|
2132
|
+
|
|
2133
|
+
if (failed) {
|
|
2134
|
+
if (live?.declining || failed.label === 'Cancelled') {
|
|
2135
|
+
item.stage = 'declined';
|
|
2136
|
+
if (live) this._live = null;
|
|
2137
|
+
} else {
|
|
2138
|
+
item.stage = 'failed';
|
|
2139
|
+
item.message = failed.detail || failed.label;
|
|
2140
|
+
}
|
|
2141
|
+
} else if (done) {
|
|
2142
|
+
item.stage = 'done';
|
|
2143
|
+
if (live) this._live = null;
|
|
2144
|
+
} else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
|
|
2145
|
+
item.stage = 'working';
|
|
2146
|
+
}
|
|
2147
|
+
this._paintAction(item);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
|
|
2151
|
+
private _cardLeft(tool: ModelContextToolInfo, destination: string | null): void {
|
|
2152
|
+
const live = this._live;
|
|
2153
|
+
if (!live || live.tool.name !== tool.name) {
|
|
2154
|
+
this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
live.item.stage = 'left';
|
|
2158
|
+
live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
|
|
2159
|
+
this._live = null;
|
|
2160
|
+
this._paintAction(live.item);
|
|
2161
|
+
this._saveConversations();
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
|
|
2165
|
+
private _cardDone(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): boolean {
|
|
2166
|
+
const conv = this._active();
|
|
2167
|
+
const id = currentJourney();
|
|
2168
|
+
const item = conv?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === id && it.tool === tool.name);
|
|
2169
|
+
if (!item || this._classicPrompts) return false;
|
|
2170
|
+
const note = this._buildDetailItem(tool, values, text).note;
|
|
2171
|
+
// The default note says nothing the card's "Sent" does not.
|
|
2172
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
2173
|
+
item.stage = 'done';
|
|
2174
|
+
item.fields.forEach((f) => {
|
|
2175
|
+
if (values[f.name] !== undefined) f.value = String(values[f.name]);
|
|
2176
|
+
});
|
|
2177
|
+
if (this._live?.item === item) this._live = null;
|
|
2178
|
+
this._paintAction(item);
|
|
2179
|
+
this._saveConversations();
|
|
2180
|
+
return true;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** It did not go through: the card says why and offers to change it. False when no card is waiting. */
|
|
2184
|
+
private _cardFailed(tool: ModelContextToolInfo, message: string): boolean {
|
|
2185
|
+
const live = this._live;
|
|
2186
|
+
if (!live || live.tool.name !== tool.name) return false;
|
|
2187
|
+
live.sent = false;
|
|
2188
|
+
live.item.stage = 'failed';
|
|
2189
|
+
live.item.message = message;
|
|
2190
|
+
this._paintAction(live.item);
|
|
2191
|
+
this._saveConversations();
|
|
2192
|
+
return true;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* After a page change: the card of the request that travelled, found by its
|
|
2197
|
+
* journey, now finished or asked about here.
|
|
2198
|
+
*/
|
|
2199
|
+
private _resumedCard(journeyId: string | undefined): ActionItem | null {
|
|
2200
|
+
if (!journeyId || this._classicPrompts) return null;
|
|
2201
|
+
return this._active()?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === journeyId) ?? null;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/** Let a resumed card be changed and sent again from this page, with the page's own tool. */
|
|
2205
|
+
private _reviveCard(item: ActionItem, tool: ModelContextToolInfo, values: Record<string, unknown>, needed: string[]): void {
|
|
2206
|
+
const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
|
|
2207
|
+
const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
|
|
2208
|
+
item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
|
|
2209
|
+
this._live = {
|
|
2210
|
+
item,
|
|
2211
|
+
tool,
|
|
2212
|
+
sent: false,
|
|
2213
|
+
declining: false,
|
|
2214
|
+
run: async (vals) => {
|
|
2215
|
+
item.stage = 'working';
|
|
2216
|
+
item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
|
|
2217
|
+
this._paintAction(item);
|
|
2218
|
+
try {
|
|
2219
|
+
const res = await this.toolSource.execTool(tool, tool.name, vals);
|
|
2220
|
+
const text = extractResult(res);
|
|
2221
|
+
if (res?.isError) {
|
|
2222
|
+
this._cardFailed(tool, text || 'That form did not accept the details.');
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
this._active()?.messages.push({
|
|
2226
|
+
role: 'user',
|
|
2227
|
+
content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
|
|
2228
|
+
});
|
|
2229
|
+
this._live = null;
|
|
2230
|
+
const note = this._buildDetailItem(tool, vals, text).note;
|
|
2231
|
+
item.message = /submitted successfully/.test(note) ? undefined : note;
|
|
2232
|
+
item.stage = 'done';
|
|
2233
|
+
item.steps = [];
|
|
2234
|
+
this._paintAction(item);
|
|
2235
|
+
this._saveConversations();
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
this._cardFailed(tool, String((err as Error)?.message || err));
|
|
2238
|
+
}
|
|
2239
|
+
},
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
|
|
1676
2243
|
// ── Inline generated form (from JSON-Schema) ───────────────────────────────
|
|
1677
2244
|
private _renderForm(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
|
|
1678
2245
|
if (this._useA2ui) {
|
|
@@ -1905,6 +2472,20 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1905
2472
|
submitBtn.disabled = false;
|
|
1906
2473
|
return;
|
|
1907
2474
|
}
|
|
2475
|
+
// A handoff started: what happens next arrives from the target, and the
|
|
2476
|
+
// controller's events draw it. There is nothing to show as "Submitted".
|
|
2477
|
+
const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
|
|
2478
|
+
if (started) {
|
|
2479
|
+
this._dismissPrompt();
|
|
2480
|
+
this._active()?.messages.push({
|
|
2481
|
+
role: 'user',
|
|
2482
|
+
content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
|
|
2483
|
+
});
|
|
2484
|
+
this._saveConversations();
|
|
2485
|
+
this._paintHandoffBar();
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
1908
2489
|
// A call that left for another page has filled in nothing yet, so it
|
|
1909
2490
|
// must not become a "Submitted" card. The page it is opening finishes
|
|
1910
2491
|
// the job and draws the card there.
|
|
@@ -2603,6 +3184,17 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2603
3184
|
* `fallback`. Inexpensive: each agent is a role over the one shared provider,
|
|
2604
3185
|
* so no additional model is loaded.
|
|
2605
3186
|
*/
|
|
3187
|
+
/** The one agent a page without `agents` runs, on the current provider. */
|
|
3188
|
+
private _singleHarness(): AgentHarness {
|
|
3189
|
+
return new AgentHarness({
|
|
3190
|
+
provider: this.provider,
|
|
3191
|
+
toolSource: this.toolSource,
|
|
3192
|
+
renderer: this._renderer(),
|
|
3193
|
+
profile: (schema) => this._profileForSchema(schema as JsonSchema),
|
|
3194
|
+
onMessages: () => this._saveConversations(),
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
|
|
2606
3198
|
private _buildAgents(): void {
|
|
2607
3199
|
if (!this.provider || !this._agentDefs) {
|
|
2608
3200
|
this._coordinator = null;
|
|
@@ -2644,6 +3236,172 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2644
3236
|
});
|
|
2645
3237
|
}
|
|
2646
3238
|
|
|
3239
|
+
// ── Handoff ────────────────────────────────────────────────────────────────
|
|
3240
|
+
|
|
3241
|
+
/**
|
|
3242
|
+
* Handing conversations to other agents and to people.
|
|
3243
|
+
*
|
|
3244
|
+
* Set to `{ relay, targets }` to offer `transfer_to_agent` and
|
|
3245
|
+
* `talk_to_a_person` for those targets (see docs/handoff.md). While a
|
|
3246
|
+
* conversation is handed off, what the visitor types goes to the target,
|
|
3247
|
+
* and a bar above the input shows who is listening and the way back.
|
|
3248
|
+
*/
|
|
3249
|
+
get handoff(): HandoffConfig | null {
|
|
3250
|
+
return this._handoff?.config ?? null;
|
|
3251
|
+
}
|
|
3252
|
+
set handoff(next: HandoffConfig | null) {
|
|
3253
|
+
this._offHandoff?.();
|
|
3254
|
+
this._offHandoff = null;
|
|
3255
|
+
this._handoff?.dispose();
|
|
3256
|
+
this._handoff = null;
|
|
3257
|
+
if (!next?.relay || !next.targets) {
|
|
3258
|
+
if (this._els?.thread) this._paintHandoffBar();
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
const controller = new HandoffController(next);
|
|
3262
|
+
const listener = (e: Event) => this._onHandoffEvent((e as CustomEvent<HandoffEvent>).detail);
|
|
3263
|
+
controller.addEventListener('handoff', listener);
|
|
3264
|
+
this._offHandoff = () => controller.removeEventListener('handoff', listener);
|
|
3265
|
+
this._handoff = controller;
|
|
3266
|
+
// Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
|
|
3267
|
+
if (this._connect) this._connect = this._connect.then(() => this._registerHandoffBounded());
|
|
3268
|
+
if (this._els?.thread) {
|
|
3269
|
+
this._paintHandoffBar();
|
|
3270
|
+
if (this._activeId) void controller.resume(this._activeId);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
/** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
|
|
3275
|
+
private _registerHandoffBounded(): Promise<void> {
|
|
3276
|
+
return Promise.race([this._registerHandoff(), new Promise<void>((r) => setTimeout(r, 4000))]).catch((err) =>
|
|
3277
|
+
console.warn('[nxis-agent] handoff tools unavailable:', err),
|
|
3278
|
+
);
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
/** Offer the handoff tools on the page's registry. */
|
|
3282
|
+
private async _registerHandoff(): Promise<void> {
|
|
3283
|
+
const controller = this._handoff;
|
|
3284
|
+
const mc = this.toolSource?.modelContext();
|
|
3285
|
+
if (!controller || !mc) return;
|
|
3286
|
+
const added = await registerHandoffTools(mc, controller, {
|
|
3287
|
+
conversationId: () => this._activeId ?? undefined,
|
|
3288
|
+
transcript: () => this._transcript(),
|
|
3289
|
+
});
|
|
3290
|
+
if (added.length) void this._refreshToolCount();
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
/** The conversation's last turns, text only, for a target allowed to see them. */
|
|
3294
|
+
private _transcript(): { role: 'visitor' | 'assistant'; text: string }[] {
|
|
3295
|
+
const conv = this._active();
|
|
3296
|
+
if (!conv) return [];
|
|
3297
|
+
return conv.items
|
|
3298
|
+
.filter((it): it is Extract<ThreadItem, { kind: 'msg' }> => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
|
|
3299
|
+
.slice(-10)
|
|
3300
|
+
.map((it) => ({ role: it.role === 'user' ? ('visitor' as const) : ('assistant' as const), text: it.text }));
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
/** What a handoff reported, into the conversation it belongs to. */
|
|
3304
|
+
private _onHandoffEvent(e: HandoffEvent): void {
|
|
3305
|
+
const conv = this._conversations.find((c) => c.id === e.conversationId);
|
|
3306
|
+
if (!conv) return;
|
|
3307
|
+
if (e.type === 'message') {
|
|
3308
|
+
this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
|
|
3309
|
+
} else if (e.type === 'handback') {
|
|
3310
|
+
const { outcome, note, references } = e.handBack;
|
|
3311
|
+
const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
|
|
3312
|
+
// The assistant carries on from here, so it learns what happened: the note, and nothing else the
|
|
3313
|
+
// target said. A remote reply is shown to the visitor and never read as an instruction.
|
|
3314
|
+
conv.messages.push({
|
|
3315
|
+
role: 'user',
|
|
3316
|
+
content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
|
|
3317
|
+
});
|
|
3318
|
+
if (refs) this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
|
|
3319
|
+
} else {
|
|
3320
|
+
const text = handoffLine(e.session);
|
|
3321
|
+
const last = [...conv.items].reverse().find((it) => it.kind === 'handoff') as
|
|
3322
|
+
| Extract<ThreadItem, { kind: 'handoff' }>
|
|
3323
|
+
| undefined;
|
|
3324
|
+
if (text && last?.text !== text) {
|
|
3325
|
+
const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
|
|
3326
|
+
this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
|
|
3327
|
+
}
|
|
3328
|
+
if (e.state === 'ended' || e.state === 'failed') {
|
|
3329
|
+
conv.messages.push({
|
|
3330
|
+
role: 'user',
|
|
3331
|
+
content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
this._saveConversations();
|
|
3336
|
+
if (conv.id === this._activeId) this._paintHandoffBar();
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
/** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
|
|
3340
|
+
private _appendHandoffItem(conversationId: string, item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): void {
|
|
3341
|
+
const conv = this._conversations.find((c) => c.id === conversationId);
|
|
3342
|
+
if (!conv) return;
|
|
3343
|
+
conv.items.push(item);
|
|
3344
|
+
this._saveConversations();
|
|
3345
|
+
if (conversationId === this._activeId && this._els?.thread) {
|
|
3346
|
+
this._paintHandoffItem(item);
|
|
3347
|
+
this._scroll();
|
|
3348
|
+
this._noteThreadGrew();
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
private _paintHandoffItem(item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): HTMLElement {
|
|
3353
|
+
this._clearEmpty();
|
|
3354
|
+
let el: HTMLElement;
|
|
3355
|
+
if (item.kind === 'remote') {
|
|
3356
|
+
el = document.createElement('div');
|
|
3357
|
+
el.className = 'remote';
|
|
3358
|
+
const who = document.createElement('div');
|
|
3359
|
+
who.className = 'remote-from';
|
|
3360
|
+
who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
|
|
3361
|
+
el.append(who, this._msgEl('assistant', item.text));
|
|
3362
|
+
} else {
|
|
3363
|
+
el = document.createElement('div');
|
|
3364
|
+
el.className = `handoff-note ${item.tone}`;
|
|
3365
|
+
el.textContent = item.text;
|
|
3366
|
+
}
|
|
3367
|
+
this._els.thread.appendChild(el);
|
|
3368
|
+
return el;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/** The bar above the input: shown while the conversation is handed off. */
|
|
3372
|
+
private _paintHandoffBar(): void {
|
|
3373
|
+
if (!this._els?.handoffBar) return;
|
|
3374
|
+
const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
|
|
3375
|
+
this._els.handoffBar.hidden = !session;
|
|
3376
|
+
const input = this._els.input;
|
|
3377
|
+
if (!session) {
|
|
3378
|
+
input.placeholder = 'Message the agent…';
|
|
3379
|
+
input.setAttribute('aria-label', 'Message the agent');
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
const who = session.operator ?? session.label;
|
|
3383
|
+
const q = session.queue;
|
|
3384
|
+
const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
|
|
3385
|
+
const doing =
|
|
3386
|
+
session.state === 'connecting'
|
|
3387
|
+
? ' · connecting…'
|
|
3388
|
+
: session.state === 'queued'
|
|
3389
|
+
? inLine
|
|
3390
|
+
: session.state === 'awaiting-visitor'
|
|
3391
|
+
? ' · waiting for your reply'
|
|
3392
|
+
: '';
|
|
3393
|
+
this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
|
|
3394
|
+
input.placeholder = `Message ${who}…`;
|
|
3395
|
+
input.setAttribute('aria-label', `Message ${who}`);
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
/** "Back to the assistant": end the handoff for this conversation. */
|
|
3399
|
+
private async _endHandoff(): Promise<void> {
|
|
3400
|
+
if (!this._activeId || !this._handoff) return;
|
|
3401
|
+
await this._handoff.end(this._activeId);
|
|
3402
|
+
this._els.input.focus();
|
|
3403
|
+
}
|
|
3404
|
+
|
|
2647
3405
|
/** Zero when the provider cannot count, which hides the token metrics. */
|
|
2648
3406
|
private async countTokens(text: string): Promise<number> {
|
|
2649
3407
|
return (await this.provider.countTokens?.(text)) ?? 0;
|
|
@@ -2737,6 +3495,44 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2737
3495
|
});
|
|
2738
3496
|
if (outcome.status !== 'none') this._setStatus('');
|
|
2739
3497
|
|
|
3498
|
+
// The card that travelled with the request finishes here, when there is one.
|
|
3499
|
+
const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
|
|
3500
|
+
if (card && outcome.status !== 'none') {
|
|
3501
|
+
switch (outcome.status) {
|
|
3502
|
+
case 'no-registry':
|
|
3503
|
+
case 'no-tool':
|
|
3504
|
+
card.stage = 'failed';
|
|
3505
|
+
card.message = `I couldn't find the ${card.noun} on this page, so nothing was sent. Ask me again and I'll fill it in from here.`;
|
|
3506
|
+
break;
|
|
3507
|
+
case 'incomplete':
|
|
3508
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
|
|
3509
|
+
card.stage = 'collect';
|
|
3510
|
+
card.message = undefined;
|
|
3511
|
+
break;
|
|
3512
|
+
case 'rejected':
|
|
3513
|
+
case 'failed':
|
|
3514
|
+
this._reviveCard(card, outcome.tool, outcome.pending.values, []);
|
|
3515
|
+
card.stage = 'failed';
|
|
3516
|
+
card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
|
|
3517
|
+
break;
|
|
3518
|
+
case 'submitted': {
|
|
3519
|
+
const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
|
|
3520
|
+
card.message = /submitted successfully/.test(note) ? undefined : note;
|
|
3521
|
+
card.stage = 'done';
|
|
3522
|
+
this._active()?.messages.push({
|
|
3523
|
+
role: 'user',
|
|
3524
|
+
content:
|
|
3525
|
+
`[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
|
|
3526
|
+
'Keep these unless I ask to change them.',
|
|
3527
|
+
});
|
|
3528
|
+
break;
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
this._paintAction(card);
|
|
3532
|
+
this._saveConversations();
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
2740
3536
|
switch (outcome.status) {
|
|
2741
3537
|
case 'none':
|
|
2742
3538
|
return;
|
|
@@ -2836,7 +3632,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2836
3632
|
if (this._toolsOpen) void this._renderTools();
|
|
2837
3633
|
});
|
|
2838
3634
|
})
|
|
2839
|
-
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
|
|
3635
|
+
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
|
|
3636
|
+
// Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
|
|
3637
|
+
// them could only claim, in words, to be handing the visitor over.
|
|
3638
|
+
.then(() => this._registerHandoffBounded());
|
|
2840
3639
|
return this._connect;
|
|
2841
3640
|
}
|
|
2842
3641
|
|
|
@@ -3091,6 +3890,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3091
3890
|
if (this._els?.thread) {
|
|
3092
3891
|
this.provider = next ?? this.provider;
|
|
3093
3892
|
this._paintProvenance();
|
|
3893
|
+
// Both the single agent and any configured ones: a page without `agents` kept the old
|
|
3894
|
+
// provider's harness, and answered with the model it had just replaced.
|
|
3895
|
+
this._harness = this._singleHarness();
|
|
3094
3896
|
this._buildAgents();
|
|
3095
3897
|
}
|
|
3096
3898
|
}
|
|
@@ -3294,6 +4096,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3294
4096
|
/* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
|
|
3295
4097
|
${A2UI_THEME_CSS}
|
|
3296
4098
|
|
|
4099
|
+
/* The action card: forms and handoffs as one message. */
|
|
4100
|
+
${ACTION_CARD_CSS}
|
|
4101
|
+
|
|
3297
4102
|
/* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
|
|
3298
4103
|
.launcher {
|
|
3299
4104
|
position: fixed; right: 20px; bottom: 20px; z-index: 9998;
|
|
@@ -3745,6 +4550,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3745
4550
|
padding: 2px 0 6px;
|
|
3746
4551
|
letter-spacing: .01em;
|
|
3747
4552
|
}
|
|
4553
|
+
|
|
4554
|
+
/* Handoffs. A target's message is a bubble with its name above; where the
|
|
4555
|
+
handoff stands is a quiet centred line; the bar says who is listening. */
|
|
4556
|
+
.remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
|
|
4557
|
+
.remote .msg { max-width: 100%; }
|
|
4558
|
+
.remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
|
|
4559
|
+
.handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
|
|
4560
|
+
.handoff-note.done { color: var(--green-strong); }
|
|
4561
|
+
.handoff-note.error { color: var(--red); }
|
|
4562
|
+
.handoff-bar { display: flex; align-items: center; gap: 8px; margin: 0 20px 6px; padding: 6px 6px 6px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--green-tint); color: var(--ink); font-family: var(--sans); font-size: 12.5px; }
|
|
4563
|
+
.handoff-bar[hidden] { display: none; }
|
|
4564
|
+
.handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
|
|
4565
|
+
.handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
4566
|
+
.handoff-bar .hb-back { flex: none; border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); border-radius: var(--radius-pill); padding: 4px 11px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
4567
|
+
.handoff-bar .hb-back:hover { border-color: var(--ink-3); }
|
|
4568
|
+
.handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
4569
|
+
.overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
|
|
3748
4570
|
.composer .go svg { width: 17px; height: 17px; }
|
|
3749
4571
|
|
|
3750
4572
|
/* Attribution. Quiet enough to sit under a customer's own chat surface
|
|
@@ -3958,6 +4780,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3958
4780
|
</button>
|
|
3959
4781
|
<div class="thread"></div>
|
|
3960
4782
|
<div class="statusline"></div>
|
|
4783
|
+
<div class="handoff-bar" role="status" hidden>
|
|
4784
|
+
<span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
|
|
4785
|
+
<button class="hb-back" type="button">Back to the assistant</button>
|
|
4786
|
+
</div>
|
|
3961
4787
|
<div class="composer">
|
|
3962
4788
|
<div class="cbox">
|
|
3963
4789
|
<textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
|