@artooi/ag-ui-web-component 0.6.0 → 0.8.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +180 -9
  3. package/dist/ag-ui-web-component.bundle.js +268 -47
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +5 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +20 -0
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +16 -0
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +847 -237
  14. package/dist/index.js.map +3 -3
  15. package/dist/tools/page_action_tools.d.ts +31 -0
  16. package/dist/tools/page_action_tools.d.ts.map +1 -0
  17. package/dist/ui/attachment_tray.d.ts +3 -0
  18. package/dist/ui/attachment_tray.d.ts.map +1 -1
  19. package/dist/ui/confirmation_card.d.ts +4 -1
  20. package/dist/ui/confirmation_card.d.ts.map +1 -1
  21. package/dist/ui/relative_time.d.ts +5 -3
  22. package/dist/ui/relative_time.d.ts.map +1 -1
  23. package/dist/ui/styles.d.ts +1 -1
  24. package/dist/ui/styles.d.ts.map +1 -1
  25. package/dist/ui/thread_drawer.d.ts +7 -1
  26. package/dist/ui/thread_drawer.d.ts.map +1 -1
  27. package/dist/ui/tool_call_card.d.ts +19 -8
  28. package/dist/ui/tool_call_card.d.ts.map +1 -1
  29. package/dist/ui/ui_strings.d.ts +126 -0
  30. package/dist/ui/ui_strings.d.ts.map +1 -0
  31. package/package.json +1 -1
  32. package/src/constants.ts +5 -0
  33. package/src/core/ag_ui_chat.ts +267 -46
  34. package/src/core/agui_client.ts +33 -2
  35. package/src/index.ts +7 -0
  36. package/src/tools/page_action_tools.ts +130 -0
  37. package/src/ui/attachment_tray.ts +13 -7
  38. package/src/ui/confirmation_card.ts +15 -5
  39. package/src/ui/relative_time.ts +15 -8
  40. package/src/ui/styles.ts +221 -0
  41. package/src/ui/thread_drawer.ts +53 -25
  42. package/src/ui/tool_call_card.ts +63 -25
  43. package/src/ui/ui_strings.ts +208 -0
  44. package/src/version.ts +1 -1
@@ -15,6 +15,7 @@ import type { Skill } from "../skills/skill.js";
15
15
  import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
16
16
  import { isDestructive } from "../tools/is_destructive.js";
17
17
  import { isNavigates } from "../tools/is_navigates.js";
18
+ import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
18
19
  import { createPageMapContext, type PageMap } from "../tools/page_map.js";
19
20
  import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
20
21
  import { createRouteTools, type RouteMap } from "../tools/route_map.js";
@@ -29,6 +30,7 @@ import { SkillsMenu } from "../ui/skills_menu.js";
29
30
  import { STYLES } from "../ui/styles.js";
30
31
  import { ThreadDrawer } from "../ui/thread_drawer.js";
31
32
  import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
33
+ import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
32
34
  import {
33
35
  AgUiClient,
34
36
  type AgUiClientHandlers,
@@ -194,12 +196,30 @@ export class AgUiChat extends HTMLElement {
194
196
  */
195
197
  toolSummaries: Record<string, string> = {};
196
198
 
199
+ /**
200
+ * Localizable UI strings — a partial override merged over the English
201
+ * {@link DEFAULT_UI_STRINGS}. Resolved once on connect (so set it before the
202
+ * element is appended); the `data-strings` JSON attribute is the markup
203
+ * equivalent, and this property wins key-by-key over it.
204
+ */
205
+ strings: Partial<UiStrings> = {};
206
+
207
+ /**
208
+ * Resolve a `scroll_to` / `drag_and_drop` target string to a host-page
209
+ * element (or `null`). Defaults to a CSS-selector lookup; override to map
210
+ * page-map element ids. The page-action tools are opt-in via the
211
+ * `data-page-actions` attribute (`"scroll"` / `"drag"`).
212
+ */
213
+ resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
214
+
197
215
  /**
198
216
  * Card labels fetched from a server tool catalog (`data-tools-url`), keyed by
199
217
  * tool name. The base layer behind {@link toolSummaries}: an explicit entry in
200
218
  * `toolSummaries` wins, this fills the rest. Populated once on connect.
201
219
  */
202
220
  #toolCatalog: Record<string, string> = {};
221
+ /** The resolved string table (defaults ← `data-strings` ← `strings`). */
222
+ #strings: UiStrings = DEFAULT_UI_STRINGS;
203
223
 
204
224
  readonly #toolRegistry = new ClientToolRegistry();
205
225
  /** Tool-call cards awaiting execution, keyed by call id. */
@@ -223,6 +243,10 @@ export class AgUiChat extends HTMLElement {
223
243
  readonly #attachButton: HTMLButtonElement;
224
244
  readonly #fileInput: HTMLInputElement;
225
245
  readonly #attachSlot: HTMLDivElement;
246
+ /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
247
+ readonly #rail: HTMLButtonElement;
248
+ /** Empty-state region at the top of the message list; hidden once anything renders. */
249
+ readonly #emptyWrap: HTMLDivElement;
226
250
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
227
251
  #attachTray: AttachmentTray | null = null;
228
252
  /** Refs attached to the message currently being sent (the context manifest). */
@@ -242,6 +266,13 @@ export class AgUiChat extends HTMLElement {
242
266
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
243
267
  #streamDeltas = 0;
244
268
  #pending: HTMLDivElement | null = null;
269
+ // The current assistant turn's grouping container (WELL-1). One `.answer`
270
+ // wraps everything a single answer produces — streamed text, tool cards, the
271
+ // pending indicator — so it can be boxed as one "well" by CSS. Opened on the
272
+ // turn's first run start, closed at settle, so it spans the whole multi-round
273
+ // frontend-tool loop (which is several AG-UI runs), not one run. `null`
274
+ // between turns; user bubbles never enter it.
275
+ #currentGroup: HTMLDivElement | null = null;
245
276
  #threadId = "";
246
277
  #initialMessages: readonly Message[] = [];
247
278
  // Skill catalog by source; merged backend → embed → client (later wins).
@@ -261,6 +292,8 @@ export class AgUiChat extends HTMLElement {
261
292
  this.#attachButton = document.createElement("button");
262
293
  this.#fileInput = document.createElement("input");
263
294
  this.#attachSlot = document.createElement("div");
295
+ this.#rail = document.createElement("button");
296
+ this.#emptyWrap = document.createElement("div");
264
297
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
265
298
  this.#drawer = new ThreadDrawer({
266
299
  onSelect: (threadId) => {
@@ -287,8 +320,9 @@ export class AgUiChat extends HTMLElement {
287
320
 
288
321
  attributeChangedCallback(_name: string, _previous: string | null, value: string | null): void {
289
322
  // Only `title-text` is observed (other attributes are read at use-time or
290
- // are CSS-reactive), so update the header title directly.
291
- this.#title.textContent = value ?? "Assistant";
323
+ // are CSS-reactive), so update the header title directly. `#strings` is the
324
+ // resolved table once connected, the English defaults before then.
325
+ this.#title.textContent = value ?? this.#strings.title;
292
326
  }
293
327
 
294
328
  /** Declare a frontend tool the agent may call. */
@@ -341,9 +375,29 @@ export class AgUiChat extends HTMLElement {
341
375
  ];
342
376
  }
343
377
 
344
- /** All built-in (route + page) frontend tools. */
378
+ /**
379
+ * Opt-in page-action tools (`scroll_to` / `drag_and_drop`), enabled per token
380
+ * via the `data-page-actions` attribute (e.g. `"scroll,drag"`). Targets resolve
381
+ * through {@link resolvePageTarget} so a host controls the agent's interaction
382
+ * surface; absent attribute ⇒ no tools registered.
383
+ */
384
+ #pageActionTools(): ClientTool[] {
385
+ const attr = this.getAttribute("data-page-actions");
386
+ if (attr === null) {
387
+ return [];
388
+ }
389
+ const enabled = new Set(
390
+ attr
391
+ .split(",")
392
+ .map((token) => token.trim())
393
+ .filter((token) => token !== ""),
394
+ );
395
+ return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
396
+ }
397
+
398
+ /** All built-in (route + page + page-action) frontend tools. */
345
399
  #builtinTools(): ClientTool[] {
346
- return [...this.#routeTools(), ...this.#pageTools()];
400
+ return [...this.#routeTools(), ...this.#pageTools(), ...this.#pageActionTools()];
347
401
  }
348
402
 
349
403
  /** Resolve a tool by name: built-in tools first, then the registry. */
@@ -373,7 +427,11 @@ export class AgUiChat extends HTMLElement {
373
427
  */
374
428
  get toolDisplay(): ToolDisplayMode {
375
429
  const attr = this.getAttribute("data-tool-display");
376
- if (attr === TOOL_DISPLAY.MINIMAL || attr === TOOL_DISPLAY.COMPACT) {
430
+ if (
431
+ attr === TOOL_DISPLAY.INLINE ||
432
+ attr === TOOL_DISPLAY.MINIMAL ||
433
+ attr === TOOL_DISPLAY.COMPACT
434
+ ) {
377
435
  return attr;
378
436
  }
379
437
  return TOOL_DISPLAY.FULL;
@@ -384,10 +442,15 @@ export class AgUiChat extends HTMLElement {
384
442
  }
385
443
 
386
444
  connectedCallback(): void {
445
+ // Resolve the string table before rendering any chrome (defaults are the
446
+ // floor; `data-strings` then the `strings` property layer over them).
447
+ this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
387
448
  this.#render();
449
+ this.#drawer.setStrings(this.#strings);
388
450
  if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
389
451
  this.setAttribute("collapsed", "");
390
452
  }
453
+ this.#syncRail();
391
454
  this.#initSkills();
392
455
  void this.#fetchToolCatalog();
393
456
  this.#wireThreadStore();
@@ -396,6 +459,23 @@ export class AgUiChat extends HTMLElement {
396
459
  void this.#rehydrate();
397
460
  }
398
461
 
462
+ /** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
463
+ #readStringOverrides(): Partial<UiStrings> {
464
+ const raw = this.getAttribute("data-strings");
465
+ if (raw === null) {
466
+ return {};
467
+ }
468
+ try {
469
+ const parsed: unknown = JSON.parse(raw);
470
+ if (typeof parsed === "object" && parsed !== null) {
471
+ return parsed as Partial<UiStrings>;
472
+ }
473
+ } catch {
474
+ // Malformed JSON — fall back to the defaults rather than failing to mount.
475
+ }
476
+ return {};
477
+ }
478
+
399
479
  /**
400
480
  * Enable the composer's file-upload tray when uploads are possible — either a
401
481
  * custom {@link uploadHandler} is set or `data-attachments-url` provides the
@@ -414,6 +494,7 @@ export class AgUiChat extends HTMLElement {
414
494
  upload,
415
495
  maxBytes: this.#attachmentMaxBytes(),
416
496
  accept,
497
+ strings: this.#strings,
417
498
  });
418
499
  this.#attachSlot.appendChild(this.#attachTray.element);
419
500
  this.#fileInput.accept = accept;
@@ -578,7 +659,9 @@ export class AgUiChat extends HTMLElement {
578
659
  #applySkill(skill: Skill): void {
579
660
  const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
580
661
  if (missing.length > 0) {
581
- this.#skillHint.textContent = `“${skill.title}” needs: ${missing.join(", ")}`;
662
+ this.#skillHint.textContent = this.#strings.skillNeeds
663
+ .replace("{title}", skill.title)
664
+ .replace("{fields}", missing.join(", "));
582
665
  this.#skillHint.hidden = false;
583
666
  return;
584
667
  }
@@ -614,6 +697,7 @@ export class AgUiChat extends HTMLElement {
614
697
  this.removeAttribute("collapsed");
615
698
  }
616
699
  sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
700
+ this.#syncRail();
617
701
  this.dispatchEvent(
618
702
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
619
703
  detail: { collapsed },
@@ -646,13 +730,16 @@ export class AgUiChat extends HTMLElement {
646
730
  #resetState(): void {
647
731
  this.#client = null;
648
732
  this.#streamingBubble = null;
733
+ this.#currentGroup = null;
649
734
  this.#hidePending();
650
735
  this.#toolCards.clear();
651
736
  this.#serverSettled.clear();
652
737
  this.#initialMessages = [];
653
738
  this.#runAttachments = [];
654
739
  this.#attachTray?.clear();
655
- this.#messages.replaceChildren();
740
+ // Keep the empty-state region; everything else clears.
741
+ this.#messages.replaceChildren(this.#emptyWrap);
742
+ this.#updateEmptyState();
656
743
  }
657
744
 
658
745
  /** Switch the active conversation to an existing thread and replay it. */
@@ -791,86 +878,129 @@ export class AgUiChat extends HTMLElement {
791
878
  * Assistant content is rendered as sanitised markdown/HTML; user content
792
879
  * stays literal text (no need to parse what the user typed, and it avoids
793
880
  * rendering user-authored markup).
881
+ *
882
+ * Assistant bubbles land in the current answer group (WELL-1), opening one if
883
+ * needed; a user bubble closes the prior group and sits directly in the list
884
+ * (the well wraps the *assistant* turn, the user message precedes it).
794
885
  */
795
886
  appendMessage(role: MessageRole, content: string): HTMLDivElement {
796
887
  const bubble = document.createElement("div");
797
888
  bubble.className = `message message--${role}`;
889
+ bubble.setAttribute("part", `message message-${role}`);
798
890
  if (role === MESSAGE_ROLE.ASSISTANT) {
799
891
  bubble.innerHTML = renderMarkdown(content, { allowImages: this.allowImages });
892
+ this.#ensureGroup().appendChild(bubble);
800
893
  } else {
894
+ this.#currentGroup = null;
801
895
  bubble.textContent = content;
896
+ this.#messages.appendChild(bubble);
802
897
  }
803
- this.#messages.appendChild(bubble);
898
+ this.#updateEmptyState();
804
899
  this.#messages.scrollTop = this.#messages.scrollHeight;
805
900
  return bubble;
806
901
  }
807
902
 
903
+ /**
904
+ * The open answer group, creating and appending it on first use. Everything a
905
+ * single assistant turn renders (text, tool cards, the pending indicator)
906
+ * goes inside it, so the opt-in `data-answer-well` styling can box the whole
907
+ * turn. Idempotent across the turn's runs — it persists until {@link #handlers}'
908
+ * `onSettled` nulls it.
909
+ */
910
+ #ensureGroup(): HTMLDivElement {
911
+ if (this.#currentGroup === null) {
912
+ const group = document.createElement("div");
913
+ group.className = "answer";
914
+ group.setAttribute("part", "answer");
915
+ this.#currentGroup = group;
916
+ this.#messages.appendChild(group);
917
+ this.#updateEmptyState();
918
+ }
919
+ return this.#currentGroup;
920
+ }
921
+
808
922
  #render(): void {
809
923
  const style = document.createElement("style");
810
924
  style.textContent = STYLES;
811
925
 
812
926
  this.#chat.className = "chat";
927
+ this.#chat.setAttribute("part", "panel");
813
928
 
814
929
  const header = document.createElement("div");
815
930
  header.className = "header";
931
+ header.setAttribute("part", "header");
816
932
 
817
933
  const title = this.#title;
818
934
  title.className = "header-title";
819
- title.textContent = this.getAttribute("title-text") ?? "Assistant";
935
+ title.setAttribute("part", "title");
936
+ title.textContent = this.getAttribute("title-text") ?? this.#strings.title;
937
+
938
+ // Optional header icon: a slot (any markup) with a `data-icon-url` <img>
939
+ // fallback. Rendered only when one of the two is provided, so the header has
940
+ // no phantom gap otherwise.
941
+ if (
942
+ this.querySelector('[slot="icon"]') !== null ||
943
+ this.getAttribute("data-icon-url") !== null
944
+ ) {
945
+ header.append(this.#iconElement("icon", "icon", null));
946
+ }
947
+
948
+ // A coarse slot for host-provided header actions, between title and controls.
949
+ const headerActions = document.createElement("slot");
950
+ headerActions.name = "header-actions";
820
951
 
821
952
  const controls = document.createElement("div");
822
953
  controls.className = "header-controls";
954
+ controls.setAttribute("part", "header-controls");
823
955
 
824
- const history = document.createElement("button");
825
- history.type = "button";
826
- history.className = "header-btn header-btn--history";
827
- history.title = "Chat history";
828
- history.setAttribute("aria-label", "Chat history");
829
- history.textContent = "☰";
956
+ const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
830
957
  history.addEventListener("click", () => {
831
958
  void this.#refreshDrawer();
832
959
  this.#drawer.open();
833
960
  });
834
961
 
835
- const newChat = document.createElement("button");
836
- newChat.type = "button";
837
- newChat.className = "header-btn header-btn--new";
838
- newChat.title = "New chat";
839
- newChat.setAttribute("aria-label", "New chat");
840
- newChat.textContent = "✚";
962
+ const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
841
963
  newChat.addEventListener("click", () => this.newChat());
842
964
 
843
- const collapse = document.createElement("button");
844
- collapse.type = "button";
845
- collapse.className = "header-btn header-btn--collapse";
846
- collapse.title = "Collapse";
847
- collapse.setAttribute("aria-label", "Collapse");
848
- collapse.textContent = "—";
965
+ const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
849
966
  collapse.addEventListener("click", () => this.toggleCollapsed());
850
967
 
851
968
  controls.append(history, newChat, collapse);
852
- header.append(title, controls);
969
+ header.append(title, headerActions, controls);
853
970
 
854
971
  this.#messages.className = "messages";
972
+ this.#messages.setAttribute("part", "messages");
855
973
  // Screen readers announce streamed messages as they arrive.
856
974
  this.#messages.setAttribute("role", "log");
857
975
  this.#messages.setAttribute("aria-live", "polite");
858
- this.#messages.setAttribute("aria-label", "Conversation");
976
+ this.#messages.setAttribute("aria-label", this.#strings.conversation);
977
+
978
+ // Empty-state region: a host slot at the top of the list, hidden as soon as
979
+ // anything renders.
980
+ this.#emptyWrap.className = "empty";
981
+ this.#emptyWrap.setAttribute("part", "empty");
982
+ const emptySlot = document.createElement("slot");
983
+ emptySlot.name = "empty";
984
+ this.#emptyWrap.append(emptySlot);
985
+ this.#messages.append(this.#emptyWrap);
859
986
 
860
987
  const inputRow = document.createElement("div");
861
988
  inputRow.className = "input-row";
989
+ inputRow.setAttribute("part", "composer");
862
990
 
863
991
  this.#input.className = "input";
864
- this.#input.setAttribute("aria-label", "Message");
992
+ this.#input.setAttribute("part", "input");
993
+ this.#input.setAttribute("aria-label", this.#strings.message);
865
994
  this.#input.rows = 2;
866
- this.#input.placeholder = "Ask anything…";
995
+ this.#input.placeholder = this.#strings.inputPlaceholder;
867
996
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
868
997
  this.#input.addEventListener("input", () => this.#onInput());
869
998
 
870
999
  this.#send.className = "send";
871
1000
  this.#send.type = "button";
872
- this.#send.textContent = "Send";
873
- this.#send.setAttribute("aria-label", "Send");
1001
+ this.#send.setAttribute("part", "send");
1002
+ this.#send.textContent = this.#strings.send;
1003
+ this.#send.setAttribute("aria-label", this.#strings.send);
874
1004
  this.#send.dataset["state"] = "idle";
875
1005
  this.#send.addEventListener("click", () => {
876
1006
  // One button, two states: Send while idle, Stop while a run is in
@@ -890,9 +1020,10 @@ export class AgUiChat extends HTMLElement {
890
1020
  // whole shell (wired in #enableDragAndDrop).
891
1021
  this.#attachButton.className = "attach-btn";
892
1022
  this.#attachButton.type = "button";
1023
+ this.#attachButton.setAttribute("part", "attach-button");
893
1024
  this.#attachButton.textContent = "📎";
894
- this.#attachButton.title = "Attach files";
895
- this.#attachButton.setAttribute("aria-label", "Attach files");
1025
+ this.#attachButton.title = this.#strings.attachFiles;
1026
+ this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
896
1027
  this.#attachButton.hidden = true;
897
1028
  this.#attachButton.addEventListener("click", () => this.#fileInput.click());
898
1029
 
@@ -904,6 +1035,10 @@ export class AgUiChat extends HTMLElement {
904
1035
 
905
1036
  this.#attachSlot.className = "attachment-slot";
906
1037
 
1038
+ // A coarse footer slot below the composer.
1039
+ const footer = document.createElement("slot");
1040
+ footer.name = "footer";
1041
+
907
1042
  inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
908
1043
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
909
1044
  // the missing-placeholder hint, and the pending-attachments tray.
@@ -915,9 +1050,67 @@ export class AgUiChat extends HTMLElement {
915
1050
  this.#skillHint,
916
1051
  this.#attachSlot,
917
1052
  inputRow,
1053
+ footer,
918
1054
  this.#drawer.element,
919
1055
  );
920
- this.#root.append(style, this.#chat);
1056
+
1057
+ // The collapsed-sidebar rail: a slim edge strip (the expand affordance),
1058
+ // sibling of the panel so it survives the panel being hidden. CSS shows it
1059
+ // only for `placement="sidebar"` + `collapsed`.
1060
+ this.#rail.className = "rail";
1061
+ this.#rail.type = "button";
1062
+ this.#rail.setAttribute("part", "launcher");
1063
+ this.#rail.setAttribute("aria-label", this.#strings.expand);
1064
+ this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
1065
+ this.#rail.addEventListener("click", () => this.setCollapsed(false));
1066
+
1067
+ this.#root.append(style, this.#chat, this.#rail);
1068
+ }
1069
+
1070
+ /** Build a header control button (icon glyph + localized title/aria). */
1071
+ #headerButton(modifier: string, label: string, glyph: string): HTMLButtonElement {
1072
+ const button = document.createElement("button");
1073
+ button.type = "button";
1074
+ button.className = `header-btn header-btn--${modifier}`;
1075
+ button.setAttribute("part", `header-button ${modifier}-button`);
1076
+ button.title = label;
1077
+ button.setAttribute("aria-label", label);
1078
+ button.textContent = glyph;
1079
+ return button;
1080
+ }
1081
+
1082
+ /**
1083
+ * An icon holder wrapping a `<slot>` so a host can project custom markup; with
1084
+ * a `data-icon-url` `<img>` as the slot's fallback, or a glyph when given.
1085
+ */
1086
+ #iconElement(slotName: string, part: string, fallbackGlyph: string | null): HTMLSpanElement {
1087
+ const holder = document.createElement("span");
1088
+ holder.className = "icon-holder";
1089
+ holder.setAttribute("part", part);
1090
+ const slot = document.createElement("slot");
1091
+ slot.name = slotName;
1092
+ const iconUrl = this.getAttribute("data-icon-url");
1093
+ if (iconUrl !== null) {
1094
+ const img = document.createElement("img");
1095
+ img.className = "icon-img";
1096
+ img.src = iconUrl;
1097
+ img.alt = "";
1098
+ slot.append(img);
1099
+ } else if (fallbackGlyph !== null) {
1100
+ slot.append(document.createTextNode(fallbackGlyph));
1101
+ }
1102
+ holder.append(slot);
1103
+ return holder;
1104
+ }
1105
+
1106
+ /** Reflect the collapsed state on the rail's `aria-expanded`. */
1107
+ #syncRail(): void {
1108
+ this.#rail.setAttribute("aria-expanded", String(!this.collapsed));
1109
+ }
1110
+
1111
+ /** Hide the empty-state region once the message list holds anything else. */
1112
+ #updateEmptyState(): void {
1113
+ this.#emptyWrap.hidden = this.#messages.childElementCount > 1;
921
1114
  }
922
1115
 
923
1116
  /** Forward input changes to the skills palette and clear any stale hint. */
@@ -959,7 +1152,7 @@ export class AgUiChat extends HTMLElement {
959
1152
  /** Swap the composer button between Send (idle) and Stop (running). */
960
1153
  #setRunning(running: boolean): void {
961
1154
  this.#running = running;
962
- const label = running ? "Stop" : "Send";
1155
+ const label = running ? this.#strings.stop : this.#strings.send;
963
1156
  this.#send.textContent = label;
964
1157
  this.#send.setAttribute("aria-label", label);
965
1158
  this.#send.dataset["state"] = running ? "running" : "idle";
@@ -1018,6 +1211,7 @@ export class AgUiChat extends HTMLElement {
1018
1211
  getContext: () => this.getContext(),
1019
1212
  executeTool: (call) => this.#executeTool(call),
1020
1213
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
1214
+ connectionLostMessage: this.#strings.connectionLost,
1021
1215
  });
1022
1216
  }
1023
1217
  return this.#client;
@@ -1047,7 +1241,7 @@ export class AgUiChat extends HTMLElement {
1047
1241
  // pending indicator: nothing here triggers another client round, so it
1048
1242
  // would hang after the run ended.
1049
1243
  if (!this.#serverSettled.has(call.id)) {
1050
- card.settle(TOOL_CALL_STATUS.DONE, "No result returned.");
1244
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
1051
1245
  }
1052
1246
  return null;
1053
1247
  }
@@ -1062,12 +1256,14 @@ export class AgUiChat extends HTMLElement {
1062
1256
  this.#confirmAbort = new AbortController();
1063
1257
  const decision = requestConfirmation(this.#messages, request, {
1064
1258
  signal: this.#confirmAbort.signal,
1259
+ strings: this.#strings,
1065
1260
  });
1261
+ this.#updateEmptyState();
1066
1262
  this.#messages.scrollTop = this.#messages.scrollHeight;
1067
1263
  const accepted = await decision;
1068
1264
  this.#confirmAbort = null;
1069
1265
  if (!accepted) {
1070
- const message = "User declined the action.";
1266
+ const message = this.#strings.declinedAction;
1071
1267
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
1072
1268
  this.#showPending();
1073
1269
  return { content: message };
@@ -1085,7 +1281,7 @@ export class AgUiChat extends HTMLElement {
1085
1281
  try {
1086
1282
  const result = await tool.handler(call.args);
1087
1283
  if (navigates) {
1088
- card.settle(TOOL_CALL_STATUS.DONE, "Navigating…");
1284
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
1089
1285
  return { content: "", halt: true };
1090
1286
  }
1091
1287
  const content = JSON.stringify(result ?? null);
@@ -1108,6 +1304,10 @@ export class AgUiChat extends HTMLElement {
1108
1304
  return {
1109
1305
  onRunStart: () => {
1110
1306
  this.#setRunning(true);
1307
+ // Open the answer group on the turn's first run so the pending
1308
+ // indicator (and everything after) lands inside the well. Idempotent:
1309
+ // later rounds of the same turn reuse it.
1310
+ this.#ensureGroup();
1111
1311
  this.#showPending();
1112
1312
  },
1113
1313
  onTextDelta: (buffer) => {
@@ -1164,6 +1364,22 @@ export class AgUiChat extends HTMLElement {
1164
1364
  // The attachment manifest was for this run only; the model has read what
1165
1365
  // it needed (results now live in history).
1166
1366
  this.#runAttachments = [];
1367
+ // Belt-and-suspenders: a tool card still pending at settle (e.g. a
1368
+ // server tool whose result never streamed because the connection
1369
+ // dropped) would hang forever — settle it to the no-result fallback.
1370
+ for (const card of this.#toolCards.values()) {
1371
+ if (!card.settled) {
1372
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
1373
+ }
1374
+ }
1375
+ // Close the turn's answer group. Drop it if the turn rendered nothing
1376
+ // (e.g. a server-only round that streamed no text/card) so an opted-in
1377
+ // well leaves no empty box behind.
1378
+ if (this.#currentGroup !== null && this.#currentGroup.childElementCount === 0) {
1379
+ this.#currentGroup.remove();
1380
+ this.#updateEmptyState();
1381
+ }
1382
+ this.#currentGroup = null;
1167
1383
  },
1168
1384
  };
1169
1385
  }
@@ -1172,9 +1388,11 @@ export class AgUiChat extends HTMLElement {
1172
1388
  #appendStoppedNote(): void {
1173
1389
  const note = document.createElement("div");
1174
1390
  note.className = "stopped-note";
1391
+ note.setAttribute("part", "stopped");
1175
1392
  note.setAttribute("role", "status");
1176
- note.textContent = "⏹ Stopped";
1177
- this.#messages.appendChild(note);
1393
+ note.textContent = this.#strings.stopped;
1394
+ this.#ensureGroup().appendChild(note);
1395
+ this.#updateEmptyState();
1178
1396
  this.#messages.scrollTop = this.#messages.scrollHeight;
1179
1397
  }
1180
1398
 
@@ -1189,15 +1407,17 @@ export class AgUiChat extends HTMLElement {
1189
1407
  }
1190
1408
  const pending = document.createElement("div");
1191
1409
  pending.className = "pending";
1410
+ pending.setAttribute("part", "pending");
1192
1411
  pending.setAttribute("role", "status");
1193
- pending.setAttribute("aria-label", "Assistant is thinking…");
1412
+ pending.setAttribute("aria-label", this.#strings.thinking);
1194
1413
  for (let i = 0; i < 3; i += 1) {
1195
1414
  const dot = document.createElement("span");
1196
1415
  dot.className = "pending-dot";
1197
1416
  pending.appendChild(dot);
1198
1417
  }
1199
1418
  this.#pending = pending;
1200
- this.#messages.appendChild(pending);
1419
+ this.#ensureGroup().appendChild(pending);
1420
+ this.#updateEmptyState();
1201
1421
  this.#messages.scrollTop = this.#messages.scrollHeight;
1202
1422
  }
1203
1423
 
@@ -1238,9 +1458,10 @@ export class AgUiChat extends HTMLElement {
1238
1458
  : (this.toolSummaries[call.name] ??
1239
1459
  this.#toolCatalog[call.name] ??
1240
1460
  prettifyToolName(call.name));
1241
- const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary);
1461
+ const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary, this.#strings);
1242
1462
  this.#toolCards.set(call.id, card);
1243
- this.#messages.appendChild(card.element);
1463
+ this.#ensureGroup().appendChild(card.element);
1464
+ this.#updateEmptyState();
1244
1465
  this.#messages.scrollTop = this.#messages.scrollHeight;
1245
1466
  return card;
1246
1467
  }
@@ -93,6 +93,26 @@ export interface AgUiClientConfig extends AgUiRunInputs {
93
93
  * conversation in-memory only.
94
94
  */
95
95
  onPersist?: (messages: readonly Message[]) => void;
96
+ /**
97
+ * Error text surfaced to {@link AgUiClientHandlers.onError} when a run's
98
+ * stream closes without a terminal AG-UI event (`RUN_FINISHED`/`RUN_ERROR`) —
99
+ * a dropped connection. Defaults to `"Connection lost"`; the host passes its
100
+ * localized string.
101
+ */
102
+ connectionLostMessage?: string;
103
+ }
104
+
105
+ /**
106
+ * Raised when a run's stream closes cleanly at the transport level but never
107
+ * emits a terminal AG-UI event, so the run neither finished nor errored. Routed
108
+ * to {@link AgUiClientHandlers.onError} (it is not an abort), turning a silent
109
+ * "stuck pending" into a visible "connection lost".
110
+ */
111
+ export class ConnectionLostError extends Error {
112
+ constructor(message: string) {
113
+ super(message);
114
+ this.name = "ConnectionLostError";
115
+ }
96
116
  }
97
117
 
98
118
  /**
@@ -109,6 +129,7 @@ export class AgUiClient {
109
129
  readonly #getContext: () => Context[];
110
130
  readonly #executeTool: ExecuteTool | null;
111
131
  readonly #onPersist: (messages: readonly Message[]) => void;
132
+ readonly #connectionLostMessage: string;
112
133
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
113
134
  // a cancel between frontend-tool rounds doesn't start another round.
114
135
  #cancelled = false;
@@ -120,6 +141,7 @@ export class AgUiClient {
120
141
  this.#getContext = config.getContext ?? (() => []);
121
142
  this.#executeTool = config.executeTool ?? null;
122
143
  this.#onPersist = config.onPersist ?? (() => {});
144
+ this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
123
145
  }
124
146
 
125
147
  /** Whether a run is currently in flight. */
@@ -220,9 +242,10 @@ export class AgUiClient {
220
242
  return;
221
243
  }
222
244
  const pending: AgUiToolCall[] = [];
245
+ const runState = { terminal: false };
223
246
  await this.#agent.runAgent(
224
247
  { tools: this.#getTools(), context: this.#getContext() },
225
- this.#buildSubscriber(pending),
248
+ this.#buildSubscriber(pending, runState),
226
249
  );
227
250
  this.#onPersist(this.#agent.messages);
228
251
  // Cancelled mid-stream: the user said stop — don't execute the tool
@@ -230,6 +253,12 @@ export class AgUiClient {
230
253
  if (this.#cancelled) {
231
254
  return;
232
255
  }
256
+ // The stream resolved without RUN_FINISHED / RUN_ERROR: the transport
257
+ // dropped mid-run. Surface it as an error so the UI doesn't rest silently
258
+ // with a stuck pending indicator (caught by #run → onError).
259
+ if (!runState.terminal) {
260
+ throw new ConnectionLostError(this.#connectionLostMessage);
261
+ }
233
262
  if (this.#executeTool === null || pending.length === 0) {
234
263
  return;
235
264
  }
@@ -259,7 +288,7 @@ export class AgUiClient {
259
288
  }
260
289
  }
261
290
 
262
- #buildSubscriber(pending: AgUiToolCall[]): AgentSubscriber {
291
+ #buildSubscriber(pending: AgUiToolCall[], runState: { terminal: boolean }): AgentSubscriber {
263
292
  const h = this.#handlers;
264
293
  return {
265
294
  onRunInitialized() {
@@ -284,9 +313,11 @@ export class AgUiClient {
284
313
  h.onToolResult(event.toolCallId, event.content);
285
314
  },
286
315
  onRunErrorEvent({ event }) {
316
+ runState.terminal = true;
287
317
  h.onError(event.message);
288
318
  },
289
319
  onRunFinalized() {
320
+ runState.terminal = true;
290
321
  h.onRunEnd();
291
322
  },
292
323
  };