@nxisai/agent-harness 0.5.0 → 0.6.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 +22 -1
- 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 +148 -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 +30 -7
- package/dist/harness/harness.js.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/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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +264 -1
- package/dist/ui/agent-fullscreen.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 +178 -0
- package/src/harness/harness.ts +28 -3
- package/src/harness/router/coordinator.ts +21 -0
- package/src/harness/tools/discovery.ts +20 -2
- package/src/index.ts +3 -0
- package/src/ui/agent-fullscreen.ts +280 -2
|
@@ -53,6 +53,14 @@ import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
|
|
|
53
53
|
import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
|
|
54
54
|
import { viewJourney } from './journey.js';
|
|
55
55
|
import { AgentCoordinator } from '../harness/router/coordinator.js';
|
|
56
|
+
import {
|
|
57
|
+
HandoffController,
|
|
58
|
+
isHandoffOver,
|
|
59
|
+
type HandoffConfig,
|
|
60
|
+
type HandoffEvent,
|
|
61
|
+
type HandoffSession,
|
|
62
|
+
} from '../handoff/controller.js';
|
|
63
|
+
import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
|
|
56
64
|
import type { RouteRule } from '../harness/router/rules.js';
|
|
57
65
|
import type { AskRequest, ConfirmRequest, HarnessRenderer, StreamingBubble as HStreamingBubble } from '../harness/renderer.js';
|
|
58
66
|
import {
|
|
@@ -319,7 +327,42 @@ type ThreadItem =
|
|
|
319
327
|
* so a second request does not erase the first, and so a card survives the
|
|
320
328
|
* page change a cross-page action makes.
|
|
321
329
|
*/
|
|
322
|
-
| { kind: 'journey'; id: string; steps: AgentStep[] }
|
|
330
|
+
| { kind: 'journey'; id: string; steps: AgentStep[] }
|
|
331
|
+
/** Something a handoff target said: an agent, or a person at a desk. */
|
|
332
|
+
| { kind: 'remote'; from: string; fromKind: 'agent' | 'person'; text: string }
|
|
333
|
+
/** Where a handoff stands, as a quiet line in the thread. */
|
|
334
|
+
| { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' };
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A handoff's state as a line in the thread, or '' for a state the bar alone
|
|
338
|
+
* shows.
|
|
339
|
+
*/
|
|
340
|
+
function handoffLine(session: HandoffSession): string {
|
|
341
|
+
const who = session.label;
|
|
342
|
+
switch (session.state) {
|
|
343
|
+
case 'connecting':
|
|
344
|
+
return `Connecting you to ${who}…`;
|
|
345
|
+
case 'queued':
|
|
346
|
+
// The bar says where they are in line; a desk usually says it in words too.
|
|
347
|
+
return '';
|
|
348
|
+
case 'active':
|
|
349
|
+
return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
|
|
350
|
+
case 'awaiting-visitor':
|
|
351
|
+
return '';
|
|
352
|
+
case 'handed-back':
|
|
353
|
+
return 'Back with the assistant';
|
|
354
|
+
case 'ended':
|
|
355
|
+
return `${who} has finished. Back with the assistant.`;
|
|
356
|
+
case 'failed':
|
|
357
|
+
return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ordinal(n: number): string {
|
|
362
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
363
|
+
const v = n % 100;
|
|
364
|
+
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
|
|
365
|
+
}
|
|
323
366
|
|
|
324
367
|
interface Conversation {
|
|
325
368
|
id: string;
|
|
@@ -344,6 +387,10 @@ interface FullscreenEls {
|
|
|
344
387
|
bar: HTMLElement;
|
|
345
388
|
/** Names the running step while its card is scrolled out of view. */
|
|
346
389
|
jstatus: HTMLButtonElement;
|
|
390
|
+
/** Shown while the conversation is handed off: who to, and the way back. */
|
|
391
|
+
handoffBar: HTMLElement;
|
|
392
|
+
handoffLabel: HTMLElement;
|
|
393
|
+
handoffBack: HTMLButtonElement;
|
|
347
394
|
input: HTMLTextAreaElement;
|
|
348
395
|
go: HTMLButtonElement;
|
|
349
396
|
/** Opens the list of tools the agent can use. */
|
|
@@ -455,6 +502,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
455
502
|
private toolSource!: WebMcpTools;
|
|
456
503
|
/** A provider the host supplied, used instead of the local model. */
|
|
457
504
|
private _provider: ModelProvider | null = null;
|
|
505
|
+
/** Runs handoffs to agents and people, when the host configured any. */
|
|
506
|
+
private _handoff: HandoffController | null = null;
|
|
507
|
+
private _offHandoff: (() => void) | null = null;
|
|
458
508
|
|
|
459
509
|
constructor() {
|
|
460
510
|
super();
|
|
@@ -489,6 +539,9 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
489
539
|
status: root.querySelector('.statusline')!,
|
|
490
540
|
bar: root.querySelector('.prog > i')!,
|
|
491
541
|
jstatus: root.querySelector('.jstatus')!,
|
|
542
|
+
handoffBar: root.querySelector('.handoff-bar')!,
|
|
543
|
+
handoffLabel: root.querySelector('.handoff-bar .hb-label')!,
|
|
544
|
+
handoffBack: root.querySelector('.handoff-bar .hb-back')!,
|
|
492
545
|
input: root.querySelector('.composer textarea')!,
|
|
493
546
|
go: root.querySelector('.composer .go')!,
|
|
494
547
|
toolsBtn: root.querySelector('.tools-btn')!,
|
|
@@ -579,6 +632,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
579
632
|
this._resumePendingVisit();
|
|
580
633
|
|
|
581
634
|
this._els.launcher.addEventListener('click', () => this._open());
|
|
635
|
+
this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
|
|
636
|
+
// A handoff left open on the last page carries on here.
|
|
637
|
+
this._paintHandoffBar();
|
|
638
|
+
if (this._activeId) void this._handoff?.resume(this._activeId);
|
|
582
639
|
this._els.close.addEventListener('click', () => {
|
|
583
640
|
this._els.overlay.classList.remove('show');
|
|
584
641
|
// A remote turn can outlive the panel, and its answer would go unread.
|
|
@@ -658,6 +715,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
658
715
|
this._offJourney = null;
|
|
659
716
|
this._journeyObserver?.disconnect();
|
|
660
717
|
this._journeyObserver = null;
|
|
718
|
+
// Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
|
|
719
|
+
this._handoff?.dispose();
|
|
661
720
|
}
|
|
662
721
|
|
|
663
722
|
/** Programmatic open (used by pages that dock the agent full-page). */
|
|
@@ -729,6 +788,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
729
788
|
this._renderHistory();
|
|
730
789
|
this._renderThread();
|
|
731
790
|
this._saveConversations();
|
|
791
|
+
this._paintHandoffBar();
|
|
732
792
|
}
|
|
733
793
|
if (this._els.input) this._els.input.focus();
|
|
734
794
|
}
|
|
@@ -742,6 +802,8 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
742
802
|
this._setNav(false);
|
|
743
803
|
this._renderHistory();
|
|
744
804
|
this._renderThread();
|
|
805
|
+
this._paintHandoffBar();
|
|
806
|
+
void this._handoff?.resume(id);
|
|
745
807
|
}
|
|
746
808
|
|
|
747
809
|
private _renderHistory(): void {
|
|
@@ -842,6 +904,7 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
842
904
|
else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
|
|
843
905
|
else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
|
|
844
906
|
else if (it.kind === 'journey') this._els.thread.appendChild(this._journeyEl(it));
|
|
907
|
+
else if (it.kind === 'remote' || it.kind === 'handoff') this._paintHandoffItem(it);
|
|
845
908
|
}
|
|
846
909
|
this._scroll();
|
|
847
910
|
this._watchRunningJourney();
|
|
@@ -1438,6 +1501,18 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1438
1501
|
this._saveConversations();
|
|
1439
1502
|
}
|
|
1440
1503
|
|
|
1504
|
+
// Handed off: this message is for the target, not the assistant.
|
|
1505
|
+
const handed = this._handoff?.active(this._activeId ?? undefined);
|
|
1506
|
+
if (handed && this._activeId) {
|
|
1507
|
+
try {
|
|
1508
|
+
await this._handoff!.send(this._activeId, goal);
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
const why = this._handoff!.session(this._activeId)?.error ?? String((err as Error)?.message || err);
|
|
1511
|
+
this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
|
|
1512
|
+
}
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1441
1516
|
this._turnActive = true;
|
|
1442
1517
|
this._setStoppable(true);
|
|
1443
1518
|
let settle!: () => void;
|
|
@@ -1905,6 +1980,20 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1905
1980
|
submitBtn.disabled = false;
|
|
1906
1981
|
return;
|
|
1907
1982
|
}
|
|
1983
|
+
// A handoff started: what happens next arrives from the target, and the
|
|
1984
|
+
// controller's events draw it. There is nothing to show as "Submitted".
|
|
1985
|
+
const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
|
|
1986
|
+
if (started) {
|
|
1987
|
+
this._dismissPrompt();
|
|
1988
|
+
this._active()?.messages.push({
|
|
1989
|
+
role: 'user',
|
|
1990
|
+
content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
|
|
1991
|
+
});
|
|
1992
|
+
this._saveConversations();
|
|
1993
|
+
this._paintHandoffBar();
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1908
1997
|
// A call that left for another page has filled in nothing yet, so it
|
|
1909
1998
|
// must not become a "Submitted" card. The page it is opening finishes
|
|
1910
1999
|
// the job and draws the card there.
|
|
@@ -2644,6 +2733,171 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2644
2733
|
});
|
|
2645
2734
|
}
|
|
2646
2735
|
|
|
2736
|
+
// ── Handoff ────────────────────────────────────────────────────────────────
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Handing conversations to other agents and to people.
|
|
2740
|
+
*
|
|
2741
|
+
* Set to `{ relay, targets }` to offer `transfer_to_agent` and
|
|
2742
|
+
* `talk_to_a_person` for those targets (see docs/handoff.md). While a
|
|
2743
|
+
* conversation is handed off, what the visitor types goes to the target,
|
|
2744
|
+
* and a bar above the input shows who is listening and the way back.
|
|
2745
|
+
*/
|
|
2746
|
+
get handoff(): HandoffConfig | null {
|
|
2747
|
+
return this._handoff?.config ?? null;
|
|
2748
|
+
}
|
|
2749
|
+
set handoff(next: HandoffConfig | null) {
|
|
2750
|
+
this._offHandoff?.();
|
|
2751
|
+
this._offHandoff = null;
|
|
2752
|
+
this._handoff?.dispose();
|
|
2753
|
+
this._handoff = null;
|
|
2754
|
+
if (!next?.relay || !next.targets) {
|
|
2755
|
+
if (this._els?.thread) this._paintHandoffBar();
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
const controller = new HandoffController(next);
|
|
2759
|
+
const listener = (e: Event) => this._onHandoffEvent((e as CustomEvent<HandoffEvent>).detail);
|
|
2760
|
+
controller.addEventListener('handoff', listener);
|
|
2761
|
+
this._offHandoff = () => controller.removeEventListener('handoff', listener);
|
|
2762
|
+
this._handoff = controller;
|
|
2763
|
+
// Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
|
|
2764
|
+
if (this._connect) this._connect = this._connect.then(() => this._registerHandoffBounded());
|
|
2765
|
+
if (this._els?.thread) {
|
|
2766
|
+
this._paintHandoffBar();
|
|
2767
|
+
if (this._activeId) void controller.resume(this._activeId);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
|
|
2772
|
+
private _registerHandoffBounded(): Promise<void> {
|
|
2773
|
+
return Promise.race([this._registerHandoff(), new Promise<void>((r) => setTimeout(r, 4000))]).catch((err) =>
|
|
2774
|
+
console.warn('[nxis-agent] handoff tools unavailable:', err),
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
/** Offer the handoff tools on the page's registry. */
|
|
2779
|
+
private async _registerHandoff(): Promise<void> {
|
|
2780
|
+
const controller = this._handoff;
|
|
2781
|
+
const mc = this.toolSource?.modelContext();
|
|
2782
|
+
if (!controller || !mc) return;
|
|
2783
|
+
const added = await registerHandoffTools(mc, controller, {
|
|
2784
|
+
conversationId: () => this._activeId ?? undefined,
|
|
2785
|
+
transcript: () => this._transcript(),
|
|
2786
|
+
});
|
|
2787
|
+
if (added.length) void this._refreshToolCount();
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
/** The conversation's last turns, text only, for a target allowed to see them. */
|
|
2791
|
+
private _transcript(): { role: 'visitor' | 'assistant'; text: string }[] {
|
|
2792
|
+
const conv = this._active();
|
|
2793
|
+
if (!conv) return [];
|
|
2794
|
+
return conv.items
|
|
2795
|
+
.filter((it): it is Extract<ThreadItem, { kind: 'msg' }> => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
|
|
2796
|
+
.slice(-10)
|
|
2797
|
+
.map((it) => ({ role: it.role === 'user' ? ('visitor' as const) : ('assistant' as const), text: it.text }));
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/** What a handoff reported, into the conversation it belongs to. */
|
|
2801
|
+
private _onHandoffEvent(e: HandoffEvent): void {
|
|
2802
|
+
const conv = this._conversations.find((c) => c.id === e.conversationId);
|
|
2803
|
+
if (!conv) return;
|
|
2804
|
+
if (e.type === 'message') {
|
|
2805
|
+
this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
|
|
2806
|
+
} else if (e.type === 'handback') {
|
|
2807
|
+
const { outcome, note, references } = e.handBack;
|
|
2808
|
+
const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
|
|
2809
|
+
// The assistant carries on from here, so it learns what happened: the note, and nothing else the
|
|
2810
|
+
// target said. A remote reply is shown to the visitor and never read as an instruction.
|
|
2811
|
+
conv.messages.push({
|
|
2812
|
+
role: 'user',
|
|
2813
|
+
content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
|
|
2814
|
+
});
|
|
2815
|
+
if (refs) this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
|
|
2816
|
+
} else {
|
|
2817
|
+
const text = handoffLine(e.session);
|
|
2818
|
+
const last = [...conv.items].reverse().find((it) => it.kind === 'handoff') as
|
|
2819
|
+
| Extract<ThreadItem, { kind: 'handoff' }>
|
|
2820
|
+
| undefined;
|
|
2821
|
+
if (text && last?.text !== text) {
|
|
2822
|
+
const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
|
|
2823
|
+
this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
|
|
2824
|
+
}
|
|
2825
|
+
if (e.state === 'ended' || e.state === 'failed') {
|
|
2826
|
+
conv.messages.push({
|
|
2827
|
+
role: 'user',
|
|
2828
|
+
content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
this._saveConversations();
|
|
2833
|
+
if (conv.id === this._activeId) this._paintHandoffBar();
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
|
|
2837
|
+
private _appendHandoffItem(conversationId: string, item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): void {
|
|
2838
|
+
const conv = this._conversations.find((c) => c.id === conversationId);
|
|
2839
|
+
if (!conv) return;
|
|
2840
|
+
conv.items.push(item);
|
|
2841
|
+
this._saveConversations();
|
|
2842
|
+
if (conversationId === this._activeId && this._els?.thread) {
|
|
2843
|
+
this._paintHandoffItem(item);
|
|
2844
|
+
this._scroll();
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
private _paintHandoffItem(item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): HTMLElement {
|
|
2849
|
+
this._clearEmpty();
|
|
2850
|
+
let el: HTMLElement;
|
|
2851
|
+
if (item.kind === 'remote') {
|
|
2852
|
+
el = document.createElement('div');
|
|
2853
|
+
el.className = 'remote';
|
|
2854
|
+
const who = document.createElement('div');
|
|
2855
|
+
who.className = 'remote-from';
|
|
2856
|
+
who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
|
|
2857
|
+
el.append(who, this._msgEl('assistant', item.text));
|
|
2858
|
+
} else {
|
|
2859
|
+
el = document.createElement('div');
|
|
2860
|
+
el.className = `handoff-note ${item.tone}`;
|
|
2861
|
+
el.textContent = item.text;
|
|
2862
|
+
}
|
|
2863
|
+
this._els.thread.appendChild(el);
|
|
2864
|
+
return el;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
/** The bar above the input: shown while the conversation is handed off. */
|
|
2868
|
+
private _paintHandoffBar(): void {
|
|
2869
|
+
if (!this._els?.handoffBar) return;
|
|
2870
|
+
const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
|
|
2871
|
+
this._els.handoffBar.hidden = !session;
|
|
2872
|
+
const input = this._els.input;
|
|
2873
|
+
if (!session) {
|
|
2874
|
+
input.placeholder = 'Message the agent…';
|
|
2875
|
+
input.setAttribute('aria-label', 'Message the agent');
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
const who = session.operator ?? session.label;
|
|
2879
|
+
const q = session.queue;
|
|
2880
|
+
const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
|
|
2881
|
+
const doing =
|
|
2882
|
+
session.state === 'connecting'
|
|
2883
|
+
? ' · connecting…'
|
|
2884
|
+
: session.state === 'queued'
|
|
2885
|
+
? inLine
|
|
2886
|
+
: session.state === 'awaiting-visitor'
|
|
2887
|
+
? ' · waiting for your reply'
|
|
2888
|
+
: '';
|
|
2889
|
+
this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
|
|
2890
|
+
input.placeholder = `Message ${who}…`;
|
|
2891
|
+
input.setAttribute('aria-label', `Message ${who}`);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/** "Back to the assistant": end the handoff for this conversation. */
|
|
2895
|
+
private async _endHandoff(): Promise<void> {
|
|
2896
|
+
if (!this._activeId || !this._handoff) return;
|
|
2897
|
+
await this._handoff.end(this._activeId);
|
|
2898
|
+
this._els.input.focus();
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2647
2901
|
/** Zero when the provider cannot count, which hides the token metrics. */
|
|
2648
2902
|
private async countTokens(text: string): Promise<number> {
|
|
2649
2903
|
return (await this.provider.countTokens?.(text)) ?? 0;
|
|
@@ -2836,7 +3090,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2836
3090
|
if (this._toolsOpen) void this._renderTools();
|
|
2837
3091
|
});
|
|
2838
3092
|
})
|
|
2839
|
-
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
|
|
3093
|
+
.catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
|
|
3094
|
+
// Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
|
|
3095
|
+
// them could only claim, in words, to be handing the visitor over.
|
|
3096
|
+
.then(() => this._registerHandoffBounded());
|
|
2840
3097
|
return this._connect;
|
|
2841
3098
|
}
|
|
2842
3099
|
|
|
@@ -3745,6 +4002,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3745
4002
|
padding: 2px 0 6px;
|
|
3746
4003
|
letter-spacing: .01em;
|
|
3747
4004
|
}
|
|
4005
|
+
|
|
4006
|
+
/* Handoffs. A target's message is a bubble with its name above; where the
|
|
4007
|
+
handoff stands is a quiet centred line; the bar says who is listening. */
|
|
4008
|
+
.remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
|
|
4009
|
+
.remote .msg { max-width: 100%; }
|
|
4010
|
+
.remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
|
|
4011
|
+
.handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
|
|
4012
|
+
.handoff-note.done { color: var(--green-strong); }
|
|
4013
|
+
.handoff-note.error { color: var(--red); }
|
|
4014
|
+
.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; }
|
|
4015
|
+
.handoff-bar[hidden] { display: none; }
|
|
4016
|
+
.handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
|
|
4017
|
+
.handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
4018
|
+
.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; }
|
|
4019
|
+
.handoff-bar .hb-back:hover { border-color: var(--ink-3); }
|
|
4020
|
+
.handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
4021
|
+
.overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
|
|
3748
4022
|
.composer .go svg { width: 17px; height: 17px; }
|
|
3749
4023
|
|
|
3750
4024
|
/* Attribution. Quiet enough to sit under a customer's own chat surface
|
|
@@ -3958,6 +4232,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3958
4232
|
</button>
|
|
3959
4233
|
<div class="thread"></div>
|
|
3960
4234
|
<div class="statusline"></div>
|
|
4235
|
+
<div class="handoff-bar" role="status" hidden>
|
|
4236
|
+
<span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
|
|
4237
|
+
<button class="hb-back" type="button">Back to the assistant</button>
|
|
4238
|
+
</div>
|
|
3961
4239
|
<div class="composer">
|
|
3962
4240
|
<div class="cbox">
|
|
3963
4241
|
<textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
|