@artooi/ag-ui-web-component 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/README.md +242 -50
  3. package/dist/ag-ui-web-component.bundle.js +451 -46
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +34 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/{ag_ui_chat.d.ts → core/ag_ui_chat.d.ts} +63 -6
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -0
  9. package/dist/{agui_client.d.ts → core/agui_client.d.ts} +13 -0
  10. package/dist/core/agui_client.d.ts.map +1 -0
  11. package/dist/core/conversation_store.d.ts.map +1 -0
  12. package/dist/core/create_http_agent.d.ts.map +1 -0
  13. package/dist/core/define_ag_ui_chat.d.ts.map +1 -0
  14. package/dist/dom/animations.d.ts +71 -0
  15. package/dist/dom/animations.d.ts.map +1 -0
  16. package/dist/{dom_driver.d.ts → dom/dom_driver.d.ts} +7 -1
  17. package/dist/dom/dom_driver.d.ts.map +1 -0
  18. package/dist/dom/native_setter.d.ts +5 -0
  19. package/dist/dom/native_setter.d.ts.map +1 -0
  20. package/dist/index.d.ts +19 -16
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3934 -373
  23. package/dist/index.js.map +4 -4
  24. package/dist/skills/fill_template.d.ts +17 -0
  25. package/dist/skills/fill_template.d.ts.map +1 -0
  26. package/dist/skills/parse_skills.d.ts +8 -0
  27. package/dist/skills/parse_skills.d.ts.map +1 -0
  28. package/dist/skills/skill.d.ts +24 -0
  29. package/dist/skills/skill.d.ts.map +1 -0
  30. package/dist/{client_tool_registry.d.ts → tools/client_tool_registry.d.ts} +6 -1
  31. package/dist/tools/client_tool_registry.d.ts.map +1 -0
  32. package/dist/tools/is_destructive.d.ts.map +1 -0
  33. package/dist/tools/is_navigates.d.ts.map +1 -0
  34. package/dist/tools/page_map.d.ts.map +1 -0
  35. package/dist/tools/route_map.d.ts +40 -0
  36. package/dist/tools/route_map.d.ts.map +1 -0
  37. package/dist/tools/state_hook.d.ts.map +1 -0
  38. package/dist/ui/confirmation_card.d.ts +22 -0
  39. package/dist/ui/confirmation_card.d.ts.map +1 -0
  40. package/dist/ui/render_markdown.d.ts +13 -0
  41. package/dist/ui/render_markdown.d.ts.map +1 -0
  42. package/dist/ui/reveal_words.d.ts +8 -0
  43. package/dist/ui/reveal_words.d.ts.map +1 -0
  44. package/dist/ui/skills_menu.d.ts +35 -0
  45. package/dist/ui/skills_menu.d.ts.map +1 -0
  46. package/dist/ui/styles.d.ts +2 -0
  47. package/dist/ui/styles.d.ts.map +1 -0
  48. package/dist/ui/tool_call_card.d.ts +35 -0
  49. package/dist/ui/tool_call_card.d.ts.map +1 -0
  50. package/package.json +5 -3
  51. package/src/constants.ts +38 -0
  52. package/src/core/ag_ui_chat.ts +851 -0
  53. package/src/{agui_client.ts → core/agui_client.ts} +19 -1
  54. package/src/{define_ag_ui_chat.ts → core/define_ag_ui_chat.ts} +1 -1
  55. package/src/dom/animations.ts +201 -0
  56. package/src/{dom_driver.ts → dom/dom_driver.ts} +35 -2
  57. package/src/dom/native_setter.ts +42 -0
  58. package/src/index.ts +61 -32
  59. package/src/skills/fill_template.ts +32 -0
  60. package/src/skills/parse_skills.ts +26 -0
  61. package/src/skills/skill.ts +23 -0
  62. package/src/{client_tool_registry.ts → tools/client_tool_registry.ts} +6 -4
  63. package/src/{is_destructive.ts → tools/is_destructive.ts} +1 -1
  64. package/src/{is_navigates.ts → tools/is_navigates.ts} +1 -1
  65. package/src/tools/route_map.ts +140 -0
  66. package/src/{state_hook.ts → tools/state_hook.ts} +1 -1
  67. package/src/ui/confirmation_card.ts +71 -0
  68. package/src/ui/render_markdown.ts +68 -0
  69. package/src/ui/reveal_words.ts +41 -0
  70. package/src/ui/skills_menu.ts +171 -0
  71. package/src/ui/styles.ts +631 -0
  72. package/src/{tool_call_card.ts → ui/tool_call_card.ts} +50 -19
  73. package/src/version.ts +1 -1
  74. package/dist/ag_ui_chat.d.ts.map +0 -1
  75. package/dist/agui_client.d.ts.map +0 -1
  76. package/dist/animations.d.ts +0 -33
  77. package/dist/animations.d.ts.map +0 -1
  78. package/dist/client_tool_registry.d.ts.map +0 -1
  79. package/dist/confirmation_modal.d.ts +0 -14
  80. package/dist/confirmation_modal.d.ts.map +0 -1
  81. package/dist/conversation_store.d.ts.map +0 -1
  82. package/dist/create_http_agent.d.ts.map +0 -1
  83. package/dist/define_ag_ui_chat.d.ts.map +0 -1
  84. package/dist/dom_driver.d.ts.map +0 -1
  85. package/dist/is_destructive.d.ts.map +0 -1
  86. package/dist/is_navigates.d.ts.map +0 -1
  87. package/dist/page_map.d.ts.map +0 -1
  88. package/dist/route_map.d.ts +0 -27
  89. package/dist/route_map.d.ts.map +0 -1
  90. package/dist/state_hook.d.ts.map +0 -1
  91. package/dist/styles.d.ts +0 -2
  92. package/dist/styles.d.ts.map +0 -1
  93. package/dist/tool_call_card.d.ts +0 -29
  94. package/dist/tool_call_card.d.ts.map +0 -1
  95. package/src/ag_ui_chat.ts +0 -411
  96. package/src/animations.ts +0 -86
  97. package/src/confirmation_modal.ts +0 -69
  98. package/src/route_map.ts +0 -83
  99. package/src/styles.ts +0 -296
  100. /package/dist/{conversation_store.d.ts → core/conversation_store.d.ts} +0 -0
  101. /package/dist/{create_http_agent.d.ts → core/create_http_agent.d.ts} +0 -0
  102. /package/dist/{define_ag_ui_chat.d.ts → core/define_ag_ui_chat.d.ts} +0 -0
  103. /package/dist/{is_destructive.d.ts → tools/is_destructive.d.ts} +0 -0
  104. /package/dist/{is_navigates.d.ts → tools/is_navigates.d.ts} +0 -0
  105. /package/dist/{page_map.d.ts → tools/page_map.d.ts} +0 -0
  106. /package/dist/{state_hook.d.ts → tools/state_hook.d.ts} +0 -0
  107. /package/src/{conversation_store.ts → core/conversation_store.ts} +0 -0
  108. /package/src/{create_http_agent.ts → core/create_http_agent.ts} +0 -0
  109. /package/src/{page_map.ts → tools/page_map.ts} +0 -0
@@ -0,0 +1,851 @@
1
+ import type { Context, Message, Tool } from "@ag-ui/core";
2
+ import {
3
+ MESSAGE_ROLE,
4
+ SUBMIT_EVENT,
5
+ TOGGLE_EVENT,
6
+ TOOL_CALL_STATUS,
7
+ TOOL_DISPLAY,
8
+ X_CONFIRM_KEY,
9
+ X_SUMMARY_KEY,
10
+ } from "../constants.js";
11
+ import { fillTemplate } from "../skills/fill_template.js";
12
+ import { parseSkills } from "../skills/parse_skills.js";
13
+ import type { Skill } from "../skills/skill.js";
14
+ import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
15
+ import { isDestructive } from "../tools/is_destructive.js";
16
+ import { isNavigates } from "../tools/is_navigates.js";
17
+ import { createPageMapContext, type PageMap } from "../tools/page_map.js";
18
+ import { createRouteTools, type RouteMap } from "../tools/route_map.js";
19
+ import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
20
+ import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
21
+ import { renderMarkdown } from "../ui/render_markdown.js";
22
+ import { wrapWords } from "../ui/reveal_words.js";
23
+ import { SkillsMenu } from "../ui/skills_menu.js";
24
+ import { STYLES } from "../ui/styles.js";
25
+ import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
26
+ import {
27
+ AgUiClient,
28
+ type AgUiClientHandlers,
29
+ type AgUiToolCall,
30
+ type ToolExecution,
31
+ } from "./agui_client.js";
32
+ import {
33
+ type ClientConversationStore,
34
+ type NavigationCheckpoint,
35
+ SessionStorageStore,
36
+ } from "./conversation_store.js";
37
+ import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
38
+
39
+ /** The role a rendered chat message takes. */
40
+ export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
41
+
42
+ /** `detail` shape of the {@link SUBMIT_EVENT} CustomEvent. */
43
+ export interface SubmitDetail {
44
+ readonly content: string;
45
+ }
46
+
47
+ /** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
48
+ export interface ToggleDetail {
49
+ readonly collapsed: boolean;
50
+ }
51
+
52
+ /** Per-tab persistence key for the collapsed state (survives MPA reloads). */
53
+ const COLLAPSED_KEY = "ag-ui-chat:collapsed";
54
+
55
+ /**
56
+ * `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
57
+ *
58
+ * Owns the Shadow DOM shell (header, scrolling message list, input row),
59
+ * builds an {@link AgUiClient} on first send (via the overridable
60
+ * {@link agentFactory}), and renders streaming assistant text plus tool-call
61
+ * activity. Emits a {@link SUBMIT_EVENT} for host visibility.
62
+ *
63
+ * The per-run frontend tool catalog and context are supplied by
64
+ * {@link getTools} / {@link getContext}, which later phases (the tool
65
+ * registry, DOM driver) populate.
66
+ */
67
+ export class AgUiChat extends HTMLElement {
68
+ /** Agent factory; override to inject a custom or fake agent (tests). */
69
+ agentFactory: AgentFactory = createHttpAgent;
70
+
71
+ /** Extra HTTP headers for the AG-UI endpoint (e.g. CSRF). */
72
+ headers: Record<string, string> = {};
73
+
74
+ /** When true, destructive tools execute without a confirmation modal. */
75
+ autoConfirm = false;
76
+
77
+ /**
78
+ * Optional per-call confirmation predicate. When set, it is authoritative:
79
+ * given a tool name + args it decides whether *this* call needs confirmation
80
+ * (so one tool can be instant for some args and confirmed for others — what a
81
+ * static `x-destructive` flag can't express). When unset, the `x-destructive`
82
+ * schema flag is used. `autoConfirm` short-circuits both.
83
+ */
84
+ confirmPredicate:
85
+ | ((toolName: string, args: Record<string, unknown>) => boolean | Promise<boolean>)
86
+ | null = null;
87
+
88
+ /**
89
+ * Per-run frontend tool catalog provider. Defaults to the built-in
90
+ * `route.*` tools (when a {@link routeMap} is set) plus the tools registered
91
+ * via {@link registerTool} / {@link registerStateHook}; override to supply a
92
+ * fully custom catalog.
93
+ */
94
+ getTools: () => Tool[] = () => [
95
+ ...this.#builtinTools().map((t) => ({
96
+ name: t.name,
97
+ description: t.description,
98
+ parameters: t.parameters,
99
+ })),
100
+ ...this.#toolRegistry.tools(),
101
+ ];
102
+
103
+ /**
104
+ * Per-run context provider. Defaults to the compact page map (when a
105
+ * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on).
106
+ */
107
+ getContext: () => Context[] = () => createPageMapContext(this.getPageMap, this.autoInjectPageMap);
108
+
109
+ /**
110
+ * Navigable routes the agent can jump to via the built-in `route.*` tools.
111
+ * A compact summary also rides in each run's context.
112
+ */
113
+ routeMap: RouteMap = [];
114
+
115
+ /**
116
+ * Optional client-side router. When set (an SPA), `navigate_to_route` routes
117
+ * in-page and the run loop continues; when unset (an MPA like the admin), it
118
+ * falls back to `window.location` and the resumable-loop machinery applies.
119
+ */
120
+ navigate: ((path: string) => void) | null = null;
121
+
122
+ /** Provider for the per-run page map; see {@link getContext}. */
123
+ getPageMap: (() => PageMap) | null = null;
124
+
125
+ /** Whether to auto-inject the page map into context each run. */
126
+ autoInjectPageMap = true;
127
+
128
+ /**
129
+ * Persistence for the conversation + navigation checkpoint. Defaults to
130
+ * per-tab `sessionStorage` so the chat survives full page reloads; inject a
131
+ * server-backed store for cross-tab/device durability.
132
+ */
133
+ conversationStore: ClientConversationStore = new SessionStorageStore();
134
+
135
+ /**
136
+ * Builds the tool result a navigating tool resumes with after the page
137
+ * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
138
+ * override to include a page snapshot or post-reload validation errors.
139
+ */
140
+ navigationResult: (checkpoint: NavigationCheckpoint) => unknown = () => ({
141
+ navigated: true,
142
+ url: window.location.href,
143
+ });
144
+
145
+ /**
146
+ * Named values used to fill a skill prompt's `{placeholder}`s before send
147
+ * (e.g. `{ model: "Order", selected_ids: "1,2" }`). A host (the admin) sets
148
+ * this from the current page; a missing placeholder blocks the send.
149
+ */
150
+ skillContext: () => Record<string, unknown> = () => ({});
151
+
152
+ readonly #toolRegistry = new ClientToolRegistry();
153
+ /** Tool-call cards awaiting execution, keyed by call id. */
154
+ readonly #toolCards = new Map<string, ToolCallCard>();
155
+ /**
156
+ * Call ids whose card was already settled from a streamed server-side result
157
+ * (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
158
+ * the real output with the generic "executed on the server" fallback.
159
+ */
160
+ readonly #serverSettled = new Set<string>();
161
+ readonly #root: ShadowRoot;
162
+ readonly #chat: HTMLDivElement;
163
+ readonly #messages: HTMLDivElement;
164
+ readonly #input: HTMLTextAreaElement;
165
+ readonly #send: HTMLButtonElement;
166
+ readonly #title: HTMLSpanElement;
167
+ readonly #skillsMenu: SkillsMenu;
168
+ readonly #skillHint: HTMLDivElement;
169
+
170
+ #client: AgUiClient | null = null;
171
+ #streamingBubble: HTMLDivElement | null = null;
172
+ #pending: HTMLDivElement | null = null;
173
+ #threadId = "";
174
+ #initialMessages: readonly Message[] = [];
175
+ // Skill catalog by source; merged backend → embed → client (later wins).
176
+ #backendSkills: readonly Skill[] = [];
177
+ #embedSkills: readonly Skill[] = [];
178
+ #clientSkills: readonly Skill[] = [];
179
+
180
+ constructor() {
181
+ super();
182
+ this.#root = this.attachShadow({ mode: "open" });
183
+ this.#chat = document.createElement("div");
184
+ this.#messages = document.createElement("div");
185
+ this.#input = document.createElement("textarea");
186
+ this.#send = document.createElement("button");
187
+ this.#title = document.createElement("span");
188
+ this.#skillHint = document.createElement("div");
189
+ this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
190
+ }
191
+
192
+ /** Attributes whose late changes must reflect in already-rendered chrome. */
193
+ static get observedAttributes(): string[] {
194
+ return ["title-text"];
195
+ }
196
+
197
+ attributeChangedCallback(_name: string, _previous: string | null, value: string | null): void {
198
+ // Only `title-text` is observed (other attributes are read at use-time or
199
+ // are CSS-reactive), so update the header title directly.
200
+ this.#title.textContent = value ?? "Assistant";
201
+ }
202
+
203
+ /** Declare a frontend tool the agent may call. */
204
+ registerTool(tool: ClientTool): void {
205
+ this.#toolRegistry.register(tool);
206
+ }
207
+
208
+ /** Bind a piece of host state to `read_<name>` / `set_<name>` tools. */
209
+ registerStateHook(hook: StateHook): void {
210
+ for (const tool of createStateHookTools(hook)) {
211
+ this.#toolRegistry.register(tool);
212
+ }
213
+ }
214
+
215
+ /** The built-in `route.*` tools, present only when a route map is set. */
216
+ #routeTools(): ClientTool[] {
217
+ if (this.routeMap.length === 0) {
218
+ return [];
219
+ }
220
+ return createRouteTools(
221
+ () => this.routeMap,
222
+ () => this.navigate,
223
+ );
224
+ }
225
+
226
+ /**
227
+ * The built-in `read_page` tool, present only when a {@link getPageMap}
228
+ * provider is set. A *pull* the agent can call mid-turn to see the page after
229
+ * it has acted (the auto-injected context is a send-time snapshot).
230
+ */
231
+ #pageTools(): ClientTool[] {
232
+ const getPageMap = this.getPageMap;
233
+ if (getPageMap === null) {
234
+ return [];
235
+ }
236
+ return [
237
+ {
238
+ name: "read_page",
239
+ description:
240
+ "Read the current page's structure (fields, buttons, route). Call after " +
241
+ "acting to observe the result within the same turn.",
242
+ parameters: { type: "object", properties: {}, required: [] },
243
+ handler: () => getPageMap(),
244
+ },
245
+ ];
246
+ }
247
+
248
+ /** All built-in (route + page) frontend tools. */
249
+ #builtinTools(): ClientTool[] {
250
+ return [...this.#routeTools(), ...this.#pageTools()];
251
+ }
252
+
253
+ /** Resolve a tool by name: built-in tools first, then the registry. */
254
+ #resolveTool(name: string): ClientTool | null {
255
+ const builtin = this.#builtinTools().find((t) => t.name === name);
256
+ if (builtin !== undefined) {
257
+ return builtin;
258
+ }
259
+ return this.#toolRegistry.has(name) ? this.#toolRegistry.get(name) : null;
260
+ }
261
+
262
+ /** The AG-UI endpoint URL, read from the `endpoint` attribute. */
263
+ get endpoint(): string {
264
+ return this.getAttribute("endpoint") ?? "";
265
+ }
266
+
267
+ // Reflecting setter so frameworks (e.g. React 19) that assign matching
268
+ // props as element *properties* don't hit a read-only property. Read at
269
+ // use-time, so a runtime change applies to the next run.
270
+ set endpoint(value: string) {
271
+ this.setAttribute("endpoint", value);
272
+ }
273
+
274
+ /**
275
+ * How much detail tool-call cards show, from the `data-tool-display`
276
+ * attribute (`minimal` / `compact` / `full`). Defaults to `full`.
277
+ */
278
+ get toolDisplay(): ToolDisplayMode {
279
+ const attr = this.getAttribute("data-tool-display");
280
+ if (attr === TOOL_DISPLAY.MINIMAL || attr === TOOL_DISPLAY.COMPACT) {
281
+ return attr;
282
+ }
283
+ return TOOL_DISPLAY.FULL;
284
+ }
285
+
286
+ set toolDisplay(value: ToolDisplayMode) {
287
+ this.setAttribute("data-tool-display", value);
288
+ }
289
+
290
+ connectedCallback(): void {
291
+ this.#render();
292
+ if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
293
+ this.setAttribute("collapsed", "");
294
+ }
295
+ this.#initSkills();
296
+ this.#threadId = this.conversationStore.threadId();
297
+ void this.#rehydrate();
298
+ }
299
+
300
+ /**
301
+ * Replace the host-supplied (client) skill catalog. Merged after the embedded
302
+ * and fetched skills (so a client skill overrides a same-named server one).
303
+ */
304
+ setSkills(skills: readonly Skill[]): void {
305
+ this.#clientSkills = skills;
306
+ this.#recomputeSkills();
307
+ }
308
+
309
+ /** Wire the skill surfaces: opt-in flags, embedded catalog, optional fetch. */
310
+ #initSkills(): void {
311
+ this.#skillsMenu.enableChips(this.getAttribute("data-prompt-chips") === "true");
312
+ this.#skillsMenu.enableSlash(this.getAttribute("data-slash-commands") === "true");
313
+ this.#embedSkills = this.#readEmbeddedSkills();
314
+ this.#recomputeSkills();
315
+ void this.#fetchSkills();
316
+ }
317
+
318
+ /** Parse the inline `data-skills` JSON catalog (empty when absent/malformed). */
319
+ #readEmbeddedSkills(): readonly Skill[] {
320
+ const raw = this.getAttribute("data-skills");
321
+ if (raw === null) {
322
+ return [];
323
+ }
324
+ try {
325
+ return parseSkills(JSON.parse(raw));
326
+ } catch {
327
+ return [];
328
+ }
329
+ }
330
+
331
+ /** Fetch the backend skills catalog from `data-skills-url`, if set. */
332
+ async #fetchSkills(): Promise<void> {
333
+ const url = this.getAttribute("data-skills-url");
334
+ if (url === null) {
335
+ return;
336
+ }
337
+ try {
338
+ const response = await fetch(url, { headers: this.headers });
339
+ this.#backendSkills = parseSkills(await response.json());
340
+ this.#recomputeSkills();
341
+ } catch {
342
+ // Network/parse failure: skills just stay as the embedded/client set.
343
+ }
344
+ }
345
+
346
+ /** Merge the three sources (backend → embed → client; later wins by name). */
347
+ #recomputeSkills(): void {
348
+ const merged = new Map<string, Skill>();
349
+ for (const skill of [...this.#backendSkills, ...this.#embedSkills, ...this.#clientSkills]) {
350
+ merged.set(skill.name, skill);
351
+ }
352
+ this.#skillsMenu.setSkills([...merged.values()]);
353
+ }
354
+
355
+ /** Pre-fill (or send) a picked skill's prompt, filling its placeholders. */
356
+ #applySkill(skill: Skill): void {
357
+ const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
358
+ if (missing.length > 0) {
359
+ this.#skillHint.textContent = `“${skill.title}” needs: ${missing.join(", ")}`;
360
+ this.#skillHint.hidden = false;
361
+ return;
362
+ }
363
+ this.#skillHint.hidden = true;
364
+ this.#input.value = text;
365
+ if (skill.sendImmediately === true) {
366
+ void this.#submit();
367
+ } else {
368
+ this.#input.focus();
369
+ }
370
+ }
371
+
372
+ /** Whether the widget is collapsed (reflected as the `collapsed` attribute). */
373
+ get collapsed(): boolean {
374
+ return this.hasAttribute("collapsed");
375
+ }
376
+
377
+ // Property setter (framework interop) — delegates to setCollapsed so a
378
+ // `collapsed` prop assignment persists + emits the toggle event.
379
+ set collapsed(value: boolean) {
380
+ this.setCollapsed(value);
381
+ }
382
+
383
+ /**
384
+ * Set the collapsed state: reflect the `collapsed` attribute, persist it
385
+ * per-tab, and emit a {@link TOGGLE_EVENT} so a host can mirror the state in
386
+ * its own chrome.
387
+ */
388
+ setCollapsed(collapsed: boolean): void {
389
+ if (collapsed) {
390
+ this.setAttribute("collapsed", "");
391
+ } else {
392
+ this.removeAttribute("collapsed");
393
+ }
394
+ sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
395
+ this.dispatchEvent(
396
+ new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
397
+ detail: { collapsed },
398
+ bubbles: true,
399
+ composed: true,
400
+ }),
401
+ );
402
+ }
403
+
404
+ /** Flip the collapsed state. Bound to the built-in header toggle. */
405
+ toggleCollapsed(): void {
406
+ this.setCollapsed(!this.collapsed);
407
+ }
408
+
409
+ /**
410
+ * Start a fresh conversation: forget the persisted history, drop the
411
+ * in-memory run state, clear the transcript, and mint a new thread id.
412
+ */
413
+ newChat(): void {
414
+ this.conversationStore.clear(this.#threadId);
415
+ this.#client = null;
416
+ this.#streamingBubble = null;
417
+ this.#hidePending();
418
+ this.#toolCards.clear();
419
+ this.#serverSettled.clear();
420
+ this.#initialMessages = [];
421
+ this.#messages.replaceChildren();
422
+ this.#threadId = this.conversationStore.threadId();
423
+ this.#send.disabled = false;
424
+ }
425
+
426
+ /**
427
+ * Restore the conversation from the store on mount, then — if a navigating
428
+ * tool reloaded the page mid-run — resume the loop by supplying that tool's
429
+ * result from the page we landed on.
430
+ */
431
+ async #rehydrate(): Promise<void> {
432
+ const messages = await this.conversationStore.loadMessages(this.#threadId);
433
+ if (messages !== null) {
434
+ this.#initialMessages = messages;
435
+ for (const message of messages) {
436
+ this.#renderHistoricMessage(message);
437
+ }
438
+ }
439
+ const checkpoint = this.conversationStore.loadCheckpoint(this.#threadId);
440
+ if (checkpoint !== null) {
441
+ await this.#resumeFrom(checkpoint);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Replay a restored message: text bubbles *and* tool activity. An assistant
447
+ * turn may carry `toolCalls` (rendered as cards) and/or text; a `tool` turn
448
+ * carries a result that settles the matching card. So a refreshed page shows
449
+ * the full transcript — tool calls and their results — not just the prose.
450
+ */
451
+ #renderHistoricMessage(message: Message): void {
452
+ const text = typeof message.content === "string" ? message.content : "";
453
+ if (message.role === MESSAGE_ROLE.USER) {
454
+ if (text !== "") {
455
+ this.appendMessage(MESSAGE_ROLE.USER, text);
456
+ }
457
+ return;
458
+ }
459
+ if (message.role === MESSAGE_ROLE.ASSISTANT) {
460
+ if (text !== "") {
461
+ this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, text));
462
+ }
463
+ const toolCalls = message.toolCalls;
464
+ if (toolCalls !== undefined) {
465
+ for (const call of toolCalls) {
466
+ this.#cardFor({
467
+ id: call.id,
468
+ name: call.function.name,
469
+ args: this.#parseArgs(call.function.arguments),
470
+ });
471
+ }
472
+ }
473
+ return;
474
+ }
475
+ if (message.role === "tool") {
476
+ const card = this.#toolCards.get(message.toolCallId);
477
+ if (card !== undefined) {
478
+ card.settle(TOOL_CALL_STATUS.DONE, message.content);
479
+ }
480
+ }
481
+ }
482
+
483
+ /** Parse a tool call's JSON `arguments` string from history into an object. */
484
+ #parseArgs(raw: string): Record<string, unknown> {
485
+ try {
486
+ const parsed: unknown = JSON.parse(raw);
487
+ if (typeof parsed === "object" && parsed !== null) {
488
+ return parsed as Record<string, unknown>;
489
+ }
490
+ } catch {
491
+ // Malformed history — fall back to empty args rather than failing replay.
492
+ }
493
+ return {};
494
+ }
495
+
496
+ /**
497
+ * Word-by-word reveal for the `word` text-animation mode, applied to a
498
+ * completed assistant bubble. `fade` is pure CSS (no JS); `none` is a no-op.
499
+ */
500
+ #revealWords(bubble: HTMLDivElement): void {
501
+ if (this.getAttribute("data-text-animation") === "word") {
502
+ wrapWords(bubble);
503
+ }
504
+ }
505
+
506
+ /** Complete the checkpointed navigating tool call and continue the run. */
507
+ async #resumeFrom(checkpoint: NavigationCheckpoint): Promise<void> {
508
+ this.conversationStore.saveCheckpoint(this.#threadId, null);
509
+ const client = this.#ensureClient();
510
+ client.addToolResult(checkpoint.toolCallId, JSON.stringify(this.navigationResult(checkpoint)));
511
+ await client.resume();
512
+ }
513
+
514
+ /**
515
+ * Append a message bubble and return it.
516
+ *
517
+ * Assistant content is rendered as sanitised markdown/HTML; user content
518
+ * stays literal text (no need to parse what the user typed, and it avoids
519
+ * rendering user-authored markup).
520
+ */
521
+ appendMessage(role: MessageRole, content: string): HTMLDivElement {
522
+ const bubble = document.createElement("div");
523
+ bubble.className = `message message--${role}`;
524
+ if (role === MESSAGE_ROLE.ASSISTANT) {
525
+ bubble.innerHTML = renderMarkdown(content);
526
+ } else {
527
+ bubble.textContent = content;
528
+ }
529
+ this.#messages.appendChild(bubble);
530
+ this.#messages.scrollTop = this.#messages.scrollHeight;
531
+ return bubble;
532
+ }
533
+
534
+ #render(): void {
535
+ const style = document.createElement("style");
536
+ style.textContent = STYLES;
537
+
538
+ this.#chat.className = "chat";
539
+
540
+ const header = document.createElement("div");
541
+ header.className = "header";
542
+
543
+ const title = this.#title;
544
+ title.className = "header-title";
545
+ title.textContent = this.getAttribute("title-text") ?? "Assistant";
546
+
547
+ const controls = document.createElement("div");
548
+ controls.className = "header-controls";
549
+
550
+ const newChat = document.createElement("button");
551
+ newChat.type = "button";
552
+ newChat.className = "header-btn header-btn--new";
553
+ newChat.title = "New chat";
554
+ newChat.setAttribute("aria-label", "New chat");
555
+ newChat.textContent = "✚";
556
+ newChat.addEventListener("click", () => this.newChat());
557
+
558
+ const collapse = document.createElement("button");
559
+ collapse.type = "button";
560
+ collapse.className = "header-btn header-btn--collapse";
561
+ collapse.title = "Collapse";
562
+ collapse.setAttribute("aria-label", "Collapse");
563
+ collapse.textContent = "—";
564
+ collapse.addEventListener("click", () => this.toggleCollapsed());
565
+
566
+ controls.append(newChat, collapse);
567
+ header.append(title, controls);
568
+
569
+ this.#messages.className = "messages";
570
+ // Screen readers announce streamed messages as they arrive.
571
+ this.#messages.setAttribute("role", "log");
572
+ this.#messages.setAttribute("aria-live", "polite");
573
+ this.#messages.setAttribute("aria-label", "Conversation");
574
+
575
+ const inputRow = document.createElement("div");
576
+ inputRow.className = "input-row";
577
+
578
+ this.#input.className = "input";
579
+ this.#input.setAttribute("aria-label", "Message");
580
+ this.#input.rows = 2;
581
+ this.#input.placeholder = "Ask anything…";
582
+ this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
583
+ this.#input.addEventListener("input", () => this.#onInput());
584
+
585
+ this.#send.className = "send";
586
+ this.#send.type = "button";
587
+ this.#send.textContent = "Send";
588
+ this.#send.addEventListener("click", () => {
589
+ void this.#submit();
590
+ });
591
+
592
+ this.#skillHint.className = "skill-hint";
593
+ this.#skillHint.hidden = true;
594
+
595
+ inputRow.append(this.#input, this.#send);
596
+ // Skill surfaces sit just above the input: palette (opens on `/`), chips,
597
+ // and the missing-placeholder hint.
598
+ this.#chat.append(
599
+ header,
600
+ this.#messages,
601
+ this.#skillsMenu.palette,
602
+ this.#skillsMenu.chips,
603
+ this.#skillHint,
604
+ inputRow,
605
+ );
606
+ this.#root.append(style, this.#chat);
607
+ }
608
+
609
+ /** Forward input changes to the skills palette and clear any stale hint. */
610
+ #onInput(): void {
611
+ this.#skillsMenu.onInput(this.#input.value);
612
+ this.#skillHint.hidden = true;
613
+ }
614
+
615
+ #onKeydown(event: KeyboardEvent): void {
616
+ // The skills palette consumes arrows/enter/escape while open.
617
+ if (this.#skillsMenu.onKeydown(event)) {
618
+ event.preventDefault();
619
+ return;
620
+ }
621
+ if (event.key === "Enter" && !event.shiftKey) {
622
+ event.preventDefault();
623
+ void this.#submit();
624
+ }
625
+ }
626
+
627
+ async #submit(): Promise<void> {
628
+ const content = this.#input.value.trim();
629
+ if (content === "") {
630
+ return;
631
+ }
632
+ this.appendMessage(MESSAGE_ROLE.USER, content);
633
+ this.#input.value = "";
634
+ this.dispatchEvent(
635
+ new CustomEvent<SubmitDetail>(SUBMIT_EVENT, {
636
+ detail: { content },
637
+ bubbles: true,
638
+ composed: true,
639
+ }),
640
+ );
641
+ await this.#client_send(content);
642
+ }
643
+
644
+ async #client_send(content: string): Promise<void> {
645
+ if (this.endpoint === "") {
646
+ return;
647
+ }
648
+ await this.#ensureClient().send(content);
649
+ }
650
+
651
+ #ensureClient(): AgUiClient {
652
+ if (this.#client === null) {
653
+ const agent = this.agentFactory({
654
+ endpoint: this.endpoint,
655
+ headers: this.headers,
656
+ threadId: this.#threadId,
657
+ initialMessages: this.#initialMessages,
658
+ });
659
+ this.#client = new AgUiClient({
660
+ agent,
661
+ handlers: this.#handlers(),
662
+ getTools: () => this.getTools(),
663
+ getContext: () => this.getContext(),
664
+ executeTool: (call) => this.#executeTool(call),
665
+ onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
666
+ });
667
+ }
668
+ return this.#client;
669
+ }
670
+
671
+ /** Whether ``call`` should be gated behind the confirmation card. */
672
+ async #needsConfirmation(call: AgUiToolCall, tool: ClientTool): Promise<boolean> {
673
+ if (this.autoConfirm) {
674
+ return false;
675
+ }
676
+ if (this.confirmPredicate !== null) {
677
+ return (await this.confirmPredicate(call.name, call.args)) === true;
678
+ }
679
+ return isDestructive(tool.parameters);
680
+ }
681
+
682
+ async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
683
+ const card = this.#cardFor(call);
684
+ this.#toolCards.delete(call.id);
685
+ const tool = this.#resolveTool(call.name);
686
+ if (tool === null) {
687
+ // A server-side tool the server already executed — not ours to re-run.
688
+ // Its real output usually arrived via `onToolResult` (TOOL_CALL_RESULT)
689
+ // and already settled the card; only fall back when it didn't. We do NOT
690
+ // show the pending indicator here: a server tool never triggers another
691
+ // client round, so showing it would leave it stuck after the run ended.
692
+ if (!this.#serverSettled.has(call.id)) {
693
+ card.settle(TOOL_CALL_STATUS.DONE, "Executed on the server.");
694
+ }
695
+ return null;
696
+ }
697
+ if (await this.#needsConfirmation(call, tool)) {
698
+ const request: ConfirmationRequest = { toolName: call.name, args: call.args };
699
+ const confirmText = tool.parameters[X_CONFIRM_KEY];
700
+ if (typeof confirmText === "string") {
701
+ request.message = confirmText;
702
+ }
703
+ const decision = requestConfirmation(this.#messages, request);
704
+ this.#messages.scrollTop = this.#messages.scrollHeight;
705
+ const accepted = await decision;
706
+ if (!accepted) {
707
+ const message = "User declined the action.";
708
+ card.settle(TOOL_CALL_STATUS.DECLINED, message);
709
+ this.#showPending();
710
+ return { content: message };
711
+ }
712
+ }
713
+ // A navigating tool reloads only without a client-side router; with a
714
+ // host `navigate()` (SPA) it routes in-page and the loop just continues.
715
+ const navigates = isNavigates(tool.parameters) && this.navigate === null;
716
+ if (navigates) {
717
+ // Checkpoint before the handler reloads the page; the history (incl.
718
+ // this tool call) was already persisted when the run that produced it
719
+ // settled. The result is supplied on the next mount via the resume path.
720
+ this.conversationStore.saveCheckpoint(this.#threadId, { toolCallId: call.id });
721
+ }
722
+ try {
723
+ const result = await tool.handler(call.args);
724
+ if (navigates) {
725
+ card.settle(TOOL_CALL_STATUS.DONE, "Navigating…");
726
+ return { content: "", halt: true };
727
+ }
728
+ const content = JSON.stringify(result ?? null);
729
+ card.settle(TOOL_CALL_STATUS.DONE, content);
730
+ this.#showPending();
731
+ return { content };
732
+ } catch (error) {
733
+ if (navigates) {
734
+ // The navigation never happened; drop the dangling checkpoint.
735
+ this.conversationStore.saveCheckpoint(this.#threadId, null);
736
+ }
737
+ const message = error instanceof Error ? error.message : String(error);
738
+ card.settle(TOOL_CALL_STATUS.ERROR, message);
739
+ this.#showPending();
740
+ return { content: `Error: ${message}`, error: message };
741
+ }
742
+ }
743
+
744
+ #handlers(): AgUiClientHandlers {
745
+ return {
746
+ onRunStart: () => {
747
+ this.#send.disabled = true;
748
+ this.#showPending();
749
+ },
750
+ onTextDelta: (buffer) => {
751
+ this.#hidePending();
752
+ this.#streamInto(buffer);
753
+ },
754
+ onTextEnd: (buffer) => {
755
+ this.#revealWords(this.#streamInto(buffer));
756
+ this.#streamingBubble = null;
757
+ },
758
+ onToolCall: (call) => {
759
+ this.#hidePending();
760
+ this.#cardFor(call);
761
+ },
762
+ onToolResult: (toolCallId, content) => {
763
+ const card = this.#toolCards.get(toolCallId);
764
+ if (card === undefined) {
765
+ return;
766
+ }
767
+ card.settle(TOOL_CALL_STATUS.DONE, content);
768
+ this.#serverSettled.add(toolCallId);
769
+ },
770
+ onRunEnd: () => {
771
+ this.#hidePending();
772
+ this.#send.disabled = false;
773
+ this.#streamingBubble = null;
774
+ },
775
+ onError: (message) => {
776
+ this.#hidePending();
777
+ this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`));
778
+ this.#send.disabled = false;
779
+ this.#streamingBubble = null;
780
+ },
781
+ onSettled: () => {
782
+ // Terminal guarantee: whatever path ended the run, return to rest.
783
+ this.#hidePending();
784
+ this.#send.disabled = false;
785
+ this.#streamingBubble = null;
786
+ },
787
+ };
788
+ }
789
+
790
+ /**
791
+ * Show a "thinking" indicator while the agent is being awaited — both the
792
+ * silent stretch before the first token and the gap after a tool result
793
+ * while the next round is requested. Idempotent.
794
+ */
795
+ #showPending(): void {
796
+ if (this.#pending !== null) {
797
+ return;
798
+ }
799
+ const pending = document.createElement("div");
800
+ pending.className = "pending";
801
+ pending.setAttribute("role", "status");
802
+ pending.setAttribute("aria-label", "Assistant is thinking…");
803
+ for (let i = 0; i < 3; i += 1) {
804
+ const dot = document.createElement("span");
805
+ dot.className = "pending-dot";
806
+ pending.appendChild(dot);
807
+ }
808
+ this.#pending = pending;
809
+ this.#messages.appendChild(pending);
810
+ this.#messages.scrollTop = this.#messages.scrollHeight;
811
+ }
812
+
813
+ /** Remove the pending indicator if shown. */
814
+ #hidePending(): void {
815
+ this.#pending?.remove();
816
+ this.#pending = null;
817
+ }
818
+
819
+ #streamInto(buffer: string): HTMLDivElement {
820
+ if (this.#streamingBubble === null) {
821
+ this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
822
+ }
823
+ this.#streamingBubble.innerHTML = renderMarkdown(buffer);
824
+ this.#messages.scrollTop = this.#messages.scrollHeight;
825
+ return this.#streamingBubble;
826
+ }
827
+
828
+ /**
829
+ * The card for ``call``, creating and appending it on first sight.
830
+ *
831
+ * {@link AgUiClientHandlers.onToolCall} creates the card (pending) during the
832
+ * run; {@link #executeTool} later retrieves the same card to settle it.
833
+ */
834
+ #cardFor(call: AgUiToolCall): ToolCallCard {
835
+ const existing = this.#toolCards.get(call.id);
836
+ if (existing !== undefined) {
837
+ return existing;
838
+ }
839
+ const summary = this.#resolveTool(call.name)?.parameters[X_SUMMARY_KEY];
840
+ const card = new ToolCallCard(
841
+ call.name,
842
+ call.args,
843
+ this.toolDisplay,
844
+ typeof summary === "string" ? summary : undefined,
845
+ );
846
+ this.#toolCards.set(call.id, card);
847
+ this.#messages.appendChild(card.element);
848
+ this.#messages.scrollTop = this.#messages.scrollHeight;
849
+ return card;
850
+ }
851
+ }