@artooi/ag-ui-web-component 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +140 -6
  3. package/dist/ag-ui-web-component.bundle.js +145 -47
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +16 -0
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +16 -0
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +672 -228
  12. package/dist/index.js.map +3 -3
  13. package/dist/tools/page_action_tools.d.ts +31 -0
  14. package/dist/tools/page_action_tools.d.ts.map +1 -0
  15. package/dist/ui/attachment_tray.d.ts +3 -0
  16. package/dist/ui/attachment_tray.d.ts.map +1 -1
  17. package/dist/ui/confirmation_card.d.ts +4 -1
  18. package/dist/ui/confirmation_card.d.ts.map +1 -1
  19. package/dist/ui/relative_time.d.ts +5 -3
  20. package/dist/ui/relative_time.d.ts.map +1 -1
  21. package/dist/ui/styles.d.ts +1 -1
  22. package/dist/ui/styles.d.ts.map +1 -1
  23. package/dist/ui/thread_drawer.d.ts +7 -1
  24. package/dist/ui/thread_drawer.d.ts.map +1 -1
  25. package/dist/ui/tool_call_card.d.ts +6 -2
  26. package/dist/ui/tool_call_card.d.ts.map +1 -1
  27. package/dist/ui/ui_strings.d.ts +126 -0
  28. package/dist/ui/ui_strings.d.ts.map +1 -0
  29. package/package.json +1 -1
  30. package/src/core/ag_ui_chat.ts +213 -41
  31. package/src/core/agui_client.ts +33 -2
  32. package/src/index.ts +7 -0
  33. package/src/tools/page_action_tools.ts +130 -0
  34. package/src/ui/attachment_tray.ts +13 -7
  35. package/src/ui/confirmation_card.ts +15 -5
  36. package/src/ui/relative_time.ts +15 -8
  37. package/src/ui/styles.ts +98 -0
  38. package/src/ui/thread_drawer.ts +53 -25
  39. package/src/ui/tool_call_card.ts +40 -17
  40. package/src/ui/ui_strings.ts +208 -0
  41. 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). */
@@ -261,6 +285,8 @@ export class AgUiChat extends HTMLElement {
261
285
  this.#attachButton = document.createElement("button");
262
286
  this.#fileInput = document.createElement("input");
263
287
  this.#attachSlot = document.createElement("div");
288
+ this.#rail = document.createElement("button");
289
+ this.#emptyWrap = document.createElement("div");
264
290
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
265
291
  this.#drawer = new ThreadDrawer({
266
292
  onSelect: (threadId) => {
@@ -287,8 +313,9 @@ export class AgUiChat extends HTMLElement {
287
313
 
288
314
  attributeChangedCallback(_name: string, _previous: string | null, value: string | null): void {
289
315
  // 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";
316
+ // are CSS-reactive), so update the header title directly. `#strings` is the
317
+ // resolved table once connected, the English defaults before then.
318
+ this.#title.textContent = value ?? this.#strings.title;
292
319
  }
293
320
 
294
321
  /** Declare a frontend tool the agent may call. */
@@ -341,9 +368,29 @@ export class AgUiChat extends HTMLElement {
341
368
  ];
342
369
  }
343
370
 
344
- /** All built-in (route + page) frontend tools. */
371
+ /**
372
+ * Opt-in page-action tools (`scroll_to` / `drag_and_drop`), enabled per token
373
+ * via the `data-page-actions` attribute (e.g. `"scroll,drag"`). Targets resolve
374
+ * through {@link resolvePageTarget} so a host controls the agent's interaction
375
+ * surface; absent attribute ⇒ no tools registered.
376
+ */
377
+ #pageActionTools(): ClientTool[] {
378
+ const attr = this.getAttribute("data-page-actions");
379
+ if (attr === null) {
380
+ return [];
381
+ }
382
+ const enabled = new Set(
383
+ attr
384
+ .split(",")
385
+ .map((token) => token.trim())
386
+ .filter((token) => token !== ""),
387
+ );
388
+ return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
389
+ }
390
+
391
+ /** All built-in (route + page + page-action) frontend tools. */
345
392
  #builtinTools(): ClientTool[] {
346
- return [...this.#routeTools(), ...this.#pageTools()];
393
+ return [...this.#routeTools(), ...this.#pageTools(), ...this.#pageActionTools()];
347
394
  }
348
395
 
349
396
  /** Resolve a tool by name: built-in tools first, then the registry. */
@@ -384,10 +431,15 @@ export class AgUiChat extends HTMLElement {
384
431
  }
385
432
 
386
433
  connectedCallback(): void {
434
+ // Resolve the string table before rendering any chrome (defaults are the
435
+ // floor; `data-strings` then the `strings` property layer over them).
436
+ this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
387
437
  this.#render();
438
+ this.#drawer.setStrings(this.#strings);
388
439
  if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
389
440
  this.setAttribute("collapsed", "");
390
441
  }
442
+ this.#syncRail();
391
443
  this.#initSkills();
392
444
  void this.#fetchToolCatalog();
393
445
  this.#wireThreadStore();
@@ -396,6 +448,23 @@ export class AgUiChat extends HTMLElement {
396
448
  void this.#rehydrate();
397
449
  }
398
450
 
451
+ /** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
452
+ #readStringOverrides(): Partial<UiStrings> {
453
+ const raw = this.getAttribute("data-strings");
454
+ if (raw === null) {
455
+ return {};
456
+ }
457
+ try {
458
+ const parsed: unknown = JSON.parse(raw);
459
+ if (typeof parsed === "object" && parsed !== null) {
460
+ return parsed as Partial<UiStrings>;
461
+ }
462
+ } catch {
463
+ // Malformed JSON — fall back to the defaults rather than failing to mount.
464
+ }
465
+ return {};
466
+ }
467
+
399
468
  /**
400
469
  * Enable the composer's file-upload tray when uploads are possible — either a
401
470
  * custom {@link uploadHandler} is set or `data-attachments-url` provides the
@@ -414,6 +483,7 @@ export class AgUiChat extends HTMLElement {
414
483
  upload,
415
484
  maxBytes: this.#attachmentMaxBytes(),
416
485
  accept,
486
+ strings: this.#strings,
417
487
  });
418
488
  this.#attachSlot.appendChild(this.#attachTray.element);
419
489
  this.#fileInput.accept = accept;
@@ -578,7 +648,9 @@ export class AgUiChat extends HTMLElement {
578
648
  #applySkill(skill: Skill): void {
579
649
  const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
580
650
  if (missing.length > 0) {
581
- this.#skillHint.textContent = `“${skill.title}” needs: ${missing.join(", ")}`;
651
+ this.#skillHint.textContent = this.#strings.skillNeeds
652
+ .replace("{title}", skill.title)
653
+ .replace("{fields}", missing.join(", "));
582
654
  this.#skillHint.hidden = false;
583
655
  return;
584
656
  }
@@ -614,6 +686,7 @@ export class AgUiChat extends HTMLElement {
614
686
  this.removeAttribute("collapsed");
615
687
  }
616
688
  sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
689
+ this.#syncRail();
617
690
  this.dispatchEvent(
618
691
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
619
692
  detail: { collapsed },
@@ -652,7 +725,9 @@ export class AgUiChat extends HTMLElement {
652
725
  this.#initialMessages = [];
653
726
  this.#runAttachments = [];
654
727
  this.#attachTray?.clear();
655
- this.#messages.replaceChildren();
728
+ // Keep the empty-state region; everything else clears.
729
+ this.#messages.replaceChildren(this.#emptyWrap);
730
+ this.#updateEmptyState();
656
731
  }
657
732
 
658
733
  /** Switch the active conversation to an existing thread and replay it. */
@@ -795,12 +870,14 @@ export class AgUiChat extends HTMLElement {
795
870
  appendMessage(role: MessageRole, content: string): HTMLDivElement {
796
871
  const bubble = document.createElement("div");
797
872
  bubble.className = `message message--${role}`;
873
+ bubble.setAttribute("part", `message message-${role}`);
798
874
  if (role === MESSAGE_ROLE.ASSISTANT) {
799
875
  bubble.innerHTML = renderMarkdown(content, { allowImages: this.allowImages });
800
876
  } else {
801
877
  bubble.textContent = content;
802
878
  }
803
879
  this.#messages.appendChild(bubble);
880
+ this.#updateEmptyState();
804
881
  this.#messages.scrollTop = this.#messages.scrollHeight;
805
882
  return bubble;
806
883
  }
@@ -810,67 +887,83 @@ export class AgUiChat extends HTMLElement {
810
887
  style.textContent = STYLES;
811
888
 
812
889
  this.#chat.className = "chat";
890
+ this.#chat.setAttribute("part", "panel");
813
891
 
814
892
  const header = document.createElement("div");
815
893
  header.className = "header";
894
+ header.setAttribute("part", "header");
816
895
 
817
896
  const title = this.#title;
818
897
  title.className = "header-title";
819
- title.textContent = this.getAttribute("title-text") ?? "Assistant";
898
+ title.setAttribute("part", "title");
899
+ title.textContent = this.getAttribute("title-text") ?? this.#strings.title;
900
+
901
+ // Optional header icon: a slot (any markup) with a `data-icon-url` <img>
902
+ // fallback. Rendered only when one of the two is provided, so the header has
903
+ // no phantom gap otherwise.
904
+ if (
905
+ this.querySelector('[slot="icon"]') !== null ||
906
+ this.getAttribute("data-icon-url") !== null
907
+ ) {
908
+ header.append(this.#iconElement("icon", "icon", null));
909
+ }
910
+
911
+ // A coarse slot for host-provided header actions, between title and controls.
912
+ const headerActions = document.createElement("slot");
913
+ headerActions.name = "header-actions";
820
914
 
821
915
  const controls = document.createElement("div");
822
916
  controls.className = "header-controls";
917
+ controls.setAttribute("part", "header-controls");
823
918
 
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 = "☰";
919
+ const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
830
920
  history.addEventListener("click", () => {
831
921
  void this.#refreshDrawer();
832
922
  this.#drawer.open();
833
923
  });
834
924
 
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 = "✚";
925
+ const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
841
926
  newChat.addEventListener("click", () => this.newChat());
842
927
 
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 = "—";
928
+ const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
849
929
  collapse.addEventListener("click", () => this.toggleCollapsed());
850
930
 
851
931
  controls.append(history, newChat, collapse);
852
- header.append(title, controls);
932
+ header.append(title, headerActions, controls);
853
933
 
854
934
  this.#messages.className = "messages";
935
+ this.#messages.setAttribute("part", "messages");
855
936
  // Screen readers announce streamed messages as they arrive.
856
937
  this.#messages.setAttribute("role", "log");
857
938
  this.#messages.setAttribute("aria-live", "polite");
858
- this.#messages.setAttribute("aria-label", "Conversation");
939
+ this.#messages.setAttribute("aria-label", this.#strings.conversation);
940
+
941
+ // Empty-state region: a host slot at the top of the list, hidden as soon as
942
+ // anything renders.
943
+ this.#emptyWrap.className = "empty";
944
+ this.#emptyWrap.setAttribute("part", "empty");
945
+ const emptySlot = document.createElement("slot");
946
+ emptySlot.name = "empty";
947
+ this.#emptyWrap.append(emptySlot);
948
+ this.#messages.append(this.#emptyWrap);
859
949
 
860
950
  const inputRow = document.createElement("div");
861
951
  inputRow.className = "input-row";
952
+ inputRow.setAttribute("part", "composer");
862
953
 
863
954
  this.#input.className = "input";
864
- this.#input.setAttribute("aria-label", "Message");
955
+ this.#input.setAttribute("part", "input");
956
+ this.#input.setAttribute("aria-label", this.#strings.message);
865
957
  this.#input.rows = 2;
866
- this.#input.placeholder = "Ask anything…";
958
+ this.#input.placeholder = this.#strings.inputPlaceholder;
867
959
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
868
960
  this.#input.addEventListener("input", () => this.#onInput());
869
961
 
870
962
  this.#send.className = "send";
871
963
  this.#send.type = "button";
872
- this.#send.textContent = "Send";
873
- this.#send.setAttribute("aria-label", "Send");
964
+ this.#send.setAttribute("part", "send");
965
+ this.#send.textContent = this.#strings.send;
966
+ this.#send.setAttribute("aria-label", this.#strings.send);
874
967
  this.#send.dataset["state"] = "idle";
875
968
  this.#send.addEventListener("click", () => {
876
969
  // One button, two states: Send while idle, Stop while a run is in
@@ -890,9 +983,10 @@ export class AgUiChat extends HTMLElement {
890
983
  // whole shell (wired in #enableDragAndDrop).
891
984
  this.#attachButton.className = "attach-btn";
892
985
  this.#attachButton.type = "button";
986
+ this.#attachButton.setAttribute("part", "attach-button");
893
987
  this.#attachButton.textContent = "📎";
894
- this.#attachButton.title = "Attach files";
895
- this.#attachButton.setAttribute("aria-label", "Attach files");
988
+ this.#attachButton.title = this.#strings.attachFiles;
989
+ this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
896
990
  this.#attachButton.hidden = true;
897
991
  this.#attachButton.addEventListener("click", () => this.#fileInput.click());
898
992
 
@@ -904,6 +998,10 @@ export class AgUiChat extends HTMLElement {
904
998
 
905
999
  this.#attachSlot.className = "attachment-slot";
906
1000
 
1001
+ // A coarse footer slot below the composer.
1002
+ const footer = document.createElement("slot");
1003
+ footer.name = "footer";
1004
+
907
1005
  inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
908
1006
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
909
1007
  // the missing-placeholder hint, and the pending-attachments tray.
@@ -915,9 +1013,67 @@ export class AgUiChat extends HTMLElement {
915
1013
  this.#skillHint,
916
1014
  this.#attachSlot,
917
1015
  inputRow,
1016
+ footer,
918
1017
  this.#drawer.element,
919
1018
  );
920
- this.#root.append(style, this.#chat);
1019
+
1020
+ // The collapsed-sidebar rail: a slim edge strip (the expand affordance),
1021
+ // sibling of the panel so it survives the panel being hidden. CSS shows it
1022
+ // only for `placement="sidebar"` + `collapsed`.
1023
+ this.#rail.className = "rail";
1024
+ this.#rail.type = "button";
1025
+ this.#rail.setAttribute("part", "launcher");
1026
+ this.#rail.setAttribute("aria-label", this.#strings.expand);
1027
+ this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
1028
+ this.#rail.addEventListener("click", () => this.setCollapsed(false));
1029
+
1030
+ this.#root.append(style, this.#chat, this.#rail);
1031
+ }
1032
+
1033
+ /** Build a header control button (icon glyph + localized title/aria). */
1034
+ #headerButton(modifier: string, label: string, glyph: string): HTMLButtonElement {
1035
+ const button = document.createElement("button");
1036
+ button.type = "button";
1037
+ button.className = `header-btn header-btn--${modifier}`;
1038
+ button.setAttribute("part", `header-button ${modifier}-button`);
1039
+ button.title = label;
1040
+ button.setAttribute("aria-label", label);
1041
+ button.textContent = glyph;
1042
+ return button;
1043
+ }
1044
+
1045
+ /**
1046
+ * An icon holder wrapping a `<slot>` so a host can project custom markup; with
1047
+ * a `data-icon-url` `<img>` as the slot's fallback, or a glyph when given.
1048
+ */
1049
+ #iconElement(slotName: string, part: string, fallbackGlyph: string | null): HTMLSpanElement {
1050
+ const holder = document.createElement("span");
1051
+ holder.className = "icon-holder";
1052
+ holder.setAttribute("part", part);
1053
+ const slot = document.createElement("slot");
1054
+ slot.name = slotName;
1055
+ const iconUrl = this.getAttribute("data-icon-url");
1056
+ if (iconUrl !== null) {
1057
+ const img = document.createElement("img");
1058
+ img.className = "icon-img";
1059
+ img.src = iconUrl;
1060
+ img.alt = "";
1061
+ slot.append(img);
1062
+ } else if (fallbackGlyph !== null) {
1063
+ slot.append(document.createTextNode(fallbackGlyph));
1064
+ }
1065
+ holder.append(slot);
1066
+ return holder;
1067
+ }
1068
+
1069
+ /** Reflect the collapsed state on the rail's `aria-expanded`. */
1070
+ #syncRail(): void {
1071
+ this.#rail.setAttribute("aria-expanded", String(!this.collapsed));
1072
+ }
1073
+
1074
+ /** Hide the empty-state region once the message list holds anything else. */
1075
+ #updateEmptyState(): void {
1076
+ this.#emptyWrap.hidden = this.#messages.childElementCount > 1;
921
1077
  }
922
1078
 
923
1079
  /** Forward input changes to the skills palette and clear any stale hint. */
@@ -959,7 +1115,7 @@ export class AgUiChat extends HTMLElement {
959
1115
  /** Swap the composer button between Send (idle) and Stop (running). */
960
1116
  #setRunning(running: boolean): void {
961
1117
  this.#running = running;
962
- const label = running ? "Stop" : "Send";
1118
+ const label = running ? this.#strings.stop : this.#strings.send;
963
1119
  this.#send.textContent = label;
964
1120
  this.#send.setAttribute("aria-label", label);
965
1121
  this.#send.dataset["state"] = running ? "running" : "idle";
@@ -1018,6 +1174,7 @@ export class AgUiChat extends HTMLElement {
1018
1174
  getContext: () => this.getContext(),
1019
1175
  executeTool: (call) => this.#executeTool(call),
1020
1176
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
1177
+ connectionLostMessage: this.#strings.connectionLost,
1021
1178
  });
1022
1179
  }
1023
1180
  return this.#client;
@@ -1047,7 +1204,7 @@ export class AgUiChat extends HTMLElement {
1047
1204
  // pending indicator: nothing here triggers another client round, so it
1048
1205
  // would hang after the run ended.
1049
1206
  if (!this.#serverSettled.has(call.id)) {
1050
- card.settle(TOOL_CALL_STATUS.DONE, "No result returned.");
1207
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
1051
1208
  }
1052
1209
  return null;
1053
1210
  }
@@ -1062,12 +1219,14 @@ export class AgUiChat extends HTMLElement {
1062
1219
  this.#confirmAbort = new AbortController();
1063
1220
  const decision = requestConfirmation(this.#messages, request, {
1064
1221
  signal: this.#confirmAbort.signal,
1222
+ strings: this.#strings,
1065
1223
  });
1224
+ this.#updateEmptyState();
1066
1225
  this.#messages.scrollTop = this.#messages.scrollHeight;
1067
1226
  const accepted = await decision;
1068
1227
  this.#confirmAbort = null;
1069
1228
  if (!accepted) {
1070
- const message = "User declined the action.";
1229
+ const message = this.#strings.declinedAction;
1071
1230
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
1072
1231
  this.#showPending();
1073
1232
  return { content: message };
@@ -1085,7 +1244,7 @@ export class AgUiChat extends HTMLElement {
1085
1244
  try {
1086
1245
  const result = await tool.handler(call.args);
1087
1246
  if (navigates) {
1088
- card.settle(TOOL_CALL_STATUS.DONE, "Navigating…");
1247
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
1089
1248
  return { content: "", halt: true };
1090
1249
  }
1091
1250
  const content = JSON.stringify(result ?? null);
@@ -1164,6 +1323,14 @@ export class AgUiChat extends HTMLElement {
1164
1323
  // The attachment manifest was for this run only; the model has read what
1165
1324
  // it needed (results now live in history).
1166
1325
  this.#runAttachments = [];
1326
+ // Belt-and-suspenders: a tool card still pending at settle (e.g. a
1327
+ // server tool whose result never streamed because the connection
1328
+ // dropped) would hang forever — settle it to the no-result fallback.
1329
+ for (const card of this.#toolCards.values()) {
1330
+ if (!card.settled) {
1331
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
1332
+ }
1333
+ }
1167
1334
  },
1168
1335
  };
1169
1336
  }
@@ -1172,9 +1339,11 @@ export class AgUiChat extends HTMLElement {
1172
1339
  #appendStoppedNote(): void {
1173
1340
  const note = document.createElement("div");
1174
1341
  note.className = "stopped-note";
1342
+ note.setAttribute("part", "stopped");
1175
1343
  note.setAttribute("role", "status");
1176
- note.textContent = "⏹ Stopped";
1344
+ note.textContent = this.#strings.stopped;
1177
1345
  this.#messages.appendChild(note);
1346
+ this.#updateEmptyState();
1178
1347
  this.#messages.scrollTop = this.#messages.scrollHeight;
1179
1348
  }
1180
1349
 
@@ -1189,8 +1358,9 @@ export class AgUiChat extends HTMLElement {
1189
1358
  }
1190
1359
  const pending = document.createElement("div");
1191
1360
  pending.className = "pending";
1361
+ pending.setAttribute("part", "pending");
1192
1362
  pending.setAttribute("role", "status");
1193
- pending.setAttribute("aria-label", "Assistant is thinking…");
1363
+ pending.setAttribute("aria-label", this.#strings.thinking);
1194
1364
  for (let i = 0; i < 3; i += 1) {
1195
1365
  const dot = document.createElement("span");
1196
1366
  dot.className = "pending-dot";
@@ -1198,6 +1368,7 @@ export class AgUiChat extends HTMLElement {
1198
1368
  }
1199
1369
  this.#pending = pending;
1200
1370
  this.#messages.appendChild(pending);
1371
+ this.#updateEmptyState();
1201
1372
  this.#messages.scrollTop = this.#messages.scrollHeight;
1202
1373
  }
1203
1374
 
@@ -1238,9 +1409,10 @@ export class AgUiChat extends HTMLElement {
1238
1409
  : (this.toolSummaries[call.name] ??
1239
1410
  this.#toolCatalog[call.name] ??
1240
1411
  prettifyToolName(call.name));
1241
- const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary);
1412
+ const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary, this.#strings);
1242
1413
  this.#toolCards.set(call.id, card);
1243
1414
  this.#messages.appendChild(card.element);
1415
+ this.#updateEmptyState();
1244
1416
  this.#messages.scrollTop = this.#messages.scrollHeight;
1245
1417
  return card;
1246
1418
  }
@@ -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
  };
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  type AgUiClientHandlers,
26
26
  type AgUiRunInputs,
27
27
  type AgUiToolCall,
28
+ ConnectionLostError,
28
29
  type ExecuteTool,
29
30
  type ToolExecution,
30
31
  } from "./core/agui_client.js";
@@ -78,6 +79,11 @@ export type { Skill } from "./skills/skill.js";
78
79
  export { type ClientTool, ClientToolRegistry } from "./tools/client_tool_registry.js";
79
80
  export { isDestructive } from "./tools/is_destructive.js";
80
81
  export { isNavigates } from "./tools/is_navigates.js";
82
+ export {
83
+ createPageActionTools,
84
+ PAGE_ACTIONS,
85
+ type ResolvePageTarget,
86
+ } from "./tools/page_action_tools.js";
81
87
  export { createPageMapContext, type PageMap } from "./tools/page_map.js";
82
88
  export { parseToolCatalog, type ToolCatalogEntry } from "./tools/parse_tool_catalog.js";
83
89
  export {
@@ -100,4 +106,5 @@ export {
100
106
  type ToolCallStatus,
101
107
  type ToolDisplayMode,
102
108
  } from "./ui/tool_call_card.js";
109
+ export { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "./ui/ui_strings.js";
103
110
  export { VERSION } from "./version.js";