@artooi/ag-ui-web-component 0.5.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 (53) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +208 -7
  3. package/dist/ag-ui-web-component.bundle.js +268 -58
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +16 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +33 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +23 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +35 -0
  12. package/dist/core/attachment.d.ts.map +1 -0
  13. package/dist/core/upload_attachment.d.ts +32 -0
  14. package/dist/core/upload_attachment.d.ts.map +1 -0
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1247 -245
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/page_action_tools.d.ts +31 -0
  20. package/dist/tools/page_action_tools.d.ts.map +1 -0
  21. package/dist/ui/attachment_chips.d.ts +13 -0
  22. package/dist/ui/attachment_chips.d.ts.map +1 -0
  23. package/dist/ui/attachment_tray.d.ts +45 -0
  24. package/dist/ui/attachment_tray.d.ts.map +1 -0
  25. package/dist/ui/confirmation_card.d.ts +4 -1
  26. package/dist/ui/confirmation_card.d.ts.map +1 -1
  27. package/dist/ui/relative_time.d.ts +5 -3
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/styles.d.ts +1 -1
  30. package/dist/ui/styles.d.ts.map +1 -1
  31. package/dist/ui/thread_drawer.d.ts +7 -1
  32. package/dist/ui/thread_drawer.d.ts.map +1 -1
  33. package/dist/ui/tool_call_card.d.ts +6 -2
  34. package/dist/ui/tool_call_card.d.ts.map +1 -1
  35. package/dist/ui/ui_strings.d.ts +126 -0
  36. package/dist/ui/ui_strings.d.ts.map +1 -0
  37. package/package.json +1 -1
  38. package/src/constants.ts +18 -0
  39. package/src/core/ag_ui_chat.ts +389 -51
  40. package/src/core/agui_client.ts +48 -4
  41. package/src/core/attachment.ts +39 -0
  42. package/src/core/upload_attachment.ts +113 -0
  43. package/src/index.ts +13 -0
  44. package/src/tools/page_action_tools.ts +130 -0
  45. package/src/ui/attachment_chips.ts +68 -0
  46. package/src/ui/attachment_tray.ts +243 -0
  47. package/src/ui/confirmation_card.ts +15 -5
  48. package/src/ui/relative_time.ts +15 -8
  49. package/src/ui/styles.ts +208 -0
  50. package/src/ui/thread_drawer.ts +53 -25
  51. package/src/ui/tool_call_card.ts +40 -17
  52. package/src/ui/ui_strings.ts +208 -0
  53. package/src/version.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { Context, Message, Tool } from "@ag-ui/core";
2
2
  import {
3
+ DEFAULT_ATTACHMENT_MAX_BYTES,
3
4
  MESSAGE_ROLE,
4
5
  SUBMIT_EVENT,
5
6
  TOGGLE_EVENT,
@@ -14,10 +15,13 @@ import type { Skill } from "../skills/skill.js";
14
15
  import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
15
16
  import { isDestructive } from "../tools/is_destructive.js";
16
17
  import { isNavigates } from "../tools/is_navigates.js";
18
+ import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
17
19
  import { createPageMapContext, type PageMap } from "../tools/page_map.js";
18
20
  import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
19
21
  import { createRouteTools, type RouteMap } from "../tools/route_map.js";
20
22
  import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
23
+ import { renderAttachmentChips } from "../ui/attachment_chips.js";
24
+ import { AttachmentTray } from "../ui/attachment_tray.js";
21
25
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
22
26
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
23
27
  import { renderMarkdown } from "../ui/render_markdown.js";
@@ -26,12 +30,14 @@ import { SkillsMenu } from "../ui/skills_menu.js";
26
30
  import { STYLES } from "../ui/styles.js";
27
31
  import { ThreadDrawer } from "../ui/thread_drawer.js";
28
32
  import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
33
+ import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
29
34
  import {
30
35
  AgUiClient,
31
36
  type AgUiClientHandlers,
32
37
  type AgUiToolCall,
33
38
  type ToolExecution,
34
39
  } from "./agui_client.js";
40
+ import { type AttachmentRef, messageAttachments } from "./attachment.js";
35
41
  import {
36
42
  type ClientConversationStore,
37
43
  type NavigationCheckpoint,
@@ -39,6 +45,7 @@ import {
39
45
  } from "./conversation_store.js";
40
46
  import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
41
47
  import { RemoteConversationStore } from "./remote_conversation_store.js";
48
+ import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
42
49
 
43
50
  /** The role a rendered chat message takes. */
44
51
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -46,6 +53,8 @@ export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
46
53
  /** `detail` shape of the {@link SUBMIT_EVENT} CustomEvent. */
47
54
  export interface SubmitDetail {
48
55
  readonly content: string;
56
+ /** Durable refs for the files attached to this message (empty when none). */
57
+ readonly attachments: readonly AttachmentRef[];
49
58
  }
50
59
 
51
60
  /** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
@@ -114,9 +123,14 @@ export class AgUiChat extends HTMLElement {
114
123
 
115
124
  /**
116
125
  * Per-run context provider. Defaults to the compact page map (when a
117
- * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on).
126
+ * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on)
127
+ * plus a one-line manifest of the files attached to the message being sent,
128
+ * so the agent knows which `read_attachment` ids are available.
118
129
  */
119
- getContext: () => Context[] = () => createPageMapContext(this.getPageMap, this.autoInjectPageMap);
130
+ getContext: () => Context[] = () => [
131
+ ...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
132
+ ...this.#attachmentContext(),
133
+ ];
120
134
 
121
135
  /**
122
136
  * Navigable routes the agent can jump to via the built-in `route.*` tools.
@@ -144,6 +158,17 @@ export class AgUiChat extends HTMLElement {
144
158
  */
145
159
  conversationStore: ClientConversationStore = new SessionStorageStore();
146
160
 
161
+ /**
162
+ * How attached files are uploaded. `null` (default) uses the built-in
163
+ * multipart `POST` to `data-attachments-url`. Set a custom
164
+ * {@link UploadHandler} — `(file, onProgress) => Promise<AttachmentRef>` — to
165
+ * swap the transport (e.g. a `tus-js-client` resumable adapter or
166
+ * direct-to-S3 multipart) without changing the tray, the chips, or the AG-UI
167
+ * wire (refs are transport-agnostic). When set, the 📎 affordance appears even
168
+ * with no `data-attachments-url`; the handler owns its own endpoint + headers.
169
+ */
170
+ uploadHandler: UploadHandler | null = null;
171
+
147
172
  /**
148
173
  * Builds the tool result a navigating tool resumes with after the page
149
174
  * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
@@ -171,12 +196,30 @@ export class AgUiChat extends HTMLElement {
171
196
  */
172
197
  toolSummaries: Record<string, string> = {};
173
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
+
174
215
  /**
175
216
  * Card labels fetched from a server tool catalog (`data-tools-url`), keyed by
176
217
  * tool name. The base layer behind {@link toolSummaries}: an explicit entry in
177
218
  * `toolSummaries` wins, this fills the rest. Populated once on connect.
178
219
  */
179
220
  #toolCatalog: Record<string, string> = {};
221
+ /** The resolved string table (defaults ← `data-strings` ← `strings`). */
222
+ #strings: UiStrings = DEFAULT_UI_STRINGS;
180
223
 
181
224
  readonly #toolRegistry = new ClientToolRegistry();
182
225
  /** Tool-call cards awaiting execution, keyed by call id. */
@@ -196,6 +239,18 @@ export class AgUiChat extends HTMLElement {
196
239
  readonly #skillsMenu: SkillsMenu;
197
240
  readonly #drawer: ThreadDrawer;
198
241
  readonly #skillHint: HTMLDivElement;
242
+ /** File-picker button + hidden input + tray slot; the tray mounts on connect. */
243
+ readonly #attachButton: HTMLButtonElement;
244
+ readonly #fileInput: HTMLInputElement;
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;
250
+ /** Upload tray; created on connect only when `data-attachments-url` is set. */
251
+ #attachTray: AttachmentTray | null = null;
252
+ /** Refs attached to the message currently being sent (the context manifest). */
253
+ #runAttachments: readonly AttachmentRef[] = [];
199
254
 
200
255
  #client: AgUiClient | null = null;
201
256
  // Whether an interaction is in flight (first onRunStart → onSettled). Drives
@@ -227,6 +282,11 @@ export class AgUiChat extends HTMLElement {
227
282
  this.#send = document.createElement("button");
228
283
  this.#title = document.createElement("span");
229
284
  this.#skillHint = document.createElement("div");
285
+ this.#attachButton = document.createElement("button");
286
+ this.#fileInput = document.createElement("input");
287
+ this.#attachSlot = document.createElement("div");
288
+ this.#rail = document.createElement("button");
289
+ this.#emptyWrap = document.createElement("div");
230
290
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
231
291
  this.#drawer = new ThreadDrawer({
232
292
  onSelect: (threadId) => {
@@ -253,8 +313,9 @@ export class AgUiChat extends HTMLElement {
253
313
 
254
314
  attributeChangedCallback(_name: string, _previous: string | null, value: string | null): void {
255
315
  // Only `title-text` is observed (other attributes are read at use-time or
256
- // are CSS-reactive), so update the header title directly.
257
- 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;
258
319
  }
259
320
 
260
321
  /** Declare a frontend tool the agent may call. */
@@ -307,9 +368,29 @@ export class AgUiChat extends HTMLElement {
307
368
  ];
308
369
  }
309
370
 
310
- /** 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. */
311
392
  #builtinTools(): ClientTool[] {
312
- return [...this.#routeTools(), ...this.#pageTools()];
393
+ return [...this.#routeTools(), ...this.#pageTools(), ...this.#pageActionTools()];
313
394
  }
314
395
 
315
396
  /** Resolve a tool by name: built-in tools first, then the registry. */
@@ -350,17 +431,133 @@ export class AgUiChat extends HTMLElement {
350
431
  }
351
432
 
352
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 });
353
437
  this.#render();
438
+ this.#drawer.setStrings(this.#strings);
354
439
  if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
355
440
  this.setAttribute("collapsed", "");
356
441
  }
442
+ this.#syncRail();
357
443
  this.#initSkills();
358
444
  void this.#fetchToolCatalog();
359
445
  this.#wireThreadStore();
446
+ this.#wireAttachments();
360
447
  this.#threadId = this.conversationStore.threadId();
361
448
  void this.#rehydrate();
362
449
  }
363
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
+
468
+ /**
469
+ * Enable the composer's file-upload tray when uploads are possible — either a
470
+ * custom {@link uploadHandler} is set or `data-attachments-url` provides the
471
+ * built-in multipart endpoint: reveal the 📎 button, wire the hidden file
472
+ * input + drag-and-drop, and mount the tray. With neither, the affordance
473
+ * stays hidden and the chat degrades to text-only.
474
+ */
475
+ #wireAttachments(): void {
476
+ const url = this.getAttribute("data-attachments-url");
477
+ const upload = this.uploadHandler ?? this.#defaultUploadHandler(url);
478
+ if (upload === null) {
479
+ return;
480
+ }
481
+ const accept = this.getAttribute("data-attachment-accept") ?? "";
482
+ this.#attachTray = new AttachmentTray({
483
+ upload,
484
+ maxBytes: this.#attachmentMaxBytes(),
485
+ accept,
486
+ strings: this.#strings,
487
+ });
488
+ this.#attachSlot.appendChild(this.#attachTray.element);
489
+ this.#fileInput.accept = accept;
490
+ this.#attachButton.hidden = false;
491
+ this.#enableDragAndDrop();
492
+ }
493
+
494
+ /** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
495
+ #defaultUploadHandler(url: string | null): UploadHandler | null {
496
+ if (url === null) {
497
+ return null;
498
+ }
499
+ return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
500
+ }
501
+
502
+ /** The client-side upload size cap from `data-attachment-max-bytes`. */
503
+ #attachmentMaxBytes(): number {
504
+ const attr = this.getAttribute("data-attachment-max-bytes");
505
+ if (attr === null) {
506
+ return DEFAULT_ATTACHMENT_MAX_BYTES;
507
+ }
508
+ const parsed = Number.parseInt(attr, 10);
509
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_ATTACHMENT_MAX_BYTES;
510
+ }
511
+
512
+ /** Queue every file from the picker into the tray, then reset the input. */
513
+ #onFilesPicked(): void {
514
+ const files = this.#fileInput.files;
515
+ if (files !== null) {
516
+ for (const file of Array.from(files)) {
517
+ this.#attachTray?.add(file);
518
+ }
519
+ }
520
+ // Reset so re-picking the same file fires `change` again.
521
+ this.#fileInput.value = "";
522
+ }
523
+
524
+ /** Accept files dropped anywhere on the chat shell into the tray. */
525
+ #enableDragAndDrop(): void {
526
+ this.#chat.addEventListener("dragover", (event) => {
527
+ event.preventDefault();
528
+ this.#chat.classList.add("chat--dragover");
529
+ });
530
+ this.#chat.addEventListener("dragleave", () => {
531
+ this.#chat.classList.remove("chat--dragover");
532
+ });
533
+ this.#chat.addEventListener("drop", (event) => {
534
+ event.preventDefault();
535
+ this.#chat.classList.remove("chat--dragover");
536
+ const files = event.dataTransfer?.files;
537
+ if (files !== undefined) {
538
+ for (const file of Array.from(files)) {
539
+ this.#attachTray?.add(file);
540
+ }
541
+ }
542
+ });
543
+ }
544
+
545
+ /** The one-line manifest of the message's attachments, for the run context. */
546
+ #attachmentContext(): Context[] {
547
+ if (this.#runAttachments.length === 0) {
548
+ return [];
549
+ }
550
+ const lines = this.#runAttachments.map(
551
+ (ref) => `- ${ref.name} (id: ${ref.id}, ${ref.mime || "unknown type"}, ${ref.size} bytes)`,
552
+ );
553
+ return [
554
+ {
555
+ description: "Files the user attached to this message",
556
+ value: `${lines.join("\n")}\nUse the read_attachment tool with an id to read a file's contents.`,
557
+ },
558
+ ];
559
+ }
560
+
364
561
  /**
365
562
  * When `data-threads-url` is set, route thread enumeration / load / rename /
366
563
  * delete through that server endpoint (wrapping the current store as the
@@ -451,7 +648,9 @@ export class AgUiChat extends HTMLElement {
451
648
  #applySkill(skill: Skill): void {
452
649
  const { text, missing } = fillTemplate(skill.prompt, this.skillContext());
453
650
  if (missing.length > 0) {
454
- 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(", "));
455
654
  this.#skillHint.hidden = false;
456
655
  return;
457
656
  }
@@ -487,6 +686,7 @@ export class AgUiChat extends HTMLElement {
487
686
  this.removeAttribute("collapsed");
488
687
  }
489
688
  sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
689
+ this.#syncRail();
490
690
  this.dispatchEvent(
491
691
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
492
692
  detail: { collapsed },
@@ -523,7 +723,11 @@ export class AgUiChat extends HTMLElement {
523
723
  this.#toolCards.clear();
524
724
  this.#serverSettled.clear();
525
725
  this.#initialMessages = [];
526
- this.#messages.replaceChildren();
726
+ this.#runAttachments = [];
727
+ this.#attachTray?.clear();
728
+ // Keep the empty-state region; everything else clears.
729
+ this.#messages.replaceChildren(this.#emptyWrap);
730
+ this.#updateEmptyState();
527
731
  }
528
732
 
529
733
  /** Switch the active conversation to an existing thread and replay it. */
@@ -587,8 +791,12 @@ export class AgUiChat extends HTMLElement {
587
791
  #renderHistoricMessage(message: Message): void {
588
792
  const text = typeof message.content === "string" ? message.content : "";
589
793
  if (message.role === MESSAGE_ROLE.USER) {
590
- if (text !== "") {
591
- this.appendMessage(MESSAGE_ROLE.USER, text);
794
+ const attachments = messageAttachments(message);
795
+ if (text !== "" || attachments.length > 0) {
796
+ const bubble = this.appendMessage(MESSAGE_ROLE.USER, text);
797
+ if (attachments.length > 0) {
798
+ bubble.appendChild(renderAttachmentChips(attachments));
799
+ }
592
800
  }
593
801
  return;
594
802
  }
@@ -662,12 +870,14 @@ export class AgUiChat extends HTMLElement {
662
870
  appendMessage(role: MessageRole, content: string): HTMLDivElement {
663
871
  const bubble = document.createElement("div");
664
872
  bubble.className = `message message--${role}`;
873
+ bubble.setAttribute("part", `message message-${role}`);
665
874
  if (role === MESSAGE_ROLE.ASSISTANT) {
666
875
  bubble.innerHTML = renderMarkdown(content, { allowImages: this.allowImages });
667
876
  } else {
668
877
  bubble.textContent = content;
669
878
  }
670
879
  this.#messages.appendChild(bubble);
880
+ this.#updateEmptyState();
671
881
  this.#messages.scrollTop = this.#messages.scrollHeight;
672
882
  return bubble;
673
883
  }
@@ -677,67 +887,83 @@ export class AgUiChat extends HTMLElement {
677
887
  style.textContent = STYLES;
678
888
 
679
889
  this.#chat.className = "chat";
890
+ this.#chat.setAttribute("part", "panel");
680
891
 
681
892
  const header = document.createElement("div");
682
893
  header.className = "header";
894
+ header.setAttribute("part", "header");
683
895
 
684
896
  const title = this.#title;
685
897
  title.className = "header-title";
686
- 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";
687
914
 
688
915
  const controls = document.createElement("div");
689
916
  controls.className = "header-controls";
917
+ controls.setAttribute("part", "header-controls");
690
918
 
691
- const history = document.createElement("button");
692
- history.type = "button";
693
- history.className = "header-btn header-btn--history";
694
- history.title = "Chat history";
695
- history.setAttribute("aria-label", "Chat history");
696
- history.textContent = "☰";
919
+ const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
697
920
  history.addEventListener("click", () => {
698
921
  void this.#refreshDrawer();
699
922
  this.#drawer.open();
700
923
  });
701
924
 
702
- const newChat = document.createElement("button");
703
- newChat.type = "button";
704
- newChat.className = "header-btn header-btn--new";
705
- newChat.title = "New chat";
706
- newChat.setAttribute("aria-label", "New chat");
707
- newChat.textContent = "✚";
925
+ const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
708
926
  newChat.addEventListener("click", () => this.newChat());
709
927
 
710
- const collapse = document.createElement("button");
711
- collapse.type = "button";
712
- collapse.className = "header-btn header-btn--collapse";
713
- collapse.title = "Collapse";
714
- collapse.setAttribute("aria-label", "Collapse");
715
- collapse.textContent = "—";
928
+ const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
716
929
  collapse.addEventListener("click", () => this.toggleCollapsed());
717
930
 
718
931
  controls.append(history, newChat, collapse);
719
- header.append(title, controls);
932
+ header.append(title, headerActions, controls);
720
933
 
721
934
  this.#messages.className = "messages";
935
+ this.#messages.setAttribute("part", "messages");
722
936
  // Screen readers announce streamed messages as they arrive.
723
937
  this.#messages.setAttribute("role", "log");
724
938
  this.#messages.setAttribute("aria-live", "polite");
725
- 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);
726
949
 
727
950
  const inputRow = document.createElement("div");
728
951
  inputRow.className = "input-row";
952
+ inputRow.setAttribute("part", "composer");
729
953
 
730
954
  this.#input.className = "input";
731
- this.#input.setAttribute("aria-label", "Message");
955
+ this.#input.setAttribute("part", "input");
956
+ this.#input.setAttribute("aria-label", this.#strings.message);
732
957
  this.#input.rows = 2;
733
- this.#input.placeholder = "Ask anything…";
958
+ this.#input.placeholder = this.#strings.inputPlaceholder;
734
959
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
735
960
  this.#input.addEventListener("input", () => this.#onInput());
736
961
 
737
962
  this.#send.className = "send";
738
963
  this.#send.type = "button";
739
- this.#send.textContent = "Send";
740
- 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);
741
967
  this.#send.dataset["state"] = "idle";
742
968
  this.#send.addEventListener("click", () => {
743
969
  // One button, two states: Send while idle, Stop while a run is in
@@ -752,19 +978,102 @@ export class AgUiChat extends HTMLElement {
752
978
  this.#skillHint.className = "skill-hint";
753
979
  this.#skillHint.hidden = true;
754
980
 
755
- inputRow.append(this.#input, this.#send);
981
+ // File-upload affordance: a 📎 button (hidden until `data-attachments-url`
982
+ // is wired) opening a hidden multi-file input. Drag-and-drop covers the
983
+ // whole shell (wired in #enableDragAndDrop).
984
+ this.#attachButton.className = "attach-btn";
985
+ this.#attachButton.type = "button";
986
+ this.#attachButton.setAttribute("part", "attach-button");
987
+ this.#attachButton.textContent = "📎";
988
+ this.#attachButton.title = this.#strings.attachFiles;
989
+ this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
990
+ this.#attachButton.hidden = true;
991
+ this.#attachButton.addEventListener("click", () => this.#fileInput.click());
992
+
993
+ this.#fileInput.className = "attach-input";
994
+ this.#fileInput.type = "file";
995
+ this.#fileInput.multiple = true;
996
+ this.#fileInput.hidden = true;
997
+ this.#fileInput.addEventListener("change", () => this.#onFilesPicked());
998
+
999
+ this.#attachSlot.className = "attachment-slot";
1000
+
1001
+ // A coarse footer slot below the composer.
1002
+ const footer = document.createElement("slot");
1003
+ footer.name = "footer";
1004
+
1005
+ inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
756
1006
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
757
- // and the missing-placeholder hint.
1007
+ // the missing-placeholder hint, and the pending-attachments tray.
758
1008
  this.#chat.append(
759
1009
  header,
760
1010
  this.#messages,
761
1011
  this.#skillsMenu.palette,
762
1012
  this.#skillsMenu.chips,
763
1013
  this.#skillHint,
1014
+ this.#attachSlot,
764
1015
  inputRow,
1016
+ footer,
765
1017
  this.#drawer.element,
766
1018
  );
767
- 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;
768
1077
  }
769
1078
 
770
1079
  /** Forward input changes to the skills palette and clear any stale hint. */
@@ -806,7 +1115,7 @@ export class AgUiChat extends HTMLElement {
806
1115
  /** Swap the composer button between Send (idle) and Stop (running). */
807
1116
  #setRunning(running: boolean): void {
808
1117
  this.#running = running;
809
- const label = running ? "Stop" : "Send";
1118
+ const label = running ? this.#strings.stop : this.#strings.send;
810
1119
  this.#send.textContent = label;
811
1120
  this.#send.setAttribute("aria-label", label);
812
1121
  this.#send.dataset["state"] = running ? "running" : "idle";
@@ -814,26 +1123,36 @@ export class AgUiChat extends HTMLElement {
814
1123
 
815
1124
  async #submit(): Promise<void> {
816
1125
  const content = this.#input.value.trim();
817
- if (content === "") {
1126
+ const attachments = this.#attachTray?.readyRefs() ?? [];
1127
+ // Allow an attachments-only message (no typed text), but nothing empty.
1128
+ if (content === "" && attachments.length === 0) {
818
1129
  return;
819
1130
  }
820
- this.appendMessage(MESSAGE_ROLE.USER, content);
1131
+ const bubble = this.appendMessage(MESSAGE_ROLE.USER, content);
1132
+ if (attachments.length > 0) {
1133
+ bubble.appendChild(renderAttachmentChips(attachments));
1134
+ }
821
1135
  this.#input.value = "";
1136
+ // The refs are now on the bubble; drop the settled chips, keep any still
1137
+ // uploading for a follow-up message.
1138
+ this.#attachTray?.clearReady();
1139
+ // Surfaced to the run via the context manifest until the run settles.
1140
+ this.#runAttachments = attachments;
822
1141
  this.dispatchEvent(
823
1142
  new CustomEvent<SubmitDetail>(SUBMIT_EVENT, {
824
- detail: { content },
1143
+ detail: { content, attachments },
825
1144
  bubbles: true,
826
1145
  composed: true,
827
1146
  }),
828
1147
  );
829
- await this.#client_send(content);
1148
+ await this.#client_send(content, attachments);
830
1149
  }
831
1150
 
832
- async #client_send(content: string): Promise<void> {
1151
+ async #client_send(content: string, attachments: readonly AttachmentRef[]): Promise<void> {
833
1152
  if (this.endpoint === "") {
834
1153
  return;
835
1154
  }
836
- await this.#ensureClient().send(content);
1155
+ await this.#ensureClient().send(content, attachments);
837
1156
  }
838
1157
 
839
1158
  #ensureClient(): AgUiClient {
@@ -855,6 +1174,7 @@ export class AgUiChat extends HTMLElement {
855
1174
  getContext: () => this.getContext(),
856
1175
  executeTool: (call) => this.#executeTool(call),
857
1176
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
1177
+ connectionLostMessage: this.#strings.connectionLost,
858
1178
  });
859
1179
  }
860
1180
  return this.#client;
@@ -884,7 +1204,7 @@ export class AgUiChat extends HTMLElement {
884
1204
  // pending indicator: nothing here triggers another client round, so it
885
1205
  // would hang after the run ended.
886
1206
  if (!this.#serverSettled.has(call.id)) {
887
- card.settle(TOOL_CALL_STATUS.DONE, "No result returned.");
1207
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
888
1208
  }
889
1209
  return null;
890
1210
  }
@@ -899,12 +1219,14 @@ export class AgUiChat extends HTMLElement {
899
1219
  this.#confirmAbort = new AbortController();
900
1220
  const decision = requestConfirmation(this.#messages, request, {
901
1221
  signal: this.#confirmAbort.signal,
1222
+ strings: this.#strings,
902
1223
  });
1224
+ this.#updateEmptyState();
903
1225
  this.#messages.scrollTop = this.#messages.scrollHeight;
904
1226
  const accepted = await decision;
905
1227
  this.#confirmAbort = null;
906
1228
  if (!accepted) {
907
- const message = "User declined the action.";
1229
+ const message = this.#strings.declinedAction;
908
1230
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
909
1231
  this.#showPending();
910
1232
  return { content: message };
@@ -922,7 +1244,7 @@ export class AgUiChat extends HTMLElement {
922
1244
  try {
923
1245
  const result = await tool.handler(call.args);
924
1246
  if (navigates) {
925
- card.settle(TOOL_CALL_STATUS.DONE, "Navigating…");
1247
+ card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
926
1248
  return { content: "", halt: true };
927
1249
  }
928
1250
  const content = JSON.stringify(result ?? null);
@@ -998,6 +1320,17 @@ export class AgUiChat extends HTMLElement {
998
1320
  this.#hidePending();
999
1321
  this.#setRunning(false);
1000
1322
  this.#streamingBubble = null;
1323
+ // The attachment manifest was for this run only; the model has read what
1324
+ // it needed (results now live in history).
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
+ }
1001
1334
  },
1002
1335
  };
1003
1336
  }
@@ -1006,9 +1339,11 @@ export class AgUiChat extends HTMLElement {
1006
1339
  #appendStoppedNote(): void {
1007
1340
  const note = document.createElement("div");
1008
1341
  note.className = "stopped-note";
1342
+ note.setAttribute("part", "stopped");
1009
1343
  note.setAttribute("role", "status");
1010
- note.textContent = "⏹ Stopped";
1344
+ note.textContent = this.#strings.stopped;
1011
1345
  this.#messages.appendChild(note);
1346
+ this.#updateEmptyState();
1012
1347
  this.#messages.scrollTop = this.#messages.scrollHeight;
1013
1348
  }
1014
1349
 
@@ -1023,8 +1358,9 @@ export class AgUiChat extends HTMLElement {
1023
1358
  }
1024
1359
  const pending = document.createElement("div");
1025
1360
  pending.className = "pending";
1361
+ pending.setAttribute("part", "pending");
1026
1362
  pending.setAttribute("role", "status");
1027
- pending.setAttribute("aria-label", "Assistant is thinking…");
1363
+ pending.setAttribute("aria-label", this.#strings.thinking);
1028
1364
  for (let i = 0; i < 3; i += 1) {
1029
1365
  const dot = document.createElement("span");
1030
1366
  dot.className = "pending-dot";
@@ -1032,6 +1368,7 @@ export class AgUiChat extends HTMLElement {
1032
1368
  }
1033
1369
  this.#pending = pending;
1034
1370
  this.#messages.appendChild(pending);
1371
+ this.#updateEmptyState();
1035
1372
  this.#messages.scrollTop = this.#messages.scrollHeight;
1036
1373
  }
1037
1374
 
@@ -1072,9 +1409,10 @@ export class AgUiChat extends HTMLElement {
1072
1409
  : (this.toolSummaries[call.name] ??
1073
1410
  this.#toolCatalog[call.name] ??
1074
1411
  prettifyToolName(call.name));
1075
- 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);
1076
1413
  this.#toolCards.set(call.id, card);
1077
1414
  this.#messages.appendChild(card.element);
1415
+ this.#updateEmptyState();
1078
1416
  this.#messages.scrollTop = this.#messages.scrollHeight;
1079
1417
  return card;
1080
1418
  }