@artooi/ag-ui-web-component 0.28.0 → 0.30.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/CHANGELOG.md +615 -1
- package/README.md +564 -35
- package/dist/ag-ui-web-component.bundle.js +491 -50
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +129 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +232 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +56 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2081 -98
- package/dist/index.js.map +4 -4
- package/dist/ui/approval_card.d.ts +18 -0
- package/dist/ui/approval_card.d.ts.map +1 -1
- package/dist/ui/checkpoint_menu.d.ts +10 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +16 -0
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/message_actions.d.ts +56 -0
- package/dist/ui/message_actions.d.ts.map +1 -0
- package/dist/ui/page_quote_offer.d.ts +33 -0
- package/dist/ui/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/quote_selection.d.ts +66 -0
- package/dist/ui/quote_selection.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +10 -0
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/stick_to_bottom.d.ts +55 -0
- package/dist/ui/stick_to_bottom.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/subagent_panel.d.ts +92 -0
- package/dist/ui/subagent_panel.d.ts.map +1 -0
- package/dist/ui/subagent_update.d.ts +19 -0
- package/dist/ui/subagent_update.d.ts.map +1 -0
- package/dist/ui/suggestion_chips.d.ts +29 -0
- package/dist/ui/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts +10 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +81 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +50 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +138 -1
- package/src/core/ag_ui_chat.ts +1081 -73
- package/src/core/agui_client.ts +89 -2
- package/src/index.ts +43 -0
- package/src/ui/approval_card.ts +90 -2
- package/src/ui/checkpoint_menu.ts +22 -5
- package/src/ui/confirmation_card.ts +29 -1
- package/src/ui/message_actions.ts +170 -0
- package/src/ui/page_quote_offer.ts +215 -0
- package/src/ui/quote_selection.ts +345 -0
- package/src/ui/relative_time.ts +11 -0
- package/src/ui/stick_to_bottom.ts +126 -0
- package/src/ui/styles.ts +410 -0
- package/src/ui/subagent_panel.ts +213 -0
- package/src/ui/subagent_update.ts +80 -0
- package/src/ui/suggestion_chips.ts +73 -0
- package/src/ui/thread_drawer.ts +22 -2
- package/src/ui/tool_call_card.ts +138 -3
- package/src/ui/ui_strings.ts +75 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from "@ag-ui/client";
|
|
2
2
|
import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
|
|
3
3
|
import {
|
|
4
|
+
ANNOUNCE_CLEAR_MS,
|
|
4
5
|
ATTACHMENT_EVENT,
|
|
5
6
|
CHART_ACTIVITY_TYPE,
|
|
6
7
|
COMPACTION_ACTIVITY_TYPE,
|
|
8
|
+
CUSTOM_AGENT_EVENT,
|
|
7
9
|
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
10
|
+
FEEDBACK_EVENT,
|
|
8
11
|
ICON_ATTACH,
|
|
9
12
|
ICON_LAUNCHER,
|
|
10
13
|
ICON_SEND,
|
|
11
14
|
ICON_STOP,
|
|
15
|
+
INVALIDATE_CUSTOM_NAME,
|
|
16
|
+
INVALIDATE_EVENT,
|
|
12
17
|
LOAD_CAPABILITY_TOOL,
|
|
18
|
+
MAX_TOOL_ROUNDS,
|
|
19
|
+
MESSAGE_ACTIONS,
|
|
13
20
|
MESSAGE_ROLE,
|
|
14
21
|
READ_PAGE_TOOL,
|
|
15
22
|
RUN_FINISHED_EVENT,
|
|
16
23
|
STATE_EVENT,
|
|
24
|
+
SUBAGENT_CUSTOM_NAME,
|
|
17
25
|
SUBMIT_EVENT,
|
|
26
|
+
SUGGESTIONS_ACTIVITY_TYPE,
|
|
18
27
|
TOGGLE_EVENT,
|
|
19
28
|
TOOL_CALL_STATUS,
|
|
20
29
|
TOOL_DISPLAY,
|
|
@@ -47,12 +56,20 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
|
|
|
47
56
|
import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
|
|
48
57
|
import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
|
|
49
58
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
59
|
+
import {
|
|
60
|
+
attachMessageActions,
|
|
61
|
+
messageActionBar,
|
|
62
|
+
messageActionButton,
|
|
63
|
+
} from "../ui/message_actions.js";
|
|
64
|
+
import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
|
|
50
65
|
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
51
66
|
import {
|
|
52
67
|
type QuestionRenderer,
|
|
53
68
|
type QuestionRequest,
|
|
54
69
|
requestQuestion,
|
|
55
70
|
} from "../ui/question_card.js";
|
|
71
|
+
import { asQuote, quotableSelection } from "../ui/quote_selection.js";
|
|
72
|
+
import type { RelativeTimeFormatter } from "../ui/relative_time.js";
|
|
56
73
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
57
74
|
import {
|
|
58
75
|
createResizeHandle,
|
|
@@ -63,10 +80,18 @@ import {
|
|
|
63
80
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
64
81
|
import { renderRunNotice } from "../ui/run_notice.js";
|
|
65
82
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
83
|
+
import { createStickToBottom, type StickToBottom } from "../ui/stick_to_bottom.js";
|
|
66
84
|
import { STYLES } from "../ui/styles.js";
|
|
85
|
+
import { SubAgentPanel } from "../ui/subagent_panel.js";
|
|
86
|
+
import { subAgentUpdate } from "../ui/subagent_update.js";
|
|
87
|
+
import { renderSuggestionChips } from "../ui/suggestion_chips.js";
|
|
67
88
|
import { ThoughtsBlock } from "../ui/thoughts_block.js";
|
|
68
89
|
import { ThreadDrawer } from "../ui/thread_drawer.js";
|
|
69
|
-
import {
|
|
90
|
+
import {
|
|
91
|
+
ToolCallCard,
|
|
92
|
+
type ToolDisplayMode,
|
|
93
|
+
type ToolPayloadFormatter,
|
|
94
|
+
} from "../ui/tool_call_card.js";
|
|
70
95
|
import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
|
|
71
96
|
import { VoiceInput } from "../ui/voice_input.js";
|
|
72
97
|
import {
|
|
@@ -109,6 +134,13 @@ export interface AttachmentsDetail {
|
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
/** `detail` shape of the {@link STATE_EVENT} CustomEvent. */
|
|
137
|
+
/** {@link FEEDBACK_EVENT} detail: what was rated, and how. */
|
|
138
|
+
export interface FeedbackDetail {
|
|
139
|
+
/** The rated message's text, as rendered. */
|
|
140
|
+
readonly content: string;
|
|
141
|
+
readonly rating: "up" | "down";
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
export interface StateDetail {
|
|
113
145
|
readonly state: Readonly<Record<string, unknown>>;
|
|
114
146
|
}
|
|
@@ -128,6 +160,83 @@ export interface ToolRun {
|
|
|
128
160
|
export interface RunFinishedDetail {
|
|
129
161
|
/** In settle order. Empty when the interaction called no tools. */
|
|
130
162
|
readonly tools: readonly ToolRun[];
|
|
163
|
+
/**
|
|
164
|
+
* Every key announced during the interaction, de-duplicated, first-seen order.
|
|
165
|
+
*
|
|
166
|
+
* **This is the field that makes adoption one line** for a host already
|
|
167
|
+
* listening here, and the `else` is the whole compatibility story:
|
|
168
|
+
*
|
|
169
|
+
* ```js
|
|
170
|
+
* if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
|
|
171
|
+
* else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* Empty against a server that announces nothing, so an old server and a new
|
|
175
|
+
* client fall through to the coarse refetch that shipped before either.
|
|
176
|
+
*/
|
|
177
|
+
readonly invalidated: readonly string[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Draw one activity, from its content alone.
|
|
182
|
+
*
|
|
183
|
+
* The contract is {@link ClientTool.render}'s, and for the same reason rather
|
|
184
|
+
* than by analogy. An activity is materialised into a `role: "activity"`
|
|
185
|
+
* message, persisted with the transcript, and re-fired on every restore -- so a
|
|
186
|
+
* renderer that writes to the page instead of returning DOM fires again on
|
|
187
|
+
* every thread load, which is exactly the bug the tool registry's purity rule
|
|
188
|
+
* was written to make unmakeable.
|
|
189
|
+
*
|
|
190
|
+
* - a pure function of `content` -- no host state, no network, no clock;
|
|
191
|
+
* - deterministic, so a reload reproduces what was there before;
|
|
192
|
+
* - free of effects outside the node it returns, which the component places.
|
|
193
|
+
*
|
|
194
|
+
* Return `null` for content that says nothing worth drawing. Anything already
|
|
195
|
+
* drawn under that message id is then removed: live and reload should agree,
|
|
196
|
+
* and the stored content is the version that could not be drawn.
|
|
197
|
+
*/
|
|
198
|
+
export type ActivityRenderer = (content: unknown) => Node | null;
|
|
199
|
+
|
|
200
|
+
/** One `activity_type` a host can draw. See {@link AgUiChat.registerActivityRenderer}. */
|
|
201
|
+
export interface ActivityRegistration {
|
|
202
|
+
/**
|
|
203
|
+
* The AG-UI `activity_type` this draws, matched exactly.
|
|
204
|
+
*
|
|
205
|
+
* An open string the protocol does not enumerate -- which is the whole reason
|
|
206
|
+
* this is a registry rather than a branch.
|
|
207
|
+
*/
|
|
208
|
+
readonly type: string;
|
|
209
|
+
readonly render: ActivityRenderer;
|
|
210
|
+
/**
|
|
211
|
+
* Shown in the transcript when something already drawn under this type stops
|
|
212
|
+
* being renderable. Omit for an activity whose disappearance needs no
|
|
213
|
+
* explanation.
|
|
214
|
+
*/
|
|
215
|
+
readonly removedNotice?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** `detail` shape of the {@link CUSTOM_AGENT_EVENT} CustomEvent. */
|
|
219
|
+
export interface CustomAgentDetail {
|
|
220
|
+
/** The `CUSTOM` event's `name`, verbatim. An open string; never interpreted here. */
|
|
221
|
+
readonly name: string;
|
|
222
|
+
/** Its `value`, verbatim and unparsed. `unknown` because the protocol says nothing about it. */
|
|
223
|
+
readonly value: unknown;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `detail` shape of the {@link INVALIDATE_EVENT} CustomEvent. */
|
|
227
|
+
export interface InvalidateDetail {
|
|
228
|
+
/**
|
|
229
|
+
* The resources that moved, as the server named them.
|
|
230
|
+
*
|
|
231
|
+
* **Opaque strings, and matching is exact.** `orders/42` does not imply
|
|
232
|
+
* `orders` -- a prefix rule would be this component guessing at a scheme it
|
|
233
|
+
* does not own, and `orders/1` would match `orders/11`. A server that wants
|
|
234
|
+
* the collection refreshed names it. Your own matching may be hierarchical,
|
|
235
|
+
* because in your vocabulary the scheme is known.
|
|
236
|
+
*/
|
|
237
|
+
readonly keys: readonly string[];
|
|
238
|
+
/** What caused the write -- usually the tool's name. `null` when unstated. */
|
|
239
|
+
readonly reason: string | null;
|
|
131
240
|
}
|
|
132
241
|
|
|
133
242
|
/** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
|
|
@@ -190,6 +299,9 @@ const SIZE_KEY = "ag-ui-chat:size";
|
|
|
190
299
|
/** Per-tab persistence key for the built-in theme toggle. */
|
|
191
300
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
192
301
|
|
|
302
|
+
/** Pixels between a selection and the offer to quote it. */
|
|
303
|
+
const QUOTE_GAP = 6;
|
|
304
|
+
|
|
193
305
|
/**
|
|
194
306
|
* Storage namespaces already spoken for in this document.
|
|
195
307
|
*
|
|
@@ -263,6 +375,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
263
375
|
*/
|
|
264
376
|
allowImages = false;
|
|
265
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Replace the relative timestamps in the thread drawer and the checkpoint
|
|
380
|
+
* panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
|
|
381
|
+
*
|
|
382
|
+
* The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
|
|
383
|
+
* this component, so it never disagrees with the page it is embedded in by
|
|
384
|
+
* guessing a locale. That is a good default and a bad requirement, which is
|
|
385
|
+
* what this is for.
|
|
386
|
+
*
|
|
387
|
+
* ```js
|
|
388
|
+
* const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
|
|
389
|
+
* chat.formatRelativeTime = (ts) =>
|
|
390
|
+
* rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
formatRelativeTime: RelativeTimeFormatter | null = null;
|
|
394
|
+
|
|
266
395
|
/** When true, destructive tools execute without a confirmation modal. */
|
|
267
396
|
autoConfirm = false;
|
|
268
397
|
|
|
@@ -290,6 +419,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
290
419
|
*/
|
|
291
420
|
approvalRenderer: ApprovalRenderer | null = null;
|
|
292
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Let the user edit a gated call's arguments before approving it.
|
|
424
|
+
*
|
|
425
|
+
* Off by default and **an assertion about your server**, not a negotiation:
|
|
426
|
+
* AG-UI carries `editedArgs` in the resume payload and gates it on the
|
|
427
|
+
* agent's own `approveWithEdits` capability, which this component never sees
|
|
428
|
+
* -- capabilities are not on the wire it reads. So the host says whether its
|
|
429
|
+
* agent honours them. Turned on against a server that does not, the user
|
|
430
|
+
* would edit arguments it silently discards, which is worse than not
|
|
431
|
+
* offering.
|
|
432
|
+
*
|
|
433
|
+
* Only affects calls whose arguments are known here: an interrupt names a
|
|
434
|
+
* `toolCallId`, and the tool card for that call is where the arguments still
|
|
435
|
+
* are. An interrupt naming no card gets the plain approve/deny.
|
|
436
|
+
*/
|
|
437
|
+
approveWithEdits = false;
|
|
438
|
+
|
|
293
439
|
/**
|
|
294
440
|
* Optional per-call confirmation predicate. When set it is authoritative,
|
|
295
441
|
* deciding from the tool name and args whether this particular call needs
|
|
@@ -395,6 +541,31 @@ export class AgUiChat extends HTMLElement {
|
|
|
395
541
|
*/
|
|
396
542
|
toolSummaries: Record<string, string> = {};
|
|
397
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Optional presentation hook for the two payload regions of a tool-call card
|
|
546
|
+
* -- the arguments and the result. Unset (the default) leaves both
|
|
547
|
+
* pretty-printed as JSON.
|
|
548
|
+
*
|
|
549
|
+
* The seam exists because a wide result has no good rendering as JSON: a
|
|
550
|
+
* thirty-field row is a wall of text where the host wanted a table, or a
|
|
551
|
+
* sentence. `ClientTool.render` cannot answer it -- it is handed the
|
|
552
|
+
* *arguments* only, and a server-side tool has no `ClientTool` at all, so the
|
|
553
|
+
* result region was the one part of the transcript a host could not reach.
|
|
554
|
+
*
|
|
555
|
+
* **Presentation, not translation.** The card and the model already read
|
|
556
|
+
* separate copies of a tool result: the model's is maintained by
|
|
557
|
+
* `@ag-ui/client` from the same event and persisted with the history, and the
|
|
558
|
+
* card has always shown that string reformatted. So a formatter changes what
|
|
559
|
+
* the person reads and nothing the agent reads -- which makes restyling safe
|
|
560
|
+
* and *rewording* a way to make the card disagree with the prose beside it.
|
|
561
|
+
* Rename a value on the server, where it reaches both.
|
|
562
|
+
*
|
|
563
|
+
* Read at render time rather than captured, so a host that sets it from a
|
|
564
|
+
* framework effect after the first card still formats the results that settle
|
|
565
|
+
* afterwards. See {@link ToolPayloadFormatter}.
|
|
566
|
+
*/
|
|
567
|
+
formatToolPayload: ToolPayloadFormatter | null = null;
|
|
568
|
+
|
|
398
569
|
/**
|
|
399
570
|
* Localizable UI strings — a partial override merged over the English
|
|
400
571
|
* {@link DEFAULT_UI_STRINGS}. Resolved once on connect (so set it before the
|
|
@@ -449,13 +620,32 @@ export class AgUiChat extends HTMLElement {
|
|
|
449
620
|
readonly #toolRegistry = new ClientToolRegistry();
|
|
450
621
|
/** Tool-call cards awaiting execution, keyed by call id. */
|
|
451
622
|
readonly #toolCards = new Map<string, ToolCallCard>();
|
|
623
|
+
/**
|
|
624
|
+
* The live delegation panels, keyed by the **parent's** `delegate_task` call
|
|
625
|
+
* id — which is what the wire keys a sub-agent's progress on, so this map and
|
|
626
|
+
* {@link #toolCards} answer to the same key.
|
|
627
|
+
*
|
|
628
|
+
* Kept beside the cards rather than on them, so a card stays a card: the tool
|
|
629
|
+
* card holds the slot and this holds what went into it, the same division the
|
|
630
|
+
* approval prompt already uses.
|
|
631
|
+
*/
|
|
632
|
+
readonly #subagentPanels = new Map<string, SubAgentPanel>();
|
|
452
633
|
/**
|
|
453
634
|
* Call ids whose card was already settled from a streamed server-side result
|
|
454
635
|
* (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
|
|
455
636
|
* the real output with the generic "executed on the server" fallback.
|
|
456
637
|
*/
|
|
457
638
|
/** Whether a server-pushed chart activity is drawn. Off unless asked for. */
|
|
458
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Which `activity_type`s this element can draw, by name.
|
|
641
|
+
*
|
|
642
|
+
* A registry rather than a branch because `activity_type` is an open string
|
|
643
|
+
* the protocol does not enumerate. The two built-ins go through it like any
|
|
644
|
+
* host registration, which is the test that the seam is real.
|
|
645
|
+
*/
|
|
646
|
+
readonly #activityRenderers = new Map<string, ActivityRegistration>();
|
|
647
|
+
/** Types that arrived with nobody registered to draw them. See {@link unhandledActivityTypes}. */
|
|
648
|
+
readonly #unhandledActivityTypes = new Set<string>();
|
|
459
649
|
|
|
460
650
|
/** Card elements by call id, so a rendering handler can find its own card. */
|
|
461
651
|
readonly #cardElements = new Map<string, HTMLElement>();
|
|
@@ -470,7 +660,77 @@ export class AgUiChat extends HTMLElement {
|
|
|
470
660
|
* Spans tool rounds and an approval interrupt; cleared when the event fires.
|
|
471
661
|
*/
|
|
472
662
|
#runTools: { readonly id: string; readonly name: string }[] = [];
|
|
663
|
+
/**
|
|
664
|
+
* Keys announced during this interaction, de-duplicated in first-seen order.
|
|
665
|
+
*
|
|
666
|
+
* Per element, never module-level: a second mounted chat is a second run, and
|
|
667
|
+
* sharing this would tell one page to refetch on the other's writes. Reset by
|
|
668
|
+
* {@link AgUiChat.#dispatchRunFinished}, which is the one place that has read
|
|
669
|
+
* it.
|
|
670
|
+
*/
|
|
671
|
+
/**
|
|
672
|
+
* Tool names the user waived confirmation for, for the life of this element.
|
|
673
|
+
*
|
|
674
|
+
* Per instance and never persisted: a session decision that outlived the tab
|
|
675
|
+
* would be a permanent grant made by one click, which is the thing
|
|
676
|
+
* `autoConfirm` already exists to say deliberately. Cleared with the element.
|
|
677
|
+
*/
|
|
678
|
+
readonly #sessionApproved = new Set<string>();
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The one action row currently carrying Retry, if any.
|
|
682
|
+
*
|
|
683
|
+
* Retry belongs to the **last** turn only: re-running an older one is
|
|
684
|
+
* branching, and for a page-driving agent editing a past turn is not neutral
|
|
685
|
+
* -- those turns clicked buttons, and re-running turn 3 does not un-save what
|
|
686
|
+
* turn 5 saved. Holding a single owner is what keeps exactly one offer on
|
|
687
|
+
* screen without per-bubble bookkeeping.
|
|
688
|
+
*/
|
|
689
|
+
#retryOwner: HTMLElement | null = null;
|
|
690
|
+
|
|
691
|
+
#runInvalidated = new Set<string>();
|
|
473
692
|
readonly #root: ShadowRoot;
|
|
693
|
+
/** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
|
|
694
|
+
readonly #announcer = document.createElement("div");
|
|
695
|
+
/** Return-to-foot affordance, shown only once something has been missed. */
|
|
696
|
+
readonly #jumpButton = document.createElement("button");
|
|
697
|
+
/**
|
|
698
|
+
* Offer to quote the current selection, floated beside it.
|
|
699
|
+
*
|
|
700
|
+
* Shares {@link AgUiChat.#messagesWrap} with the jump button for the same
|
|
701
|
+
* reason: it is positioned against the transcript, and must not scroll away
|
|
702
|
+
* with the words it is pointing at.
|
|
703
|
+
*/
|
|
704
|
+
readonly #quoteButton = document.createElement("button");
|
|
705
|
+
/** What {@link AgUiChat.#quoteButton} would quote, while it is showing. */
|
|
706
|
+
#quoting = "";
|
|
707
|
+
/** The host-page offer, while one is attached; see {@link AgUiChat.offerQuoteInPage}. */
|
|
708
|
+
#pageQuote: PageQuoteOffer | null = null;
|
|
709
|
+
/**
|
|
710
|
+
* Positioning context for {@link AgUiChat.#jumpButton}.
|
|
711
|
+
*
|
|
712
|
+
* The button cannot live in the scrolling list -- it would scroll away with
|
|
713
|
+
* the content it is offering to scroll to -- and it cannot be positioned
|
|
714
|
+
* against the panel either: the panel's foot is below the composer, the skill
|
|
715
|
+
* chips and the footer, so `bottom` measured from there lands the button on
|
|
716
|
+
* top of the composer rather than over the transcript. This wrapper is the
|
|
717
|
+
* only box whose foot *is* the transcript's foot.
|
|
718
|
+
*/
|
|
719
|
+
readonly #messagesWrap = document.createElement("div");
|
|
720
|
+
/** Follows the foot of the transcript, and stops when the reader scrolls away. */
|
|
721
|
+
#scroller!: StickToBottom;
|
|
722
|
+
/** Pending clear of {@link AgUiChat.#announcer}; see why it is cleared at all. */
|
|
723
|
+
#announceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
724
|
+
/**
|
|
725
|
+
* Whether this turn already announced how it ended.
|
|
726
|
+
*
|
|
727
|
+
* `onSettled` is the terminal guarantee and fires however the run ended, so
|
|
728
|
+
* it is the only place that can promise the user hears *something*. But a
|
|
729
|
+
* stopped or failed run has already said the truer thing from `onCancelled`
|
|
730
|
+
* or `onError`, and "assistant answered" after "response stopped" is worse
|
|
731
|
+
* than silence.
|
|
732
|
+
*/
|
|
733
|
+
#announcedOutcome = false;
|
|
474
734
|
readonly #chat: HTMLDivElement;
|
|
475
735
|
readonly #messages: HTMLDivElement;
|
|
476
736
|
readonly #input: HTMLTextAreaElement;
|
|
@@ -590,6 +850,33 @@ export class AgUiChat extends HTMLElement {
|
|
|
590
850
|
this.#launcher = document.createElement("button");
|
|
591
851
|
this.#badge = document.createElement("span");
|
|
592
852
|
this.#emptyWrap = document.createElement("div");
|
|
853
|
+
// The compaction notice is a registration, not a branch -- and going through
|
|
854
|
+
// the seam earns it two things it did not have: a reload puts it back (it is
|
|
855
|
+
// content, and content replays), and a server redrawing under the same id
|
|
856
|
+
// replaces it rather than adding a second notice for one event.
|
|
857
|
+
this.registerActivityRenderer({
|
|
858
|
+
type: COMPACTION_ACTIVITY_TYPE,
|
|
859
|
+
render: (content) => {
|
|
860
|
+
const removed = compactionRemoved(content);
|
|
861
|
+
return removed === null
|
|
862
|
+
? null
|
|
863
|
+
: renderRunNotice(
|
|
864
|
+
"\u{1F5DC}",
|
|
865
|
+
this.#strings.historyCompacted.replace("{count}", String(removed)),
|
|
866
|
+
"compaction",
|
|
867
|
+
);
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
// Follow-up chips, registered through the same seam for the same reasons:
|
|
871
|
+
// a reload puts them back, and a server pushing a new set under a new id
|
|
872
|
+
// supersedes the old one rather than leaving two offers on screen.
|
|
873
|
+
this.registerActivityRenderer({
|
|
874
|
+
type: SUGGESTIONS_ACTIVITY_TYPE,
|
|
875
|
+
render: (content) =>
|
|
876
|
+
renderSuggestionChips(content, this.#strings, (prompt) => {
|
|
877
|
+
void this.sendMessage(prompt);
|
|
878
|
+
}),
|
|
879
|
+
});
|
|
593
880
|
this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
|
|
594
881
|
this.#drawer = new ThreadDrawer({
|
|
595
882
|
onSelect: (threadId) => {
|
|
@@ -677,6 +964,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
677
964
|
/** Load the checkpoint panel with the runs that can actually be continued. */
|
|
678
965
|
async #refreshCheckpoints(): Promise<void> {
|
|
679
966
|
const index = this.#runs();
|
|
967
|
+
// Pushed at render rather than at connect: `formatRelativeTime` is a
|
|
968
|
+
// property, so a host may set it long after the element mounted.
|
|
969
|
+
this.#checkpoints.setRelativeTimeFormatter(this.formatRelativeTime);
|
|
680
970
|
this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
|
|
681
971
|
}
|
|
682
972
|
|
|
@@ -930,7 +1220,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
930
1220
|
});
|
|
931
1221
|
this.#confirmAbort = null;
|
|
932
1222
|
this.#updateEmptyState();
|
|
933
|
-
this.#
|
|
1223
|
+
this.#scroller.follow();
|
|
934
1224
|
return answer;
|
|
935
1225
|
}
|
|
936
1226
|
|
|
@@ -1242,8 +1532,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
1242
1532
|
this.#claimedNs = null;
|
|
1243
1533
|
}
|
|
1244
1534
|
this.#cancelRun();
|
|
1535
|
+
// The page offer listens on the host's document, not on anything of ours,
|
|
1536
|
+
// so nothing else would ever take it down.
|
|
1537
|
+
this.#pageQuote?.detach();
|
|
1538
|
+
this.#pageQuote = null;
|
|
1245
1539
|
this.#attachTray?.dispose();
|
|
1246
1540
|
this.#voice?.dispose();
|
|
1541
|
+
this.#scroller.dispose();
|
|
1542
|
+
if (this.#announceTimer !== null) {
|
|
1543
|
+
clearTimeout(this.#announceTimer);
|
|
1544
|
+
this.#announceTimer = null;
|
|
1545
|
+
}
|
|
1247
1546
|
}
|
|
1248
1547
|
|
|
1249
1548
|
/**
|
|
@@ -1369,6 +1668,162 @@ export class AgUiChat extends HTMLElement {
|
|
|
1369
1668
|
this.#input.focus();
|
|
1370
1669
|
}
|
|
1371
1670
|
|
|
1671
|
+
/**
|
|
1672
|
+
* Put `text` into the composer as a markdown quotation, and focus it.
|
|
1673
|
+
*
|
|
1674
|
+
* Deliberately **not** a send. Quoting is how a question narrows to one part
|
|
1675
|
+
* of an answer, so the quotation is the preamble and the question is what
|
|
1676
|
+
* comes next -- the caret is left after it, on its own line.
|
|
1677
|
+
*
|
|
1678
|
+
* This is also the seam for the half of this feature the component cannot
|
|
1679
|
+
* build: selection in the **host page**. A widget mounted beside a table can
|
|
1680
|
+
* be asked about a row, and nothing in a chat's own transcript can offer
|
|
1681
|
+
* that. A host reads its own selection, however it likes, and calls this.
|
|
1682
|
+
*
|
|
1683
|
+
* No-ops on text that is empty or only whitespace.
|
|
1684
|
+
*/
|
|
1685
|
+
quote(text: string): void {
|
|
1686
|
+
const quoted = asQuote(text);
|
|
1687
|
+
if (quoted === "") {
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
// Appended after whatever is already typed, on a fresh paragraph: a second
|
|
1691
|
+
// quotation is a second thing being asked about, not a replacement for the
|
|
1692
|
+
// first. Trailing blank lines are dropped so repeated quoting does not
|
|
1693
|
+
// accumulate gaps.
|
|
1694
|
+
const current = this.#input.value.replace(/\s+$/, "");
|
|
1695
|
+
this.#input.value = current === "" ? quoted : `${current}\n\n${quoted}`;
|
|
1696
|
+
this.#autoGrow();
|
|
1697
|
+
this.#input.focus();
|
|
1698
|
+
const end = this.#input.value.length;
|
|
1699
|
+
this.#input.setSelectionRange(end, end);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Offer to quote what the user selects in the **host page**, not just in the
|
|
1704
|
+
* transcript. Returns a function that stops offering.
|
|
1705
|
+
*
|
|
1706
|
+
* The same select-then-offer gesture, over a table, a diff, a report -- the
|
|
1707
|
+
* surface the user actually works in, which is the half of quoting no hosted
|
|
1708
|
+
* chat can reach. Opt-in, because it listens on the host's document and that
|
|
1709
|
+
* is theirs to grant.
|
|
1710
|
+
*
|
|
1711
|
+
* Deliberately **not** a four-line recipe, which is how this shipped first
|
|
1712
|
+
* and was wrong: a page listener that quotes every settled selection appends
|
|
1713
|
+
* to the composer on every drag made to read, to copy or to fix a typo -- and
|
|
1714
|
+
* it cannot tell a selection in the page's prose from one inside the user's
|
|
1715
|
+
* own half-typed `<input>`, because Chrome reports a field's internal
|
|
1716
|
+
* selection as an ordinary range over the field's *wrapper*. See
|
|
1717
|
+
* {@link attachQuoteOffer} for the guards.
|
|
1718
|
+
*
|
|
1719
|
+
* Detached automatically when the element leaves the document; a host that
|
|
1720
|
+
* re-mounts it calls this again.
|
|
1721
|
+
*/
|
|
1722
|
+
offerQuoteInPage(within: HTMLElement = document.body): () => void {
|
|
1723
|
+
this.#pageQuote?.detach();
|
|
1724
|
+
const offer = attachQuoteOffer({
|
|
1725
|
+
within,
|
|
1726
|
+
label: this.#strings.quoteSelection,
|
|
1727
|
+
exclude: this,
|
|
1728
|
+
onQuote: (text) => this.quote(text),
|
|
1729
|
+
});
|
|
1730
|
+
this.#pageQuote = offer;
|
|
1731
|
+
return () => {
|
|
1732
|
+
offer.detach();
|
|
1733
|
+
if (this.#pageQuote === offer) {
|
|
1734
|
+
this.#pageQuote = null;
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** Whether the transcript offers to quote what the user selects. */
|
|
1740
|
+
#quoteEnabled(): boolean {
|
|
1741
|
+
return this.getAttribute("data-quote-selection") !== "false";
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Offer to quote the settled selection, or retire the offer.
|
|
1746
|
+
*
|
|
1747
|
+
* `event` is passed for its coordinates and only those: they say which line
|
|
1748
|
+
* of a selection spanning several messages the offer should hang from. A
|
|
1749
|
+
* keyboard selection has none, and the first line is used instead.
|
|
1750
|
+
*/
|
|
1751
|
+
#onSelectionSettled(event?: MouseEvent): void {
|
|
1752
|
+
if (!this.#quoteEnabled()) {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const near = event === undefined ? undefined : { x: event.clientX, y: event.clientY };
|
|
1756
|
+
const selected = quotableSelection(this.#messages, [this.#root], near);
|
|
1757
|
+
if (selected === null) {
|
|
1758
|
+
this.#hideQuote();
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
this.#quoting = selected.text;
|
|
1762
|
+
this.#placeQuote(selected.rect);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/** Float the offer beside `rect`, kept inside the transcript's own box. */
|
|
1766
|
+
#placeQuote(rect: DOMRect): void {
|
|
1767
|
+
// Unhidden first: a hidden element measures zero, and its own size is what
|
|
1768
|
+
// decides whether it fits above the selection and how far to pull it left.
|
|
1769
|
+
this.#quoteButton.hidden = false;
|
|
1770
|
+
const wrap = this.#messagesWrap.getBoundingClientRect();
|
|
1771
|
+
const top = rect.top - wrap.top;
|
|
1772
|
+
// Above the selection by default, below it when there is no room --
|
|
1773
|
+
// selecting the first line of the transcript is the ordinary case, not an
|
|
1774
|
+
// edge one, and an offer clipped by the header is an offer nobody takes.
|
|
1775
|
+
const below = top < QUOTE_GAP + this.#quoteButton.offsetHeight;
|
|
1776
|
+
this.#quoteButton.dataset["below"] = String(below);
|
|
1777
|
+
this.#quoteButton.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
|
|
1778
|
+
// Centred on the selection, then pulled back by its own half-width so a
|
|
1779
|
+
// selection at either margin does not push the offer out of the panel.
|
|
1780
|
+
const half = this.#quoteButton.offsetWidth / 2;
|
|
1781
|
+
const centre = rect.left + rect.width / 2 - wrap.left;
|
|
1782
|
+
this.#quoteButton.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/** Retire the offer, and forget what it was pointing at. */
|
|
1786
|
+
#hideQuote(): void {
|
|
1787
|
+
this.#quoteButton.hidden = true;
|
|
1788
|
+
this.#quoting = "";
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* The tool-round budget from `data-max-tool-rounds`, for one send.
|
|
1793
|
+
*
|
|
1794
|
+
* Anything unparseable becomes `NaN`, which {@link AgUiClient} rejects along
|
|
1795
|
+
* with a bound below one -- so the two ways of setting this are validated in
|
|
1796
|
+
* one place rather than agreeing by coincidence.
|
|
1797
|
+
*/
|
|
1798
|
+
#maxToolRounds(): number {
|
|
1799
|
+
const attr = this.getAttribute("data-max-tool-rounds");
|
|
1800
|
+
return attr === null ? MAX_TOOL_ROUNDS : Number.parseInt(attr, 10);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Which message actions a finished bubble offers, from
|
|
1805
|
+
* `data-message-actions`.
|
|
1806
|
+
*
|
|
1807
|
+
* Absent means all of them, so the attribute only ever subtracts: the row
|
|
1808
|
+
* shipped without an off switch and a host that never sets this must keep
|
|
1809
|
+
* exactly what it had. A value names the survivors, which makes
|
|
1810
|
+
* `data-message-actions="false"` -- the spelling its sibling
|
|
1811
|
+
* `data-quote-selection` uses -- an empty set by falling out of the same rule
|
|
1812
|
+
* rather than by a case of its own.
|
|
1813
|
+
*/
|
|
1814
|
+
#messageActions(): ReadonlySet<string> {
|
|
1815
|
+
const attr = this.getAttribute("data-message-actions");
|
|
1816
|
+
if (attr === null) {
|
|
1817
|
+
return new Set(Object.values(MESSAGE_ACTIONS));
|
|
1818
|
+
}
|
|
1819
|
+
return new Set(
|
|
1820
|
+
attr
|
|
1821
|
+
.split(",")
|
|
1822
|
+
.map((token) => token.trim())
|
|
1823
|
+
.filter((token) => token !== ""),
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1372
1827
|
/** The client-side upload size cap from `data-attachment-max-bytes`. */
|
|
1373
1828
|
#attachmentMaxBytes(): number {
|
|
1374
1829
|
const attr = this.getAttribute("data-attachment-max-bytes");
|
|
@@ -1986,6 +2441,18 @@ export class AgUiChat extends HTMLElement {
|
|
|
1986
2441
|
/** Drop the in-memory run + transcript, leaving the thread id untouched. */
|
|
1987
2442
|
#resetState(): void {
|
|
1988
2443
|
this.#client = null;
|
|
2444
|
+
this.#clearTranscript();
|
|
2445
|
+
this.#initialMessages = [];
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* Wipe the rendered transcript and everything that indexes into it.
|
|
2450
|
+
*
|
|
2451
|
+
* Split from {@link #resetState} because a retry re-renders the transcript
|
|
2452
|
+
* while keeping the *client*: dropping the client there would take the
|
|
2453
|
+
* agent's message list with it, which is the thing being truncated.
|
|
2454
|
+
*/
|
|
2455
|
+
#clearTranscript(): void {
|
|
1989
2456
|
// Before the transcript goes: a render still queued would otherwise fire
|
|
1990
2457
|
// against the wiped list and open a fresh bubble holding the discarded
|
|
1991
2458
|
// conversation's last tokens.
|
|
@@ -1994,16 +2461,58 @@ export class AgUiChat extends HTMLElement {
|
|
|
1994
2461
|
this.#thoughts = null;
|
|
1995
2462
|
this.#hidePending();
|
|
1996
2463
|
this.#toolCards.clear();
|
|
2464
|
+
// The panels go with the cards they hung off. Nothing restores them: the
|
|
2465
|
+
// progress rode the imperative carrier and was never persisted, which is
|
|
2466
|
+
// the correct half of that split -- a delegation that was live before this
|
|
2467
|
+
// transcript was wiped is not live now.
|
|
2468
|
+
this.#subagentPanels.clear();
|
|
1997
2469
|
this.#serverSettled.clear();
|
|
1998
2470
|
this.#cardElements.clear();
|
|
1999
2471
|
this.#activityBlocks.clear();
|
|
2000
|
-
this.#
|
|
2472
|
+
this.#retryOwner = null;
|
|
2001
2473
|
this.#attachTray?.clear();
|
|
2002
2474
|
// Keep the empty-state region; everything else clears.
|
|
2003
2475
|
this.#messages.replaceChildren(this.#emptyWrap);
|
|
2004
2476
|
this.#updateEmptyState();
|
|
2005
2477
|
}
|
|
2006
2478
|
|
|
2479
|
+
/**
|
|
2480
|
+
* Ask the same question again and replace the answer.
|
|
2481
|
+
*
|
|
2482
|
+
* History is truncated to the most recent user message inclusive and the run
|
|
2483
|
+
* repeats, so the agent answers what it was asked rather than being told its
|
|
2484
|
+
* last answer was wrong. Returns `false` when there is nothing to retry or a
|
|
2485
|
+
* run is already in flight.
|
|
2486
|
+
*
|
|
2487
|
+
* Public because a host with its own message UI wants the same button, and
|
|
2488
|
+
* because the failed-run notice reaches it from outside the action row.
|
|
2489
|
+
*
|
|
2490
|
+
* **A retried turn re-runs its tools**, which for a page-driving agent is not
|
|
2491
|
+
* neutral: the previous attempt already clicked what it clicked, and this
|
|
2492
|
+
* does not undo it. Confirmation still applies, so a destructive tool asks
|
|
2493
|
+
* again -- unless the user waived it for this session.
|
|
2494
|
+
*/
|
|
2495
|
+
async retryLastTurn(): Promise<boolean> {
|
|
2496
|
+
if (this.#running) {
|
|
2497
|
+
return false;
|
|
2498
|
+
}
|
|
2499
|
+
const client = this.#ensureClient();
|
|
2500
|
+
const kept = client.truncateToLastUser();
|
|
2501
|
+
if (kept === null) {
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2504
|
+
// Re-render between the truncation and the run: the kept turns replay as
|
|
2505
|
+
// restored history (static, no entrance animation), and only the new answer
|
|
2506
|
+
// arrives live. Streaming into the old transcript would put the new answer
|
|
2507
|
+
// underneath the one it replaces.
|
|
2508
|
+
this.#clearTranscript();
|
|
2509
|
+
for (const message of kept) {
|
|
2510
|
+
this.#renderHistoricMessage(message);
|
|
2511
|
+
}
|
|
2512
|
+
await client.resume();
|
|
2513
|
+
return true;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2007
2516
|
/** Switch the active conversation to an existing thread and replay it. */
|
|
2008
2517
|
async #switchThread(threadId: string): Promise<void> {
|
|
2009
2518
|
if (threadId === this.#threadId) {
|
|
@@ -2034,6 +2543,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2034
2543
|
|
|
2035
2544
|
/** Reload the drawer's thread list, marking the active thread. */
|
|
2036
2545
|
async #refreshDrawer(): Promise<void> {
|
|
2546
|
+
this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
|
|
2037
2547
|
this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
|
|
2038
2548
|
}
|
|
2039
2549
|
|
|
@@ -2139,7 +2649,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
2139
2649
|
// transcript mounts at once, so animating every bubble's text in
|
|
2140
2650
|
// parallel looks wrong. Mark it so the fade CSS skips it, and don't
|
|
2141
2651
|
// wrap words.
|
|
2142
|
-
this.appendMessage(MESSAGE_ROLE.ASSISTANT, text)
|
|
2652
|
+
const restoredBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
|
|
2653
|
+
restoredBubble.classList.add("message--restored");
|
|
2654
|
+
this.#attachActions(restoredBubble);
|
|
2143
2655
|
}
|
|
2144
2656
|
// Narrowed rather than trusted, for the same reason `messageAttachments`
|
|
2145
2657
|
// narrows the neighbouring field: anything that throws in this loop aborts
|
|
@@ -2178,8 +2690,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
2178
2690
|
// chart's data is in the transcript already and survives a reload. Only
|
|
2179
2691
|
// the drawing had to be put back.
|
|
2180
2692
|
const activity = message as unknown as { activityType?: unknown; content?: unknown };
|
|
2181
|
-
if (activity.activityType ===
|
|
2182
|
-
this.#
|
|
2693
|
+
if (typeof activity.activityType === "string") {
|
|
2694
|
+
this.#drawActivity(message.id, activity.activityType, activity.content);
|
|
2183
2695
|
}
|
|
2184
2696
|
return;
|
|
2185
2697
|
}
|
|
@@ -2254,7 +2766,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
2254
2766
|
this.#messages.appendChild(bubble);
|
|
2255
2767
|
}
|
|
2256
2768
|
this.#updateEmptyState();
|
|
2257
|
-
|
|
2769
|
+
// A user bubble means someone just pressed Send, which is as deliberate as
|
|
2770
|
+
// pressing the jump button -- so it goes to the bottom even if they had
|
|
2771
|
+
// scrolled away to re-read something before typing.
|
|
2772
|
+
if (role === MESSAGE_ROLE.USER) {
|
|
2773
|
+
this.#scroller.jump();
|
|
2774
|
+
} else {
|
|
2775
|
+
this.#scroller.follow();
|
|
2776
|
+
}
|
|
2258
2777
|
return bubble;
|
|
2259
2778
|
}
|
|
2260
2779
|
|
|
@@ -2278,9 +2797,6 @@ export class AgUiChat extends HTMLElement {
|
|
|
2278
2797
|
}
|
|
2279
2798
|
|
|
2280
2799
|
#render(): void {
|
|
2281
|
-
const style = document.createElement("style");
|
|
2282
|
-
style.textContent = STYLES;
|
|
2283
|
-
|
|
2284
2800
|
this.#chat.className = "chat";
|
|
2285
2801
|
this.#chat.setAttribute("part", "panel");
|
|
2286
2802
|
|
|
@@ -2355,11 +2871,68 @@ export class AgUiChat extends HTMLElement {
|
|
|
2355
2871
|
|
|
2356
2872
|
this.#messages.className = "messages";
|
|
2357
2873
|
this.#messages.setAttribute("part", "messages");
|
|
2358
|
-
// Screen readers announce streamed messages as they arrive.
|
|
2359
2874
|
this.#messages.setAttribute("role", "log");
|
|
2360
|
-
|
|
2875
|
+
// NOT a live region. The streaming bubble's innerHTML is replaced inside
|
|
2876
|
+
// this element on every animation frame, and `role="log"` already implies
|
|
2877
|
+
// polite announcement whose default `aria-relevant` includes text
|
|
2878
|
+
// additions -- so a screen reader was asked to re-announce the whole answer
|
|
2879
|
+
// tens of times as it streamed. `aria-live="off"` is an explicit override
|
|
2880
|
+
// of the role's implicit value, which is why the role can stay: the log
|
|
2881
|
+
// semantics are what let the transcript be navigated as one, and only the
|
|
2882
|
+
// announcing is the defect. Status goes to #announcer instead.
|
|
2883
|
+
this.#messages.setAttribute("aria-live", "off");
|
|
2361
2884
|
this.#messages.setAttribute("aria-label", this.#strings.conversation);
|
|
2362
2885
|
|
|
2886
|
+
this.#jumpButton.className = "jump-latest";
|
|
2887
|
+
this.#jumpButton.type = "button";
|
|
2888
|
+
this.#jumpButton.setAttribute("part", "jump-latest");
|
|
2889
|
+
this.#jumpButton.textContent = this.#strings.jumpToLatest;
|
|
2890
|
+
this.#jumpButton.addEventListener("click", () => {
|
|
2891
|
+
this.#scroller.jump();
|
|
2892
|
+
});
|
|
2893
|
+
|
|
2894
|
+
this.#quoteButton.className = "quote-selection";
|
|
2895
|
+
this.#quoteButton.type = "button";
|
|
2896
|
+
this.#quoteButton.setAttribute("part", "quote-selection");
|
|
2897
|
+
this.#quoteButton.textContent = this.#strings.quoteSelection;
|
|
2898
|
+
this.#quoteButton.hidden = true;
|
|
2899
|
+
// `mousedown` rather than `click`: pressing anywhere else collapses the
|
|
2900
|
+
// selection first, and by the time a click lands there is nothing left to
|
|
2901
|
+
// quote. Preventing the default keeps the selection alive long enough to
|
|
2902
|
+
// read it.
|
|
2903
|
+
this.#quoteButton.addEventListener("mousedown", (event) => {
|
|
2904
|
+
event.preventDefault();
|
|
2905
|
+
});
|
|
2906
|
+
this.#quoteButton.addEventListener("click", () => {
|
|
2907
|
+
this.quote(this.#quoting);
|
|
2908
|
+
window.getSelection()?.removeAllRanges();
|
|
2909
|
+
this.#hideQuote();
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
// A settled selection, by either input. `mouseup` rather than
|
|
2913
|
+
// `selectionchange` so the offer does not chase the pointer mid-drag; the
|
|
2914
|
+
// second half of the same gesture, `mousedown`, retires the previous offer
|
|
2915
|
+
// before the new selection exists.
|
|
2916
|
+
this.#messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
|
|
2917
|
+
this.#messages.addEventListener("keyup", () => this.#onSelectionSettled());
|
|
2918
|
+
this.#messages.addEventListener("mousedown", () => this.#hideQuote());
|
|
2919
|
+
|
|
2920
|
+
// Built here rather than at field initialisation: the viewport has to exist
|
|
2921
|
+
// and the observer has to have something to observe.
|
|
2922
|
+
this.#scroller = createStickToBottom({
|
|
2923
|
+
viewport: this.#messages,
|
|
2924
|
+
onMissedContent: (missed) => {
|
|
2925
|
+
this.#jumpButton.dataset["missed"] = String(missed);
|
|
2926
|
+
},
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
this.#announcer.className = "sr-only";
|
|
2930
|
+
this.#announcer.setAttribute("role", "status");
|
|
2931
|
+
this.#announcer.setAttribute("aria-live", "polite");
|
|
2932
|
+
// Atomic: each announcement replaces the last and is read whole. Without
|
|
2933
|
+
// it a reader may announce only the changed words between two statuses.
|
|
2934
|
+
this.#announcer.setAttribute("aria-atomic", "true");
|
|
2935
|
+
|
|
2363
2936
|
// Empty-state region: a host slot at the top of the list, hidden as soon as
|
|
2364
2937
|
// anything renders.
|
|
2365
2938
|
this.#emptyWrap.className = "empty";
|
|
@@ -2450,9 +3023,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
2450
3023
|
inputRow.append(composer, this.#fileInput);
|
|
2451
3024
|
// Skill surfaces sit just above the input: palette (opens on `/`), chips,
|
|
2452
3025
|
// the missing-placeholder hint, and the pending-attachments tray.
|
|
3026
|
+
this.#messagesWrap.className = "messages-wrap";
|
|
3027
|
+
// Sibling of the list inside a shared box, not a child of it: the
|
|
3028
|
+
// affordance offering to scroll must not scroll away with the content.
|
|
3029
|
+
this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#quoteButton);
|
|
3030
|
+
|
|
2453
3031
|
this.#chat.append(
|
|
2454
3032
|
header,
|
|
2455
|
-
this.#
|
|
3033
|
+
this.#messagesWrap,
|
|
2456
3034
|
this.#skillsMenu.palette,
|
|
2457
3035
|
this.#skillsMenu.chips,
|
|
2458
3036
|
this.#skillHint,
|
|
@@ -2517,7 +3095,68 @@ export class AgUiChat extends HTMLElement {
|
|
|
2517
3095
|
label: this.#strings.resizePanel,
|
|
2518
3096
|
}),
|
|
2519
3097
|
);
|
|
2520
|
-
this.#
|
|
3098
|
+
this.#adoptStyles();
|
|
3099
|
+
this.#root.append(this.#announcer, this.#chat, this.#launcher);
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
/**
|
|
3103
|
+
* Attach the stylesheet without an inline `<style>` element.
|
|
3104
|
+
*
|
|
3105
|
+
* A host with a strict `style-src` and no `'unsafe-inline'` drops an injected
|
|
3106
|
+
* `<style>` silently: the component mounts, functions, and renders completely
|
|
3107
|
+
* unstyled, with nothing in the console to point at. `adoptedStyleSheets`
|
|
3108
|
+
* carries no inline-style origin, so it is unaffected by that policy.
|
|
3109
|
+
*
|
|
3110
|
+
* The sheet is constructed **per instance** rather than shared at module
|
|
3111
|
+
* scope. A shared sheet would additionally avoid re-parsing the stylesheet
|
|
3112
|
+
* once per mounted element, which is what `adoptedStyleSheets` is usually
|
|
3113
|
+
* reached for -- but a module-level singleton is exactly what this package
|
|
3114
|
+
* forbids, and the CSP defect is fixed either way. Per instance is no worse
|
|
3115
|
+
* than the `<style>` element it replaces, which also parsed once per mount.
|
|
3116
|
+
*
|
|
3117
|
+
* No fallback: constructible `CSSStyleSheet` is Chrome 73, Firefox 101 and
|
|
3118
|
+
* Safari 16.4, all below this package's declared Safari 17 runtime target. A
|
|
3119
|
+
* guard here would be code no supported browser can reach, and the only way
|
|
3120
|
+
* to keep it would be to exempt it from the coverage gate.
|
|
3121
|
+
*/
|
|
3122
|
+
/**
|
|
3123
|
+
* Say one short thing to a screen reader, without touching the transcript.
|
|
3124
|
+
*
|
|
3125
|
+
* The transcript cannot do this job. It is rewritten on every animation
|
|
3126
|
+
* frame while an answer streams, so as a live region it re-announced the
|
|
3127
|
+
* whole answer tens of times per turn -- not merely unhelpful but actively
|
|
3128
|
+
* hostile. The published fix for this exact bug (Microsoft's Bot Framework
|
|
3129
|
+
* WebChat #3236) is architectural rather than a matter of tuning attributes:
|
|
3130
|
+
* demote the visible transcript out of live-region duty and put one
|
|
3131
|
+
* synthesised status per event into a separate invisible region. MDN and
|
|
3132
|
+
* Scott O'Hara prescribe the same empty-region-then-inject shape.
|
|
3133
|
+
*
|
|
3134
|
+
* Roughly four calls land per turn -- responding, answered, a card is waiting,
|
|
3135
|
+
* stopped or failed -- so the user is told what happened and reads the answer
|
|
3136
|
+
* itself by navigating the log, at their own pace, rather than having it
|
|
3137
|
+
* shouted at them a token at a time.
|
|
3138
|
+
*
|
|
3139
|
+
* **The clear is load-bearing, twice.** A reader announces a live region when
|
|
3140
|
+
* its content *changes*, so setting the same string twice running -- two turns
|
|
3141
|
+
* in a row both starting -- is not a change and is silently not announced.
|
|
3142
|
+
* Emptying first makes the next set a change again. It also stops a stale
|
|
3143
|
+
* status being read out when a reader later lands on the region.
|
|
3144
|
+
*/
|
|
3145
|
+
#announce(message: string): void {
|
|
3146
|
+
if (this.#announceTimer !== null) {
|
|
3147
|
+
clearTimeout(this.#announceTimer);
|
|
3148
|
+
}
|
|
3149
|
+
this.#announcer.textContent = message;
|
|
3150
|
+
this.#announceTimer = setTimeout(() => {
|
|
3151
|
+
this.#announceTimer = null;
|
|
3152
|
+
this.#announcer.textContent = "";
|
|
3153
|
+
}, ANNOUNCE_CLEAR_MS);
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
#adoptStyles(): void {
|
|
3157
|
+
const sheet = new CSSStyleSheet();
|
|
3158
|
+
sheet.replaceSync(STYLES);
|
|
3159
|
+
this.#root.adoptedStyleSheets = [sheet];
|
|
2521
3160
|
}
|
|
2522
3161
|
|
|
2523
3162
|
/**
|
|
@@ -2860,6 +3499,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2860
3499
|
onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
|
|
2861
3500
|
onStateChanged: (state) => this.#onSharedStateChanged(state),
|
|
2862
3501
|
connectionLostMessage: this.#strings.connectionLost,
|
|
3502
|
+
maxToolRounds: this.#maxToolRounds(),
|
|
2863
3503
|
});
|
|
2864
3504
|
}
|
|
2865
3505
|
return this.#client;
|
|
@@ -2877,15 +3517,88 @@ export class AgUiChat extends HTMLElement {
|
|
|
2877
3517
|
);
|
|
2878
3518
|
}
|
|
2879
3519
|
|
|
2880
|
-
/**
|
|
2881
|
-
|
|
3520
|
+
/**
|
|
3521
|
+
* Give a finished assistant bubble its action row, and hand it Retry.
|
|
3522
|
+
*
|
|
3523
|
+
* Every finished bubble gets copy and feedback -- both are safe on a message
|
|
3524
|
+
* of any age. Retry moves to the newest, because it is the only one where
|
|
3525
|
+
* re-running answers the same question rather than rewriting history.
|
|
3526
|
+
*
|
|
3527
|
+
* `data-message-actions` subtracts from that. The row is built only when
|
|
3528
|
+
* something survives to go in it: an empty row still takes its margin, still
|
|
3529
|
+
* answers to the `message-actions` part, and still reads to a screen reader
|
|
3530
|
+
* as a group of actions with none in it.
|
|
3531
|
+
*/
|
|
3532
|
+
#attachActions(bubble: HTMLDivElement, options: { rateable?: boolean } = {}): void {
|
|
3533
|
+
const enabled = this.#messageActions();
|
|
3534
|
+
const copyable = enabled.has(MESSAGE_ACTIONS.COPY);
|
|
3535
|
+
// A failed run is copyable -- error text is what people paste into a bug
|
|
3536
|
+
// report -- but not rateable: a rating is a statement about an *answer*,
|
|
3537
|
+
// and mixing "the connection dropped" into that signal makes the host's
|
|
3538
|
+
// feedback data say less than it did before.
|
|
3539
|
+
const rateable = options.rateable !== false && enabled.has(MESSAGE_ACTIONS.FEEDBACK);
|
|
3540
|
+
if (copyable || rateable) {
|
|
3541
|
+
attachMessageActions(bubble, {
|
|
3542
|
+
strings: this.#strings,
|
|
3543
|
+
// Read at click time, not captured: a bubble rendered from markdown
|
|
3544
|
+
// holds its text in the DOM, and that is what the user sees and means
|
|
3545
|
+
// to copy.
|
|
3546
|
+
...(copyable ? { text: () => bubble.textContent as string } : {}),
|
|
3547
|
+
...(rateable
|
|
3548
|
+
? {
|
|
3549
|
+
onFeedback: (rating: "up" | "down") => {
|
|
3550
|
+
this.dispatchEvent(
|
|
3551
|
+
new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
|
|
3552
|
+
detail: { content: bubble.textContent as string, rating },
|
|
3553
|
+
bubbles: true,
|
|
3554
|
+
composed: true,
|
|
3555
|
+
}),
|
|
3556
|
+
);
|
|
3557
|
+
},
|
|
3558
|
+
}
|
|
3559
|
+
: {}),
|
|
3560
|
+
});
|
|
3561
|
+
}
|
|
3562
|
+
if (enabled.has(MESSAGE_ACTIONS.RETRY)) {
|
|
3563
|
+
this.#moveRetryTo(messageActionBar(bubble, this.#strings));
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
/** Move the Retry button onto `bar`, taking it off whoever held it. */
|
|
3568
|
+
#moveRetryTo(bar: HTMLElement): void {
|
|
3569
|
+
this.#retryOwner?.querySelector(".message-action--retry")?.remove();
|
|
3570
|
+
const retry = messageActionButton("retry", this.#strings.retryMessage, "\u21BB");
|
|
3571
|
+
retry.addEventListener("click", () => {
|
|
3572
|
+
void this.retryLastTurn();
|
|
3573
|
+
});
|
|
3574
|
+
// First in the row: it is the action a reader reaches for when the answer
|
|
3575
|
+
// was wrong, which is when they are least inclined to hunt for a control.
|
|
3576
|
+
bar.prepend(retry);
|
|
3577
|
+
this.#retryOwner = bar;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/**
|
|
3581
|
+
* Which rule gates `call`, or `null` when it runs straight through.
|
|
3582
|
+
*
|
|
3583
|
+
* The rule, rather than a bare boolean, because it decides whether the user
|
|
3584
|
+
* may *waive* the prompt for the rest of the session. Only the default
|
|
3585
|
+
* `x-destructive` gate is waivable: `confirmPredicate` is documented as
|
|
3586
|
+
* authoritative, so letting one click retire it would silently defeat a host
|
|
3587
|
+
* policy — and the session allowlist is consulted on the same path it can
|
|
3588
|
+
* be added from, so the button is never offered where honouring it would be
|
|
3589
|
+
* refused.
|
|
3590
|
+
*/
|
|
3591
|
+
async #confirmationRule(call: AgUiToolCall, tool: ClientTool): Promise<ConfirmationRule | null> {
|
|
2882
3592
|
if (this.autoConfirm) {
|
|
2883
|
-
return
|
|
3593
|
+
return null;
|
|
2884
3594
|
}
|
|
2885
3595
|
if (this.confirmPredicate !== null) {
|
|
2886
|
-
return (await this.confirmPredicate(call.name, call.args)) === true;
|
|
3596
|
+
return (await this.confirmPredicate(call.name, call.args)) === true ? "predicate" : null;
|
|
3597
|
+
}
|
|
3598
|
+
if (this.#sessionApproved.has(call.name)) {
|
|
3599
|
+
return null;
|
|
2887
3600
|
}
|
|
2888
|
-
return isDestructive(tool.parameters);
|
|
3601
|
+
return isDestructive(tool.parameters) ? "destructive" : null;
|
|
2889
3602
|
}
|
|
2890
3603
|
|
|
2891
3604
|
async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
|
|
@@ -2944,7 +3657,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
2944
3657
|
this.#showPending();
|
|
2945
3658
|
return { content: `Error: ${message}`, error: message };
|
|
2946
3659
|
}
|
|
2947
|
-
|
|
3660
|
+
const rule = await this.#confirmationRule(call, tool);
|
|
3661
|
+
if (rule !== null) {
|
|
2948
3662
|
const request: ConfirmationRequest = { toolName: call.name, args: call.args };
|
|
2949
3663
|
const confirmText = tool.parameters[X_CONFIRM_KEY];
|
|
2950
3664
|
if (typeof confirmText === "string") {
|
|
@@ -2960,9 +3674,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
2960
3674
|
const decision = requestConfirmation(this.#ensureGroup(), request, {
|
|
2961
3675
|
signal: this.#confirmAbort.signal,
|
|
2962
3676
|
strings: this.#strings,
|
|
3677
|
+
// Offered only where it can be honoured -- see `#confirmationRule`.
|
|
3678
|
+
...(rule === "destructive"
|
|
3679
|
+
? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
|
|
3680
|
+
: {}),
|
|
2963
3681
|
});
|
|
2964
3682
|
this.#updateEmptyState();
|
|
2965
|
-
this.#
|
|
3683
|
+
this.#scroller.follow();
|
|
2966
3684
|
const accepted = await decision;
|
|
2967
3685
|
this.#confirmAbort = null;
|
|
2968
3686
|
card.recordDecision(accepted ? "approved" : "declined");
|
|
@@ -3044,6 +3762,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
3044
3762
|
): Promise<Record<string, InterruptResponse>> {
|
|
3045
3763
|
// One controller covers the whole batch: a single Stop denies all of them.
|
|
3046
3764
|
this.#confirmAbort = new AbortController();
|
|
3765
|
+
// The run has stopped and is waiting on a person. Nothing else on screen
|
|
3766
|
+
// says so to a screen reader: the cards appear inside the transcript, which
|
|
3767
|
+
// is deliberately not a live region, so without this the run simply goes
|
|
3768
|
+
// quiet and the user has no reason to go looking.
|
|
3769
|
+
this.#announce(
|
|
3770
|
+
this.#strings.announceAwaitingDecision.replace("{count}", String(interrupts.length)),
|
|
3771
|
+
);
|
|
3047
3772
|
this.#hidePending();
|
|
3048
3773
|
const signal = this.#confirmAbort.signal;
|
|
3049
3774
|
const answered = await Promise.all(
|
|
@@ -3061,6 +3786,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
3061
3786
|
if (toolName !== null && toolName !== undefined) {
|
|
3062
3787
|
request.toolName = toolName;
|
|
3063
3788
|
}
|
|
3789
|
+
// Offered only where it can be honoured: the host has said its agent
|
|
3790
|
+
// accepts `editedArgs`, and this interrupt named a call whose arguments
|
|
3791
|
+
// we still hold.
|
|
3792
|
+
let editedArgs: Record<string, unknown> | undefined;
|
|
3793
|
+
const editable = this.approveWithEdits && card !== undefined;
|
|
3794
|
+
if (editable) {
|
|
3795
|
+
request.args = card.args;
|
|
3796
|
+
}
|
|
3064
3797
|
card?.mark(TOOL_CALL_STATUS.DEFERRED);
|
|
3065
3798
|
// A host-supplied renderer takes full control of the approval UI. The
|
|
3066
3799
|
// built-in card renders into the gated call's own card, falling back to
|
|
@@ -3071,6 +3804,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
3071
3804
|
: await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
|
|
3072
3805
|
signal,
|
|
3073
3806
|
strings: this.#strings,
|
|
3807
|
+
...(editable
|
|
3808
|
+
? {
|
|
3809
|
+
onEdit: (args: Record<string, unknown>) => {
|
|
3810
|
+
editedArgs = args;
|
|
3811
|
+
},
|
|
3812
|
+
}
|
|
3813
|
+
: {}),
|
|
3074
3814
|
});
|
|
3075
3815
|
// Same annotation as the client-side confirmation gate. Without it the
|
|
3076
3816
|
// two gates read differently for the same act: a locally-confirmed call
|
|
@@ -3085,16 +3825,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
3085
3825
|
// now rather than leaving it hanging until the onSettled sweep.
|
|
3086
3826
|
card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
|
|
3087
3827
|
}
|
|
3088
|
-
return { id: interrupt.id, approved };
|
|
3828
|
+
return { id: interrupt.id, approved, editedArgs };
|
|
3089
3829
|
}),
|
|
3090
3830
|
);
|
|
3091
3831
|
this.#updateEmptyState();
|
|
3092
|
-
this.#
|
|
3832
|
+
this.#scroller.follow();
|
|
3093
3833
|
this.#confirmAbort = null;
|
|
3094
3834
|
const responses: Record<string, InterruptResponse> = {};
|
|
3095
|
-
for (const { id, approved } of answered) {
|
|
3835
|
+
for (const { id, approved, editedArgs } of answered) {
|
|
3836
|
+
// `editedArgs` rides only when the user actually changed something, so a
|
|
3837
|
+
// server can tell "approved as proposed" from "approved, but like this".
|
|
3096
3838
|
responses[id] = approved
|
|
3097
|
-
? {
|
|
3839
|
+
? {
|
|
3840
|
+
status: "resolved",
|
|
3841
|
+
payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
|
|
3842
|
+
}
|
|
3098
3843
|
: { status: "cancelled" };
|
|
3099
3844
|
}
|
|
3100
3845
|
return responses;
|
|
@@ -3103,6 +3848,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
3103
3848
|
#handlers(): AgUiClientHandlers {
|
|
3104
3849
|
return {
|
|
3105
3850
|
onRunStart: () => {
|
|
3851
|
+
// Per *round*, so guard on the turn: a run that calls three tools fires
|
|
3852
|
+
// this three times and the user needs telling once.
|
|
3853
|
+
if (!this.#running) {
|
|
3854
|
+
this.#announcedOutcome = false;
|
|
3855
|
+
this.#announce(this.#strings.announceResponding);
|
|
3856
|
+
}
|
|
3106
3857
|
this.#setRunning(true);
|
|
3107
3858
|
// Open the answer group on the turn's first run so the pending
|
|
3108
3859
|
// indicator (and everything after) lands inside the well. Idempotent:
|
|
@@ -3143,6 +3894,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3143
3894
|
this.#revealWords(bubble);
|
|
3144
3895
|
}
|
|
3145
3896
|
attachCopyButtons(bubble, this.#strings);
|
|
3897
|
+
this.#attachActions(bubble);
|
|
3146
3898
|
this.#endStream();
|
|
3147
3899
|
this.#noteUnread();
|
|
3148
3900
|
},
|
|
@@ -3162,25 +3914,51 @@ export class AgUiChat extends HTMLElement {
|
|
|
3162
3914
|
this.#cardFor(call);
|
|
3163
3915
|
},
|
|
3164
3916
|
onActivity: (activityType, content, messageId) => {
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
}
|
|
3171
|
-
if (activityType !== COMPACTION_ACTIVITY_TYPE) {
|
|
3917
|
+
this.#drawActivity(messageId, activityType, content);
|
|
3918
|
+
},
|
|
3919
|
+
onCustomEvent: (name, value) => {
|
|
3920
|
+
if (name === INVALIDATE_CUSTOM_NAME) {
|
|
3921
|
+
this.#dispatchInvalidation(value);
|
|
3172
3922
|
return;
|
|
3173
3923
|
}
|
|
3174
|
-
|
|
3175
|
-
|
|
3924
|
+
if (name === SUBAGENT_CUSTOM_NAME) {
|
|
3925
|
+
this.#reportSubAgent(value);
|
|
3176
3926
|
return;
|
|
3177
3927
|
}
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3928
|
+
// Straight out to the host page, uninterpreted. This is the imperative
|
|
3929
|
+
// carrier: whatever it means, it means it to the page, not to the
|
|
3930
|
+
// transcript -- so it is dispatched and deliberately not rendered,
|
|
3931
|
+
// persisted or replayed. A host that does not know the name simply has
|
|
3932
|
+
// no listener, which is the graceful outcome the open field is for.
|
|
3933
|
+
this.dispatchEvent(
|
|
3934
|
+
new CustomEvent<CustomAgentDetail>(CUSTOM_AGENT_EVENT, {
|
|
3935
|
+
detail: { name, value },
|
|
3936
|
+
bubbles: true,
|
|
3937
|
+
composed: true,
|
|
3938
|
+
}),
|
|
3182
3939
|
);
|
|
3183
3940
|
},
|
|
3941
|
+
onMessagesSnapshot: () => {
|
|
3942
|
+
// Honoured for persistence and announced, not re-rendered.
|
|
3943
|
+
//
|
|
3944
|
+
// The store follows the server, because the server is authoritative
|
|
3945
|
+
// about what the conversation *is* -- and it would follow it anyway:
|
|
3946
|
+
// `@ag-ui/client` replaces `agent.messages` before any subscriber runs,
|
|
3947
|
+
// and the run loop persists `agent.messages`. What was wrong was that
|
|
3948
|
+
// it happened in silence, so the screen and the store disagreed and
|
|
3949
|
+
// nobody found out until a reload served a transcript they had never
|
|
3950
|
+
// seen. That is not reportable as a bug; it is reportable as "the chat
|
|
3951
|
+
// lost my messages".
|
|
3952
|
+
//
|
|
3953
|
+
// Re-rendering from the snapshot was the other candidate and is
|
|
3954
|
+
// declined: a snapshot can land mid-run, and rebuilding the transcript
|
|
3955
|
+
// then would destroy the in-flight run's own UI state -- the streaming
|
|
3956
|
+
// bubble, the open answer group, and every tool card keyed by call id,
|
|
3957
|
+
// some of which are still waiting on results. Telling the reader costs
|
|
3958
|
+
// none of that, and this is the same answer the same question already
|
|
3959
|
+
// got for compaction, one handler up.
|
|
3960
|
+
this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
|
|
3961
|
+
},
|
|
3184
3962
|
onToolResult: (toolCallId, content) => {
|
|
3185
3963
|
const card = this.#toolCards.get(toolCallId);
|
|
3186
3964
|
if (card === undefined) {
|
|
@@ -3204,9 +3982,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3204
3982
|
this.#showPending();
|
|
3205
3983
|
},
|
|
3206
3984
|
onActivityChanged: (messageId, activityType, content) => {
|
|
3207
|
-
|
|
3208
|
-
this.#drawActivityChart(messageId, content);
|
|
3209
|
-
}
|
|
3985
|
+
this.#drawActivity(messageId, activityType, content);
|
|
3210
3986
|
},
|
|
3211
3987
|
onRunEnd: () => {
|
|
3212
3988
|
// Per-round end; the button stays on Stop until the whole interaction
|
|
@@ -3215,19 +3991,38 @@ export class AgUiChat extends HTMLElement {
|
|
|
3215
3991
|
this.#endStream();
|
|
3216
3992
|
},
|
|
3217
3993
|
onError: (message) => {
|
|
3994
|
+
this.#announcedOutcome = true;
|
|
3995
|
+
this.#announce(this.#strings.announceFailed);
|
|
3218
3996
|
this.#hidePending();
|
|
3219
|
-
this
|
|
3997
|
+
const bubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
|
|
3998
|
+
bubble.classList.add("message--failed");
|
|
3999
|
+
// A failure is the one message whose action row is only worth having
|
|
4000
|
+
// for Retry: there is nothing here worth copying and nothing to rate.
|
|
4001
|
+
// A dropped connection with no way back was the whole of the gap --
|
|
4002
|
+
// uploads had a retry and runs did not.
|
|
4003
|
+
//
|
|
4004
|
+
// Not a `run-notice`: that element's contract is that it "never
|
|
4005
|
+
// settles, takes no action, and carries no controls", and is explicitly
|
|
4006
|
+
// "distinct from an error, which is a failure". This is a failure, so
|
|
4007
|
+
// it stays an error and gains the control instead.
|
|
4008
|
+
this.#attachActions(bubble, { rateable: false });
|
|
4009
|
+
this.#revealWords(bubble);
|
|
3220
4010
|
this.#endStream();
|
|
3221
4011
|
},
|
|
3222
4012
|
onCancelled: () => {
|
|
3223
4013
|
// Deliberate stop, not a failure: keep whatever partial text already
|
|
3224
4014
|
// streamed and add a muted note instead of an error bubble.
|
|
4015
|
+
this.#announcedOutcome = true;
|
|
4016
|
+
this.#announce(this.#strings.announceStopped);
|
|
3225
4017
|
this.#hidePending();
|
|
3226
4018
|
this.#appendStoppedNote();
|
|
3227
4019
|
this.#endStream();
|
|
3228
4020
|
},
|
|
3229
4021
|
onSettled: () => {
|
|
3230
4022
|
// Terminal guarantee: whatever path ended the run, return to rest.
|
|
4023
|
+
if (!this.#announcedOutcome) {
|
|
4024
|
+
this.#announce(this.#strings.announceAnswerReady);
|
|
4025
|
+
}
|
|
3231
4026
|
this.#hidePending();
|
|
3232
4027
|
this.#setRunning(false);
|
|
3233
4028
|
this.#endStream();
|
|
@@ -3268,15 +4063,100 @@ export class AgUiChat extends HTMLElement {
|
|
|
3268
4063
|
side: this.#serverSettled.has(id) ? "server" : "client",
|
|
3269
4064
|
}));
|
|
3270
4065
|
this.#runTools = [];
|
|
4066
|
+
const invalidated = [...this.#runInvalidated];
|
|
4067
|
+
this.#runInvalidated = new Set<string>();
|
|
3271
4068
|
this.dispatchEvent(
|
|
3272
4069
|
new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
|
|
3273
|
-
detail: { tools },
|
|
4070
|
+
detail: { tools, invalidated },
|
|
4071
|
+
bubbles: true,
|
|
4072
|
+
composed: true,
|
|
4073
|
+
}),
|
|
4074
|
+
);
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
/**
|
|
4078
|
+
* Route one invalidation to the host, and remember it for the run summary.
|
|
4079
|
+
*
|
|
4080
|
+
* Dispatched immediately rather than only at the end, because that is what
|
|
4081
|
+
* makes a long multi-step run feel live -- the list refreshes as the third of
|
|
4082
|
+
* eight writes lands. The accumulated set rides
|
|
4083
|
+
* {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
|
|
4084
|
+
* upgrades by reading one extra field instead of adding a listener.
|
|
4085
|
+
*
|
|
4086
|
+
* Nothing is rendered, persisted or replayed. An invalidation is an
|
|
4087
|
+
* imperative: it has no place in the transcript and no meaning once acted on,
|
|
4088
|
+
* and replaying one on every thread load would be a refetch storm. That is the
|
|
4089
|
+
* whole reason the server sends it as `CUSTOM` rather than as an activity.
|
|
4090
|
+
*/
|
|
4091
|
+
#dispatchInvalidation(value: unknown): void {
|
|
4092
|
+
const payload = (value ?? {}) as { keys?: unknown; reason?: unknown };
|
|
4093
|
+
// Defensive about the payload, not about the name: `value` is typed
|
|
4094
|
+
// `unknown` by the protocol, so a server can put anything there, and a
|
|
4095
|
+
// malformed announcement must not take the run down with it.
|
|
4096
|
+
const keys = Array.isArray(payload.keys)
|
|
4097
|
+
? payload.keys.filter((key): key is string => typeof key === "string")
|
|
4098
|
+
: [];
|
|
4099
|
+
if (keys.length === 0) {
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
for (const key of keys) {
|
|
4103
|
+
this.#runInvalidated.add(key);
|
|
4104
|
+
}
|
|
4105
|
+
this.dispatchEvent(
|
|
4106
|
+
new CustomEvent<InvalidateDetail>(INVALIDATE_EVENT, {
|
|
4107
|
+
detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
|
|
3274
4108
|
bubbles: true,
|
|
3275
4109
|
composed: true,
|
|
3276
4110
|
}),
|
|
3277
4111
|
);
|
|
3278
4112
|
}
|
|
3279
4113
|
|
|
4114
|
+
/**
|
|
4115
|
+
* Draw one step of a delegated sub-agent's progress, on the card that
|
|
4116
|
+
* delegated.
|
|
4117
|
+
*
|
|
4118
|
+
* `delegationId` is the parent's own `delegate_task` tool-call id, so the
|
|
4119
|
+
* attachment point is a card this element already drew on `TOOL_CALL_START`.
|
|
4120
|
+
* That is the whole design: a run that hands work to a sub-agent used to read
|
|
4121
|
+
* as a stall -- the card sat at "running…" for the child's entire duration --
|
|
4122
|
+
* and the fix is to narrate *into* the thing that was already standing there,
|
|
4123
|
+
* rather than to float a second element with the same identity.
|
|
4124
|
+
*
|
|
4125
|
+
* A progress event for a call this client never drew is dropped. It has no
|
|
4126
|
+
* card to attach to, and inventing a floating one is precisely the alternative
|
|
4127
|
+
* that was rejected: parent and child interleave in the transcript with
|
|
4128
|
+
* nothing marking whose is whose, and the persisted transcript -- which never
|
|
4129
|
+
* held the progress at all -- would not match what was on screen.
|
|
4130
|
+
*
|
|
4131
|
+
* Nothing here writes to the conversation store. `CUSTOM` never enters
|
|
4132
|
+
* `agent.messages`, so a reload mid-run leaves the tool card and loses the
|
|
4133
|
+
* nested detail, which is the intended behaviour rather than a gap.
|
|
4134
|
+
*/
|
|
4135
|
+
#reportSubAgent(value: unknown): void {
|
|
4136
|
+
const update = subAgentUpdate(value);
|
|
4137
|
+
if (update === null) {
|
|
4138
|
+
return;
|
|
4139
|
+
}
|
|
4140
|
+
const card = this.#toolCards.get(update.delegationId);
|
|
4141
|
+
if (card === undefined) {
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
let panel = this.#subagentPanels.get(update.delegationId);
|
|
4145
|
+
if (panel === undefined) {
|
|
4146
|
+
// Created on whichever phase arrives first rather than only on `started`.
|
|
4147
|
+
// The contract says exactly one opens a delegation, and a client that
|
|
4148
|
+
// insisted on it would answer a server that dropped one frame by showing
|
|
4149
|
+
// nothing at all for the rest of the run.
|
|
4150
|
+
panel = new SubAgentPanel(this.#strings);
|
|
4151
|
+
this.#subagentPanels.set(update.delegationId, panel);
|
|
4152
|
+
card.subagentSlot.appendChild(panel.element);
|
|
4153
|
+
}
|
|
4154
|
+
panel.report(update);
|
|
4155
|
+
// The card grew, and the transcript is usually pinned to the foot while a
|
|
4156
|
+
// run is in flight.
|
|
4157
|
+
this.#scroller.follow();
|
|
4158
|
+
}
|
|
4159
|
+
|
|
3280
4160
|
/** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
|
|
3281
4161
|
#appendStoppedNote(): void {
|
|
3282
4162
|
const note = document.createElement("div");
|
|
@@ -3286,7 +4166,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3286
4166
|
note.textContent = this.#strings.stopped;
|
|
3287
4167
|
this.#ensureGroup().appendChild(note);
|
|
3288
4168
|
this.#updateEmptyState();
|
|
3289
|
-
this.#
|
|
4169
|
+
this.#scroller.follow();
|
|
3290
4170
|
}
|
|
3291
4171
|
|
|
3292
4172
|
/**
|
|
@@ -3311,7 +4191,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3311
4191
|
this.#pending = pending;
|
|
3312
4192
|
this.#ensureGroup().appendChild(pending);
|
|
3313
4193
|
this.#updateEmptyState();
|
|
3314
|
-
this.#
|
|
4194
|
+
this.#scroller.follow();
|
|
3315
4195
|
}
|
|
3316
4196
|
|
|
3317
4197
|
/** Remove the pending indicator if shown. */
|
|
@@ -3331,7 +4211,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3331
4211
|
const group = this.#ensureGroup();
|
|
3332
4212
|
group.insertBefore(this.#thoughts.element, group.firstChild);
|
|
3333
4213
|
this.#updateEmptyState();
|
|
3334
|
-
this.#
|
|
4214
|
+
this.#scroller.follow();
|
|
3335
4215
|
}
|
|
3336
4216
|
return this.#thoughts;
|
|
3337
4217
|
}
|
|
@@ -3388,7 +4268,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3388
4268
|
this.#streamBuffer = buffer;
|
|
3389
4269
|
const bubble = this.#openStream();
|
|
3390
4270
|
bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
|
|
3391
|
-
this.#
|
|
4271
|
+
this.#scroller.follow();
|
|
3392
4272
|
return bubble;
|
|
3393
4273
|
}
|
|
3394
4274
|
|
|
@@ -3439,7 +4319,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3439
4319
|
#appendNotice(icon: string, text: string, kind: string): void {
|
|
3440
4320
|
this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind));
|
|
3441
4321
|
this.#updateEmptyState();
|
|
3442
|
-
this.#
|
|
4322
|
+
this.#scroller.follow();
|
|
3443
4323
|
}
|
|
3444
4324
|
|
|
3445
4325
|
/**
|
|
@@ -3461,9 +4341,20 @@ export class AgUiChat extends HTMLElement {
|
|
|
3461
4341
|
* arrives is not something to switch on for everybody.
|
|
3462
4342
|
*/
|
|
3463
4343
|
enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
|
|
3464
|
-
const first =
|
|
4344
|
+
const first =
|
|
4345
|
+
!this.#activityRenderers.has(CHART_ACTIVITY_TYPE) && !this.#toolRegistry.has(CHART_TOOL_NAME);
|
|
3465
4346
|
if (routes.includes("activity")) {
|
|
3466
|
-
|
|
4347
|
+
// The chart is a registration like any host's, not a privileged branch.
|
|
4348
|
+
// If the built-in cannot be expressed through the seam, the seam is not
|
|
4349
|
+
// one -- so this is the test as much as the feature.
|
|
4350
|
+
this.registerActivityRenderer({
|
|
4351
|
+
type: CHART_ACTIVITY_TYPE,
|
|
4352
|
+
render: (content) => {
|
|
4353
|
+
const spec = chartSpecFrom(content);
|
|
4354
|
+
return spec === null ? null : renderChart(spec);
|
|
4355
|
+
},
|
|
4356
|
+
removedNotice: this.#strings.chartUndrawable,
|
|
4357
|
+
});
|
|
3467
4358
|
}
|
|
3468
4359
|
if (routes.includes("tool")) {
|
|
3469
4360
|
this.registerTool(createChartTool());
|
|
@@ -3511,37 +4402,140 @@ export class AgUiChat extends HTMLElement {
|
|
|
3511
4402
|
this.#afterTranscriptGrew();
|
|
3512
4403
|
}
|
|
3513
4404
|
|
|
3514
|
-
/**
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
4405
|
+
/**
|
|
4406
|
+
* Teach this element to draw one kind of AG-UI activity.
|
|
4407
|
+
*
|
|
4408
|
+
* `activity_type` is one of exactly two fields the protocol leaves an open
|
|
4409
|
+
* string, and it is the **content** one: an activity is materialised into a
|
|
4410
|
+
* message, persisted with the thread, and replayed on every restore. Its
|
|
4411
|
+
* sibling `CUSTOM` carries an imperative and is dispatched to the page
|
|
4412
|
+
* instead ({@link CUSTOM_AGENT_EVENT}).
|
|
4413
|
+
*
|
|
4414
|
+
* That asymmetry decides which carrier a server should use. Content has a
|
|
4415
|
+
* place in the conversation and should come back; an imperative has no place
|
|
4416
|
+
* and no meaning once acted on.
|
|
4417
|
+
*
|
|
4418
|
+
* ```js
|
|
4419
|
+
* chat.registerActivityRenderer({
|
|
4420
|
+
* type: "build_status",
|
|
4421
|
+
* render: (content) => {
|
|
4422
|
+
* const el = document.createElement("div");
|
|
4423
|
+
* el.textContent = `Build ${content.status}`;
|
|
4424
|
+
* return el;
|
|
4425
|
+
* },
|
|
4426
|
+
* });
|
|
4427
|
+
* ```
|
|
4428
|
+
*
|
|
4429
|
+
* Registering a type twice replaces the earlier renderer, so a host can
|
|
4430
|
+
* override a built-in -- `chart` and `compaction` are registrations like any
|
|
4431
|
+
* other, not privileged branches.
|
|
4432
|
+
*
|
|
4433
|
+
* ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
|
|
4434
|
+
* for what that requires of it.
|
|
4435
|
+
*/
|
|
4436
|
+
registerActivityRenderer(registration: ActivityRegistration): void {
|
|
4437
|
+
this.#activityRenderers.set(registration.type, registration);
|
|
4438
|
+
this.#unhandledActivityTypes.delete(registration.type);
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
/**
|
|
4442
|
+
* Activity types that arrived with nobody registered to draw them.
|
|
4443
|
+
*
|
|
4444
|
+
* Deliberately the only trace an unhandled activity leaves. Ignoring an
|
|
4445
|
+
* unknown name is the protocol's own answer and the whole point of an open
|
|
4446
|
+
* field, so warning would fire on every forward-compatible server -- but
|
|
4447
|
+
* "nothing happened and nothing was said" is impossible to debug, so the set
|
|
4448
|
+
* is readable. Accumulates for the element's lifetime, across threads.
|
|
4449
|
+
*/
|
|
4450
|
+
get unhandledActivityTypes(): readonly string[] {
|
|
4451
|
+
return [...this.#unhandledActivityTypes];
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
/**
|
|
4455
|
+
* Draw, replace or remove one activity, whatever kind it is.
|
|
4456
|
+
*
|
|
4457
|
+
* The single path for all three routes an activity arrives by -- pushed
|
|
4458
|
+
* (`onActivity`), patched (`onActivityChanged`) and replayed from history --
|
|
4459
|
+
* which is why the renderer contract has to be pure: the same content is
|
|
4460
|
+
* drawn again on every thread load.
|
|
4461
|
+
*
|
|
4462
|
+
* An unregistered type draws nothing and says nothing. That is the protocol's
|
|
4463
|
+
* own answer -- a client that does not know a name ignores the event -- and a
|
|
4464
|
+
* warning here would fire on every well-behaved forward-compatible server,
|
|
4465
|
+
* while a placeholder would put the protocol's growth in the user's face.
|
|
4466
|
+
* {@link unhandledActivityTypes} is the way to find out what arrived.
|
|
4467
|
+
*/
|
|
4468
|
+
#drawActivity(messageId: string, activityType: string, content: unknown): void {
|
|
4469
|
+
const registration = this.#activityRenderers.get(activityType);
|
|
4470
|
+
if (registration === undefined) {
|
|
4471
|
+
this.#unhandledActivityTypes.add(activityType);
|
|
4472
|
+
return;
|
|
4473
|
+
}
|
|
4474
|
+
let node: Node | null;
|
|
4475
|
+
try {
|
|
4476
|
+
node = registration.render(content);
|
|
4477
|
+
} catch (error) {
|
|
4478
|
+
// `render` is consumer code and this runs inside the history replay,
|
|
4479
|
+
// where a throw abandons the loop and takes every later turn of the
|
|
4480
|
+
// transcript with it -- silently, and again on every reload. One activity
|
|
4481
|
+
// that fails to draw is worth losing; the rest of the conversation is not.
|
|
4482
|
+
console.warn(`ag-ui-chat: render failed for activity ${activityType}`, error);
|
|
4483
|
+
node = null;
|
|
4484
|
+
}
|
|
4485
|
+
if (node === null) {
|
|
4486
|
+
this.#removeActivity(messageId, activityType, registration.removedNotice, content);
|
|
3527
4487
|
return;
|
|
3528
4488
|
}
|
|
3529
4489
|
const existing = this.#activityBlocks.get(messageId);
|
|
3530
4490
|
if (existing === undefined) {
|
|
3531
|
-
this.#ensureGroup().appendChild(
|
|
4491
|
+
this.#ensureGroup().appendChild(node as HTMLElement);
|
|
3532
4492
|
} else {
|
|
3533
|
-
// Replaced rather than appended: a server redrawing
|
|
3534
|
-
//
|
|
3535
|
-
//
|
|
3536
|
-
existing.replaceWith(
|
|
4493
|
+
// Replaced rather than appended: a server redrawing under the same id
|
|
4494
|
+
// means *this one changed*, and a second copy below the first would read
|
|
4495
|
+
// as two measurements instead of one that moved.
|
|
4496
|
+
existing.replaceWith(node);
|
|
3537
4497
|
}
|
|
3538
|
-
this.#activityBlocks.set(messageId,
|
|
4498
|
+
this.#activityBlocks.set(messageId, node as HTMLElement);
|
|
3539
4499
|
this.#afterTranscriptGrew();
|
|
3540
4500
|
}
|
|
3541
4501
|
|
|
4502
|
+
/**
|
|
4503
|
+
* Take away an activity whose content stopped being drawable.
|
|
4504
|
+
*
|
|
4505
|
+
* Leaving the old one up is the worst available answer: it shows values that
|
|
4506
|
+
* have been retracted, reading as current, and a reload drops it anyway
|
|
4507
|
+
* because the *stored* content is the version that could not be drawn. Live
|
|
4508
|
+
* and reload should agree, and both should say "gone".
|
|
4509
|
+
*
|
|
4510
|
+
* Removing is right; doing it in silence was not. A chart that had been drawn
|
|
4511
|
+
* simply disappeared, with no `console` call anywhere on the path -- which
|
|
4512
|
+
* nobody reports as a bug, they report as "the charts are flaky".
|
|
4513
|
+
*/
|
|
4514
|
+
#removeActivity(
|
|
4515
|
+
messageId: string,
|
|
4516
|
+
activityType: string,
|
|
4517
|
+
notice: string | undefined,
|
|
4518
|
+
content: unknown,
|
|
4519
|
+
): void {
|
|
4520
|
+
const had = this.#activityBlocks.has(messageId);
|
|
4521
|
+
this.#activityBlocks.get(messageId)?.remove();
|
|
4522
|
+
this.#activityBlocks.delete(messageId);
|
|
4523
|
+
console.warn(
|
|
4524
|
+
`ag-ui-chat: activity ${messageId} (${activityType}) was not drawable and has been ` +
|
|
4525
|
+
"removed. A chart's points must each be a finite JSON number; a numeric column " +
|
|
4526
|
+
"serialised as a string (a Decimal, typically) is rejected rather than coerced.",
|
|
4527
|
+
content,
|
|
4528
|
+
);
|
|
4529
|
+
// Only when something was on screen: content that never drew has no
|
|
4530
|
+
// disappearance to explain, and a notice for every rejected push is noise.
|
|
4531
|
+
if (had && notice !== undefined) {
|
|
4532
|
+
this.#appendNotice("\u{1F4C9}", notice, "chart-undrawable");
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4535
|
+
|
|
3542
4536
|
#afterTranscriptGrew(): void {
|
|
3543
4537
|
this.#updateEmptyState();
|
|
3544
|
-
this.#
|
|
4538
|
+
this.#scroller.follow();
|
|
3545
4539
|
}
|
|
3546
4540
|
|
|
3547
4541
|
#cardFor(call: AgUiToolCall): ToolCallCard {
|
|
@@ -3559,11 +4553,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
3559
4553
|
: (this.toolSummaries[call.name] ??
|
|
3560
4554
|
this.#toolCatalog[call.name]?.summary ??
|
|
3561
4555
|
prettifyToolName(call.name));
|
|
3562
|
-
const card = new ToolCallCard(call.name, call.args, summary, this.#strings
|
|
4556
|
+
const card = new ToolCallCard(call.name, call.args, summary, this.#strings, {
|
|
4557
|
+
// A thunk over the live property, not the property itself: the card keeps
|
|
4558
|
+
// this for the life of the call, and the result region is filled when the
|
|
4559
|
+
// tool settles -- which can be long after a host set the hook.
|
|
4560
|
+
formatPayload: (payload) => this.formatToolPayload?.(payload) ?? null,
|
|
4561
|
+
});
|
|
3563
4562
|
this.#toolCards.set(call.id, card);
|
|
3564
4563
|
this.#ensureGroup().appendChild(card.element);
|
|
3565
4564
|
this.#updateEmptyState();
|
|
3566
|
-
this.#
|
|
4565
|
+
this.#scroller.follow();
|
|
3567
4566
|
return card;
|
|
3568
4567
|
}
|
|
3569
4568
|
}
|
|
@@ -3588,6 +4587,15 @@ function confirmPhrase(interrupt: Interrupt): string | undefined {
|
|
|
3588
4587
|
}
|
|
3589
4588
|
|
|
3590
4589
|
/** One tool call as a restored assistant message carries it. */
|
|
4590
|
+
/**
|
|
4591
|
+
* Why a client tool call is gated behind the confirmation card.
|
|
4592
|
+
*
|
|
4593
|
+
* Only `"destructive"` -- the default `x-destructive` gate -- may be waived for
|
|
4594
|
+
* the session. `confirmPredicate` is documented as authoritative, so a call it
|
|
4595
|
+
* gates keeps asking.
|
|
4596
|
+
*/
|
|
4597
|
+
type ConfirmationRule = "destructive" | "predicate";
|
|
4598
|
+
|
|
3591
4599
|
interface RestoredToolCall {
|
|
3592
4600
|
readonly id: string;
|
|
3593
4601
|
readonly function: { readonly name: string; readonly arguments?: unknown };
|