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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17,6 +17,12 @@ var TOOL_CALL_STATUS = {
17
17
  ERROR: "error",
18
18
  DECLINED: "declined"
19
19
  };
20
+ var ATTACHMENT_STATUS = {
21
+ UPLOADING: "uploading",
22
+ READY: "ready",
23
+ ERROR: "error"
24
+ };
25
+ var DEFAULT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
20
26
  var TOOL_DISPLAY = {
21
27
  MINIMAL: "minimal",
22
28
  COMPACT: "compact",
@@ -231,6 +237,227 @@ function createStateHookTools(hook) {
231
237
  return tools;
232
238
  }
233
239
 
240
+ // src/ui/attachment_chips.ts
241
+ function renderAttachmentChips(refs) {
242
+ const list = document.createElement("div");
243
+ list.className = "attachment-chips";
244
+ for (const ref of refs) {
245
+ list.appendChild(renderChip(ref));
246
+ }
247
+ return list;
248
+ }
249
+ function renderChip(ref) {
250
+ const chip = document.createElement("div");
251
+ chip.className = "attachment-chip attachment-chip--ready";
252
+ const icon = document.createElement("span");
253
+ icon.className = "attachment-chip-icon";
254
+ icon.textContent = iconFor(ref.mime);
255
+ icon.setAttribute("aria-hidden", "true");
256
+ const name = document.createElement("span");
257
+ name.className = "attachment-chip-name";
258
+ name.textContent = ref.name;
259
+ name.title = ref.name;
260
+ const size = document.createElement("span");
261
+ size.className = "attachment-chip-size";
262
+ size.textContent = formatBytes(ref.size);
263
+ chip.append(icon, name, size);
264
+ return chip;
265
+ }
266
+ function iconFor(mime) {
267
+ if (mime.startsWith("image/")) {
268
+ return "\u{1F5BC}";
269
+ }
270
+ if (mime === "application/pdf") {
271
+ return "\u{1F4D5}";
272
+ }
273
+ if (mime.startsWith("text/")) {
274
+ return "\u{1F4C4}";
275
+ }
276
+ return "\u{1F4CE}";
277
+ }
278
+ function formatBytes(bytes) {
279
+ if (bytes < 1024) {
280
+ return `${bytes} B`;
281
+ }
282
+ const units = ["KB", "MB", "GB"];
283
+ let value = bytes / 1024;
284
+ let unit = 0;
285
+ while (value >= 1024 && unit < units.length - 1) {
286
+ value /= 1024;
287
+ unit += 1;
288
+ }
289
+ const rounded = value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
290
+ return `${rounded} ${units[unit]}`;
291
+ }
292
+
293
+ // src/ui/attachment_tray.ts
294
+ import { randomUUID } from "@ag-ui/client";
295
+ var AttachmentTray = class {
296
+ /** The tray root; append above the input row. Hidden while empty. */
297
+ element;
298
+ #config;
299
+ #items = [];
300
+ constructor(config) {
301
+ this.#config = config;
302
+ this.element = document.createElement("div");
303
+ this.element.className = "attachment-tray";
304
+ this.element.hidden = true;
305
+ }
306
+ /** Queue a file: reject oversize/disallowed into an error chip, else upload. */
307
+ add(file) {
308
+ const item = {
309
+ localId: randomUUID(),
310
+ file,
311
+ status: ATTACHMENT_STATUS.UPLOADING,
312
+ progress: 0,
313
+ ref: null,
314
+ error: ""
315
+ };
316
+ this.#items.push(item);
317
+ const rejection = this.#reject(file);
318
+ if (rejection !== null) {
319
+ item.status = ATTACHMENT_STATUS.ERROR;
320
+ item.error = rejection;
321
+ this.#render();
322
+ this.#config.onChange?.();
323
+ return;
324
+ }
325
+ this.#render();
326
+ this.#config.onChange?.();
327
+ this.#upload(item);
328
+ }
329
+ /** The durable refs of every chip that finished uploading. */
330
+ readyRefs() {
331
+ const refs = [];
332
+ for (const item of this.#items) {
333
+ if (item.ref !== null) {
334
+ refs.push(item.ref);
335
+ }
336
+ }
337
+ return refs;
338
+ }
339
+ /** Whether any chip is still uploading (a send would drop nothing if false). */
340
+ hasPending() {
341
+ return this.#items.some((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
342
+ }
343
+ /** Whether the tray holds no chips. */
344
+ isEmpty() {
345
+ return this.#items.length === 0;
346
+ }
347
+ /** Drop the settled (ready / error) chips, leaving any still uploading. */
348
+ clearReady() {
349
+ this.#items = this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
350
+ this.#render();
351
+ }
352
+ /** Drop every chip (a reset / new-chat). */
353
+ clear() {
354
+ this.#items = [];
355
+ this.#render();
356
+ }
357
+ /** The size/type rejection reason for a file, or `null` when accepted. */
358
+ #reject(file) {
359
+ if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
360
+ return `Too large (max ${formatBytes(this.#config.maxBytes)})`;
361
+ }
362
+ if (!accepts(this.#config.accept, file)) {
363
+ return "File type not allowed";
364
+ }
365
+ return null;
366
+ }
367
+ #upload(item) {
368
+ item.status = ATTACHMENT_STATUS.UPLOADING;
369
+ item.progress = 0;
370
+ item.error = "";
371
+ this.#render();
372
+ this.#config.upload(item.file, (fraction) => {
373
+ item.progress = fraction;
374
+ this.#render();
375
+ }).then((ref) => {
376
+ item.status = ATTACHMENT_STATUS.READY;
377
+ item.ref = ref;
378
+ }).catch((error) => {
379
+ item.status = ATTACHMENT_STATUS.ERROR;
380
+ item.error = error instanceof Error ? error.message : "upload failed";
381
+ }).finally(() => {
382
+ this.#render();
383
+ this.#config.onChange?.();
384
+ });
385
+ }
386
+ #remove(item) {
387
+ this.#items = this.#items.filter((other) => other !== item);
388
+ this.#render();
389
+ this.#config.onChange?.();
390
+ }
391
+ #render() {
392
+ this.element.replaceChildren();
393
+ this.element.hidden = this.#items.length === 0;
394
+ for (const item of this.#items) {
395
+ this.element.appendChild(this.#renderChip(item));
396
+ }
397
+ }
398
+ #renderChip(item) {
399
+ const chip = document.createElement("div");
400
+ chip.className = `attachment-chip attachment-chip--${item.status}`;
401
+ const icon = document.createElement("span");
402
+ icon.className = "attachment-chip-icon";
403
+ icon.textContent = iconFor(item.file.type);
404
+ icon.setAttribute("aria-hidden", "true");
405
+ const name = document.createElement("span");
406
+ name.className = "attachment-chip-name";
407
+ name.textContent = item.file.name;
408
+ name.title = item.file.name;
409
+ const meta = document.createElement("span");
410
+ meta.className = "attachment-chip-size";
411
+ meta.textContent = item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
412
+ chip.append(icon, name, meta);
413
+ if (item.status === ATTACHMENT_STATUS.UPLOADING) {
414
+ const bar = document.createElement("div");
415
+ bar.className = "attachment-chip-bar";
416
+ const fill = document.createElement("div");
417
+ fill.className = "attachment-chip-bar-fill";
418
+ fill.style.width = `${Math.round(item.progress * 100)}%`;
419
+ bar.appendChild(fill);
420
+ chip.appendChild(bar);
421
+ }
422
+ if (item.status === ATTACHMENT_STATUS.ERROR) {
423
+ const retry = document.createElement("button");
424
+ retry.type = "button";
425
+ retry.className = "attachment-chip-retry";
426
+ retry.title = "Retry";
427
+ retry.setAttribute("aria-label", "Retry upload");
428
+ retry.textContent = "\u21BB";
429
+ retry.addEventListener("click", () => this.#upload(item));
430
+ chip.appendChild(retry);
431
+ }
432
+ const remove = document.createElement("button");
433
+ remove.type = "button";
434
+ remove.className = "attachment-chip-remove";
435
+ remove.title = "Remove";
436
+ remove.setAttribute("aria-label", "Remove attachment");
437
+ remove.textContent = "\u2715";
438
+ remove.addEventListener("click", () => this.#remove(item));
439
+ chip.appendChild(remove);
440
+ return chip;
441
+ }
442
+ };
443
+ function accepts(accept, file) {
444
+ const tokens = accept.split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
445
+ if (tokens.length === 0) {
446
+ return true;
447
+ }
448
+ const mime = file.type.toLowerCase();
449
+ const name = file.name.toLowerCase();
450
+ return tokens.some((token) => {
451
+ if (token.startsWith(".")) {
452
+ return name.endsWith(token);
453
+ }
454
+ if (token.endsWith("/*")) {
455
+ return mime.startsWith(token.slice(0, -1));
456
+ }
457
+ return mime === token;
458
+ });
459
+ }
460
+
234
461
  // src/ui/confirmation_card.ts
235
462
  function actionButton(modifier, label) {
236
463
  const button = document.createElement("button");
@@ -3464,6 +3691,116 @@ var STYLES = `
3464
3691
  background: var(--ag-ui-muted);
3465
3692
  }
3466
3693
 
3694
+ /* \u2500\u2500 File attachments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
3695
+ /* The \u{1F4CE} picker button sits left of the input; hidden until data-attachments-url. */
3696
+ .attach-btn {
3697
+ border: 1px solid var(--ag-ui-border);
3698
+ border-radius: 8px;
3699
+ padding: 0 10px;
3700
+ background: var(--ag-ui-input-bg);
3701
+ color: inherit;
3702
+ font: inherit;
3703
+ cursor: pointer;
3704
+ }
3705
+
3706
+ .attach-btn:hover {
3707
+ border-color: var(--ag-ui-accent);
3708
+ }
3709
+
3710
+ .attach-input {
3711
+ display: none;
3712
+ }
3713
+
3714
+ /* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
3715
+ .attachment-slot {
3716
+ display: contents;
3717
+ }
3718
+
3719
+ .attachment-tray {
3720
+ display: flex;
3721
+ flex-wrap: wrap;
3722
+ gap: 6px;
3723
+ padding: 8px 12px 0;
3724
+ }
3725
+
3726
+ .attachment-chips {
3727
+ display: flex;
3728
+ flex-wrap: wrap;
3729
+ gap: 6px;
3730
+ margin-top: 6px;
3731
+ }
3732
+
3733
+ .attachment-chip {
3734
+ display: inline-flex;
3735
+ align-items: center;
3736
+ gap: 6px;
3737
+ max-width: 100%;
3738
+ padding: 4px 8px;
3739
+ border: 1px solid var(--ag-ui-border);
3740
+ border-radius: 999px;
3741
+ background: var(--ag-ui-assistant-bg);
3742
+ font-size: 0.85em;
3743
+ position: relative;
3744
+ }
3745
+
3746
+ .attachment-chip--error {
3747
+ border-color: var(--ag-ui-danger);
3748
+ color: var(--ag-ui-danger);
3749
+ }
3750
+
3751
+ .attachment-chip-name {
3752
+ overflow: hidden;
3753
+ text-overflow: ellipsis;
3754
+ white-space: nowrap;
3755
+ max-width: 14ch;
3756
+ }
3757
+
3758
+ .attachment-chip-size {
3759
+ color: var(--ag-ui-muted);
3760
+ white-space: nowrap;
3761
+ }
3762
+
3763
+ .attachment-chip--error .attachment-chip-size {
3764
+ color: var(--ag-ui-danger);
3765
+ }
3766
+
3767
+ /* The progress bar fills as the file uploads. */
3768
+ .attachment-chip-bar {
3769
+ flex-basis: 100%;
3770
+ height: 3px;
3771
+ border-radius: 2px;
3772
+ background: var(--ag-ui-border);
3773
+ overflow: hidden;
3774
+ }
3775
+
3776
+ .attachment-chip-bar-fill {
3777
+ height: 100%;
3778
+ background: var(--ag-ui-accent);
3779
+ transition: width 0.15s ease;
3780
+ }
3781
+
3782
+ .attachment-chip-remove,
3783
+ .attachment-chip-retry {
3784
+ border: none;
3785
+ background: none;
3786
+ color: inherit;
3787
+ cursor: pointer;
3788
+ padding: 0;
3789
+ line-height: 1;
3790
+ opacity: 0.7;
3791
+ }
3792
+
3793
+ .attachment-chip-remove:hover,
3794
+ .attachment-chip-retry:hover {
3795
+ opacity: 1;
3796
+ }
3797
+
3798
+ /* A subtle outline while a file is dragged over the shell. */
3799
+ .chat--dragover {
3800
+ outline: 2px dashed var(--ag-ui-accent);
3801
+ outline-offset: -4px;
3802
+ }
3803
+
3467
3804
  /* Muted "\u23F9 Stopped" line after a cancelled run \u2014 a note, not an error bubble. */
3468
3805
  .stopped-note {
3469
3806
  align-self: flex-start;
@@ -4046,7 +4383,7 @@ ${text2}`;
4046
4383
  };
4047
4384
 
4048
4385
  // src/core/agui_client.ts
4049
- import { randomUUID } from "@ag-ui/client";
4386
+ import { randomUUID as randomUUID2 } from "@ag-ui/client";
4050
4387
  var AgUiClient = class {
4051
4388
  #agent;
4052
4389
  #handlers;
@@ -4080,9 +4417,18 @@ var AgUiClient = class {
4080
4417
  * When the agent calls frontend tools, this executes them and re-runs the
4081
4418
  * agent with the results, looping until the agent stops calling frontend
4082
4419
  * tools (bounded by {@link MAX_TOOL_ROUNDS}).
4420
+ *
4421
+ * `attachments` ride on the user message as a non-standard field so the
4422
+ * default client store round-trips them for history replay; the agent learns
4423
+ * the ids from the run context (the server's strict validation ignores the
4424
+ * unknown message field), then reads bytes via the `read_attachment` tool.
4083
4425
  */
4084
- async send(content) {
4085
- this.#agent.addMessage({ id: randomUUID(), role: "user", content });
4426
+ async send(content, attachments = []) {
4427
+ const message = { id: randomUUID2(), role: "user", content };
4428
+ if (attachments.length > 0) {
4429
+ message.attachments = attachments;
4430
+ }
4431
+ this.#agent.addMessage(message);
4086
4432
  this.#onPersist(this.#agent.messages);
4087
4433
  await this.#run();
4088
4434
  }
@@ -4096,7 +4442,7 @@ var AgUiClient = class {
4096
4442
  }
4097
4443
  /** Append a frontend tool result to history (used by the resume path). */
4098
4444
  addToolResult(toolCallId, content) {
4099
- this.#agent.addMessage({ id: randomUUID(), role: "tool", content, toolCallId });
4445
+ this.#agent.addMessage({ id: randomUUID2(), role: "tool", content, toolCallId });
4100
4446
  this.#onPersist(this.#agent.messages);
4101
4447
  }
4102
4448
  /**
@@ -4158,7 +4504,7 @@ var AgUiClient = class {
4158
4504
  return;
4159
4505
  }
4160
4506
  this.#agent.addMessage({
4161
- id: randomUUID(),
4507
+ id: randomUUID2(),
4162
4508
  role: "tool",
4163
4509
  content: result.content,
4164
4510
  toolCallId: call.id
@@ -4208,8 +4554,14 @@ function isAbortError(error) {
4208
4554
  return error instanceof Error && error.name === "AbortError";
4209
4555
  }
4210
4556
 
4557
+ // src/core/attachment.ts
4558
+ function messageAttachments(message) {
4559
+ const refs = message.attachments;
4560
+ return Array.isArray(refs) ? refs : [];
4561
+ }
4562
+
4211
4563
  // src/core/conversation_store.ts
4212
- import { randomUUID as randomUUID2 } from "@ag-ui/client";
4564
+ import { randomUUID as randomUUID3 } from "@ag-ui/client";
4213
4565
  var THREAD_KEY = "ag-ui-chat:thread";
4214
4566
  var THREADS_KEY = "ag-ui-chat:threads";
4215
4567
  var MESSAGES_PREFIX = "ag-ui-chat:messages:";
@@ -4223,7 +4575,7 @@ var SessionStorageStore = class {
4223
4575
  if (existing !== null) {
4224
4576
  return existing;
4225
4577
  }
4226
- const id = randomUUID2();
4578
+ const id = randomUUID3();
4227
4579
  sessionStorage.setItem(THREAD_KEY, id);
4228
4580
  return id;
4229
4581
  }
@@ -4463,6 +4815,70 @@ var RemoteConversationStore = class {
4463
4815
  }
4464
4816
  };
4465
4817
 
4818
+ // src/core/upload_attachment.ts
4819
+ function uploadAttachment(file, options) {
4820
+ return new Promise((resolve, reject) => {
4821
+ const form = new FormData();
4822
+ form.append("file", file);
4823
+ const xhr = new XMLHttpRequest();
4824
+ xhr.open("POST", options.url);
4825
+ for (const [key, value] of Object.entries(options.headers ?? {})) {
4826
+ xhr.setRequestHeader(key, value);
4827
+ }
4828
+ const onProgress = options.onProgress;
4829
+ if (onProgress !== void 0) {
4830
+ xhr.upload.addEventListener("progress", (event) => {
4831
+ if (event.lengthComputable) {
4832
+ onProgress(event.total === 0 ? 0 : event.loaded / event.total);
4833
+ }
4834
+ });
4835
+ }
4836
+ xhr.addEventListener("load", () => {
4837
+ if (xhr.status >= 200 && xhr.status < 300) {
4838
+ try {
4839
+ resolve(parseRef(JSON.parse(xhr.responseText)));
4840
+ } catch {
4841
+ reject(new Error("upload returned an unreadable response"));
4842
+ }
4843
+ } else {
4844
+ reject(new Error(errorMessage(xhr)));
4845
+ }
4846
+ });
4847
+ xhr.addEventListener("error", () => reject(new Error("upload failed")));
4848
+ xhr.addEventListener("abort", () => reject(new Error("upload cancelled")));
4849
+ const signal = options.signal;
4850
+ if (signal !== void 0) {
4851
+ signal.addEventListener("abort", () => xhr.abort());
4852
+ }
4853
+ xhr.send(form);
4854
+ });
4855
+ }
4856
+ function parseRef(body) {
4857
+ if (typeof body !== "object" || body === null) {
4858
+ throw new Error("not an object");
4859
+ }
4860
+ const o = body;
4861
+ const id = o["id"];
4862
+ const name = o["name"];
4863
+ const mime = o["mime"];
4864
+ const size = o["size"];
4865
+ const url = o["url"];
4866
+ if (typeof id !== "string" || typeof name !== "string" || typeof mime !== "string" || typeof size !== "number") {
4867
+ throw new Error("missing fields");
4868
+ }
4869
+ return typeof url === "string" ? { id, name, mime, size, url } : { id, name, mime, size };
4870
+ }
4871
+ function errorMessage(xhr) {
4872
+ try {
4873
+ const body = JSON.parse(xhr.responseText);
4874
+ if (typeof body.error === "string") {
4875
+ return body.error;
4876
+ }
4877
+ } catch {
4878
+ }
4879
+ return `upload failed (${xhr.status})`;
4880
+ }
4881
+
4466
4882
  // src/core/ag_ui_chat.ts
4467
4883
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
4468
4884
  var AgUiChat = class extends HTMLElement {
@@ -4503,9 +4919,14 @@ var AgUiChat = class extends HTMLElement {
4503
4919
  ];
4504
4920
  /**
4505
4921
  * Per-run context provider. Defaults to the compact page map (when a
4506
- * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on).
4922
+ * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on)
4923
+ * plus a one-line manifest of the files attached to the message being sent,
4924
+ * so the agent knows which `read_attachment` ids are available.
4507
4925
  */
4508
- getContext = () => createPageMapContext(this.getPageMap, this.autoInjectPageMap);
4926
+ getContext = () => [
4927
+ ...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
4928
+ ...this.#attachmentContext()
4929
+ ];
4509
4930
  /**
4510
4931
  * Navigable routes the agent can jump to via the built-in `route.*` tools.
4511
4932
  * A compact summary also rides in each run's context.
@@ -4527,6 +4948,16 @@ var AgUiChat = class extends HTMLElement {
4527
4948
  * server-backed store for cross-tab/device durability.
4528
4949
  */
4529
4950
  conversationStore = new SessionStorageStore();
4951
+ /**
4952
+ * How attached files are uploaded. `null` (default) uses the built-in
4953
+ * multipart `POST` to `data-attachments-url`. Set a custom
4954
+ * {@link UploadHandler} — `(file, onProgress) => Promise<AttachmentRef>` — to
4955
+ * swap the transport (e.g. a `tus-js-client` resumable adapter or
4956
+ * direct-to-S3 multipart) without changing the tray, the chips, or the AG-UI
4957
+ * wire (refs are transport-agnostic). When set, the 📎 affordance appears even
4958
+ * with no `data-attachments-url`; the handler owns its own endpoint + headers.
4959
+ */
4960
+ uploadHandler = null;
4530
4961
  /**
4531
4962
  * Builds the tool result a navigating tool resumes with after the page
4532
4963
  * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
@@ -4575,6 +5006,14 @@ var AgUiChat = class extends HTMLElement {
4575
5006
  #skillsMenu;
4576
5007
  #drawer;
4577
5008
  #skillHint;
5009
+ /** File-picker button + hidden input + tray slot; the tray mounts on connect. */
5010
+ #attachButton;
5011
+ #fileInput;
5012
+ #attachSlot;
5013
+ /** Upload tray; created on connect only when `data-attachments-url` is set. */
5014
+ #attachTray = null;
5015
+ /** Refs attached to the message currently being sent (the context manifest). */
5016
+ #runAttachments = [];
4578
5017
  #client = null;
4579
5018
  // Whether an interaction is in flight (first onRunStart → onSettled). Drives
4580
5019
  // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
@@ -4604,6 +5043,9 @@ var AgUiChat = class extends HTMLElement {
4604
5043
  this.#send = document.createElement("button");
4605
5044
  this.#title = document.createElement("span");
4606
5045
  this.#skillHint = document.createElement("div");
5046
+ this.#attachButton = document.createElement("button");
5047
+ this.#fileInput = document.createElement("input");
5048
+ this.#attachSlot = document.createElement("div");
4607
5049
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
4608
5050
  this.#drawer = new ThreadDrawer({
4609
5051
  onSelect: (threadId) => {
@@ -4717,9 +5159,96 @@ var AgUiChat = class extends HTMLElement {
4717
5159
  this.#initSkills();
4718
5160
  void this.#fetchToolCatalog();
4719
5161
  this.#wireThreadStore();
5162
+ this.#wireAttachments();
4720
5163
  this.#threadId = this.conversationStore.threadId();
4721
5164
  void this.#rehydrate();
4722
5165
  }
5166
+ /**
5167
+ * Enable the composer's file-upload tray when uploads are possible — either a
5168
+ * custom {@link uploadHandler} is set or `data-attachments-url` provides the
5169
+ * built-in multipart endpoint: reveal the 📎 button, wire the hidden file
5170
+ * input + drag-and-drop, and mount the tray. With neither, the affordance
5171
+ * stays hidden and the chat degrades to text-only.
5172
+ */
5173
+ #wireAttachments() {
5174
+ const url = this.getAttribute("data-attachments-url");
5175
+ const upload = this.uploadHandler ?? this.#defaultUploadHandler(url);
5176
+ if (upload === null) {
5177
+ return;
5178
+ }
5179
+ const accept = this.getAttribute("data-attachment-accept") ?? "";
5180
+ this.#attachTray = new AttachmentTray({
5181
+ upload,
5182
+ maxBytes: this.#attachmentMaxBytes(),
5183
+ accept
5184
+ });
5185
+ this.#attachSlot.appendChild(this.#attachTray.element);
5186
+ this.#fileInput.accept = accept;
5187
+ this.#attachButton.hidden = false;
5188
+ this.#enableDragAndDrop();
5189
+ }
5190
+ /** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
5191
+ #defaultUploadHandler(url) {
5192
+ if (url === null) {
5193
+ return null;
5194
+ }
5195
+ return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
5196
+ }
5197
+ /** The client-side upload size cap from `data-attachment-max-bytes`. */
5198
+ #attachmentMaxBytes() {
5199
+ const attr = this.getAttribute("data-attachment-max-bytes");
5200
+ if (attr === null) {
5201
+ return DEFAULT_ATTACHMENT_MAX_BYTES;
5202
+ }
5203
+ const parsed = Number.parseInt(attr, 10);
5204
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_ATTACHMENT_MAX_BYTES;
5205
+ }
5206
+ /** Queue every file from the picker into the tray, then reset the input. */
5207
+ #onFilesPicked() {
5208
+ const files = this.#fileInput.files;
5209
+ if (files !== null) {
5210
+ for (const file of Array.from(files)) {
5211
+ this.#attachTray?.add(file);
5212
+ }
5213
+ }
5214
+ this.#fileInput.value = "";
5215
+ }
5216
+ /** Accept files dropped anywhere on the chat shell into the tray. */
5217
+ #enableDragAndDrop() {
5218
+ this.#chat.addEventListener("dragover", (event) => {
5219
+ event.preventDefault();
5220
+ this.#chat.classList.add("chat--dragover");
5221
+ });
5222
+ this.#chat.addEventListener("dragleave", () => {
5223
+ this.#chat.classList.remove("chat--dragover");
5224
+ });
5225
+ this.#chat.addEventListener("drop", (event) => {
5226
+ event.preventDefault();
5227
+ this.#chat.classList.remove("chat--dragover");
5228
+ const files = event.dataTransfer?.files;
5229
+ if (files !== void 0) {
5230
+ for (const file of Array.from(files)) {
5231
+ this.#attachTray?.add(file);
5232
+ }
5233
+ }
5234
+ });
5235
+ }
5236
+ /** The one-line manifest of the message's attachments, for the run context. */
5237
+ #attachmentContext() {
5238
+ if (this.#runAttachments.length === 0) {
5239
+ return [];
5240
+ }
5241
+ const lines = this.#runAttachments.map(
5242
+ (ref) => `- ${ref.name} (id: ${ref.id}, ${ref.mime || "unknown type"}, ${ref.size} bytes)`
5243
+ );
5244
+ return [
5245
+ {
5246
+ description: "Files the user attached to this message",
5247
+ value: `${lines.join("\n")}
5248
+ Use the read_attachment tool with an id to read a file's contents.`
5249
+ }
5250
+ ];
5251
+ }
4723
5252
  /**
4724
5253
  * When `data-threads-url` is set, route thread enumeration / load / rename /
4725
5254
  * delete through that server endpoint (wrapping the current store as the
@@ -4865,6 +5394,8 @@ var AgUiChat = class extends HTMLElement {
4865
5394
  this.#toolCards.clear();
4866
5395
  this.#serverSettled.clear();
4867
5396
  this.#initialMessages = [];
5397
+ this.#runAttachments = [];
5398
+ this.#attachTray?.clear();
4868
5399
  this.#messages.replaceChildren();
4869
5400
  }
4870
5401
  /** Switch the active conversation to an existing thread and replay it. */
@@ -4924,8 +5455,12 @@ var AgUiChat = class extends HTMLElement {
4924
5455
  #renderHistoricMessage(message) {
4925
5456
  const text2 = typeof message.content === "string" ? message.content : "";
4926
5457
  if (message.role === MESSAGE_ROLE.USER) {
4927
- if (text2 !== "") {
4928
- this.appendMessage(MESSAGE_ROLE.USER, text2);
5458
+ const attachments = messageAttachments(message);
5459
+ if (text2 !== "" || attachments.length > 0) {
5460
+ const bubble = this.appendMessage(MESSAGE_ROLE.USER, text2);
5461
+ if (attachments.length > 0) {
5462
+ bubble.appendChild(renderAttachmentChips(attachments));
5463
+ }
4929
5464
  }
4930
5465
  return;
4931
5466
  }
@@ -5061,13 +5596,27 @@ var AgUiChat = class extends HTMLElement {
5061
5596
  });
5062
5597
  this.#skillHint.className = "skill-hint";
5063
5598
  this.#skillHint.hidden = true;
5064
- inputRow.append(this.#input, this.#send);
5599
+ this.#attachButton.className = "attach-btn";
5600
+ this.#attachButton.type = "button";
5601
+ this.#attachButton.textContent = "\u{1F4CE}";
5602
+ this.#attachButton.title = "Attach files";
5603
+ this.#attachButton.setAttribute("aria-label", "Attach files");
5604
+ this.#attachButton.hidden = true;
5605
+ this.#attachButton.addEventListener("click", () => this.#fileInput.click());
5606
+ this.#fileInput.className = "attach-input";
5607
+ this.#fileInput.type = "file";
5608
+ this.#fileInput.multiple = true;
5609
+ this.#fileInput.hidden = true;
5610
+ this.#fileInput.addEventListener("change", () => this.#onFilesPicked());
5611
+ this.#attachSlot.className = "attachment-slot";
5612
+ inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
5065
5613
  this.#chat.append(
5066
5614
  header,
5067
5615
  this.#messages,
5068
5616
  this.#skillsMenu.palette,
5069
5617
  this.#skillsMenu.chips,
5070
5618
  this.#skillHint,
5619
+ this.#attachSlot,
5071
5620
  inputRow,
5072
5621
  this.#drawer.element
5073
5622
  );
@@ -5113,25 +5662,31 @@ var AgUiChat = class extends HTMLElement {
5113
5662
  }
5114
5663
  async #submit() {
5115
5664
  const content = this.#input.value.trim();
5116
- if (content === "") {
5665
+ const attachments = this.#attachTray?.readyRefs() ?? [];
5666
+ if (content === "" && attachments.length === 0) {
5117
5667
  return;
5118
5668
  }
5119
- this.appendMessage(MESSAGE_ROLE.USER, content);
5669
+ const bubble = this.appendMessage(MESSAGE_ROLE.USER, content);
5670
+ if (attachments.length > 0) {
5671
+ bubble.appendChild(renderAttachmentChips(attachments));
5672
+ }
5120
5673
  this.#input.value = "";
5674
+ this.#attachTray?.clearReady();
5675
+ this.#runAttachments = attachments;
5121
5676
  this.dispatchEvent(
5122
5677
  new CustomEvent(SUBMIT_EVENT, {
5123
- detail: { content },
5678
+ detail: { content, attachments },
5124
5679
  bubbles: true,
5125
5680
  composed: true
5126
5681
  })
5127
5682
  );
5128
- await this.#client_send(content);
5683
+ await this.#client_send(content, attachments);
5129
5684
  }
5130
- async #client_send(content) {
5685
+ async #client_send(content, attachments) {
5131
5686
  if (this.endpoint === "") {
5132
5687
  return;
5133
5688
  }
5134
- await this.#ensureClient().send(content);
5689
+ await this.#ensureClient().send(content, attachments);
5135
5690
  }
5136
5691
  #ensureClient() {
5137
5692
  if (this.#client === null) {
@@ -5268,6 +5823,7 @@ var AgUiChat = class extends HTMLElement {
5268
5823
  this.#hidePending();
5269
5824
  this.#setRunning(false);
5270
5825
  this.#streamingBubble = null;
5826
+ this.#runAttachments = [];
5271
5827
  }
5272
5828
  };
5273
5829
  }
@@ -5501,7 +6057,7 @@ function setControlValue(el, value) {
5501
6057
  }
5502
6058
 
5503
6059
  // src/version.ts
5504
- var VERSION = "0.5.0";
6060
+ var VERSION = "0.6.0";
5505
6061
  export {
5506
6062
  AgUiChat,
5507
6063
  AgUiClient,
@@ -5532,6 +6088,7 @@ export {
5532
6088
  highlightThenClick,
5533
6089
  isDestructive,
5534
6090
  isNavigates,
6091
+ messageAttachments,
5535
6092
  parseToolCatalog,
5536
6093
  prefersReducedMotion,
5537
6094
  pressButton,
@@ -5547,7 +6104,8 @@ export {
5547
6104
  setNativeValue,
5548
6105
  toggleCheckbox,
5549
6106
  toggleControl,
5550
- typeInto
6107
+ typeInto,
6108
+ uploadAttachment
5551
6109
  };
5552
6110
  /*! Bundled license information:
5553
6111