@artooi/ag-ui-web-component 0.4.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +69 -2
  3. package/dist/ag-ui-web-component.bundle.js +355 -59
  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 +17 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +7 -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/conversation_store.d.ts +37 -8
  14. package/dist/core/conversation_store.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +35 -0
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -0
  17. package/dist/core/upload_attachment.d.ts +32 -0
  18. package/dist/core/upload_attachment.d.ts.map +1 -0
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1198 -24
  22. package/dist/index.js.map +4 -4
  23. package/dist/ui/attachment_chips.d.ts +13 -0
  24. package/dist/ui/attachment_chips.d.ts.map +1 -0
  25. package/dist/ui/attachment_tray.d.ts +42 -0
  26. package/dist/ui/attachment_tray.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +11 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -0
  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 +33 -0
  32. package/dist/ui/thread_drawer.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/constants.ts +18 -0
  35. package/src/core/ag_ui_chat.ts +267 -14
  36. package/src/core/agui_client.ts +15 -2
  37. package/src/core/attachment.ts +39 -0
  38. package/src/core/conversation_store.ts +148 -9
  39. package/src/core/remote_conversation_store.ts +147 -0
  40. package/src/core/upload_attachment.ts +113 -0
  41. package/src/index.ts +8 -0
  42. package/src/ui/attachment_chips.ts +68 -0
  43. package/src/ui/attachment_tray.ts +237 -0
  44. package/src/ui/relative_time.ts +28 -0
  45. package/src/ui/styles.ts +294 -0
  46. package/src/ui/thread_drawer.ts +200 -0
  47. package/src/version.ts +1 -1
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;
@@ -3602,8 +3939,370 @@ var STYLES = `
3602
3939
  font-size: 0.85em;
3603
3940
  color: var(--ag-ui-danger);
3604
3941
  }
3942
+
3943
+ /* Chat-history drawer \u2014 a slide-over within the chat panel. */
3944
+ .drawer {
3945
+ position: absolute;
3946
+ inset: 0;
3947
+ z-index: 5;
3948
+ display: flex;
3949
+ }
3950
+
3951
+ .drawer[hidden] {
3952
+ display: none;
3953
+ }
3954
+
3955
+ .drawer-backdrop {
3956
+ position: absolute;
3957
+ inset: 0;
3958
+ background: rgba(20, 20, 50, 0.32);
3959
+ }
3960
+
3961
+ .drawer-panel {
3962
+ position: relative;
3963
+ display: flex;
3964
+ flex-direction: column;
3965
+ width: min(300px, 85%);
3966
+ height: 100%;
3967
+ background: var(--ag-ui-bg);
3968
+ border-right: 1px solid var(--ag-ui-border);
3969
+ box-shadow: var(--ag-ui-shadow);
3970
+ overflow: hidden;
3971
+ }
3972
+
3973
+ .drawer-header {
3974
+ display: flex;
3975
+ align-items: center;
3976
+ justify-content: space-between;
3977
+ gap: var(--ag-ui-space);
3978
+ padding: var(--ag-ui-pad);
3979
+ border-bottom: 1px solid var(--ag-ui-border);
3980
+ }
3981
+
3982
+ .drawer-title {
3983
+ font-weight: 600;
3984
+ }
3985
+
3986
+ .drawer-new {
3987
+ border: 1px solid var(--ag-ui-border);
3988
+ border-radius: var(--ag-ui-radius);
3989
+ background: var(--ag-ui-bg);
3990
+ color: var(--ag-ui-accent);
3991
+ padding: 4px 10px;
3992
+ font: inherit;
3993
+ font-size: 0.85em;
3994
+ cursor: pointer;
3995
+ }
3996
+
3997
+ .drawer-list {
3998
+ flex: 1;
3999
+ min-height: 0;
4000
+ overflow-y: auto;
4001
+ }
4002
+
4003
+ .drawer-empty {
4004
+ padding: var(--ag-ui-pad);
4005
+ font-size: 0.9em;
4006
+ color: var(--ag-ui-muted);
4007
+ }
4008
+
4009
+ .drawer-row {
4010
+ display: flex;
4011
+ align-items: stretch;
4012
+ border-bottom: 1px solid var(--ag-ui-border);
4013
+ }
4014
+
4015
+ .drawer-row--active {
4016
+ background: var(--ag-ui-assistant-bg);
4017
+ }
4018
+
4019
+ .drawer-row-select {
4020
+ flex: 1;
4021
+ min-width: 0;
4022
+ display: flex;
4023
+ flex-direction: column;
4024
+ gap: 2px;
4025
+ padding: 8px 12px;
4026
+ border: none;
4027
+ background: none;
4028
+ color: inherit;
4029
+ font: inherit;
4030
+ text-align: left;
4031
+ cursor: pointer;
4032
+ }
4033
+
4034
+ .drawer-row-title {
4035
+ font-weight: 600;
4036
+ overflow: hidden;
4037
+ white-space: nowrap;
4038
+ text-overflow: ellipsis;
4039
+ }
4040
+
4041
+ .drawer-row-time {
4042
+ font-size: 0.72em;
4043
+ color: var(--ag-ui-muted);
4044
+ }
4045
+
4046
+ .drawer-row-preview {
4047
+ font-size: 0.8em;
4048
+ color: var(--ag-ui-muted);
4049
+ overflow: hidden;
4050
+ white-space: nowrap;
4051
+ text-overflow: ellipsis;
4052
+ }
4053
+
4054
+ .drawer-row-actions {
4055
+ display: flex;
4056
+ align-items: center;
4057
+ gap: 2px;
4058
+ padding: 0 6px;
4059
+ }
4060
+
4061
+ .drawer-row-rename,
4062
+ .drawer-row-delete {
4063
+ border: none;
4064
+ background: none;
4065
+ color: var(--ag-ui-muted);
4066
+ font-size: 0.9em;
4067
+ padding: 4px;
4068
+ cursor: pointer;
4069
+ }
4070
+
4071
+ .drawer-rename-input {
4072
+ flex: 1;
4073
+ min-width: 0;
4074
+ margin: 6px 10px;
4075
+ padding: 4px 8px;
4076
+ border: 1px solid var(--ag-ui-accent);
4077
+ border-radius: 6px;
4078
+ background: var(--ag-ui-input-bg);
4079
+ color: var(--ag-ui-fg);
4080
+ font: inherit;
4081
+ }
4082
+
4083
+ .drawer-confirm {
4084
+ display: flex;
4085
+ align-items: center;
4086
+ gap: 8px;
4087
+ padding: 8px 12px;
4088
+ font-size: 0.85em;
4089
+ }
4090
+
4091
+ .drawer-confirm-label {
4092
+ color: var(--ag-ui-danger);
4093
+ }
4094
+
4095
+ .drawer-confirm-yes {
4096
+ border: none;
4097
+ border-radius: 6px;
4098
+ background: var(--ag-ui-danger);
4099
+ color: #ffffff;
4100
+ padding: 3px 10px;
4101
+ font: inherit;
4102
+ cursor: pointer;
4103
+ }
4104
+
4105
+ .drawer-confirm-no {
4106
+ border: 1px solid var(--ag-ui-border);
4107
+ border-radius: 6px;
4108
+ background: none;
4109
+ color: inherit;
4110
+ padding: 3px 10px;
4111
+ font: inherit;
4112
+ cursor: pointer;
4113
+ }
4114
+
4115
+ /* Embedded placement: an inline, flush side panel rather than a dimmed,
4116
+ floating slide-over. */
4117
+ :host([placement="embedded"]) .drawer-backdrop {
4118
+ background: none;
4119
+ }
4120
+
4121
+ :host([placement="embedded"]) .drawer-panel {
4122
+ width: 100%;
4123
+ border-right: none;
4124
+ box-shadow: none;
4125
+ }
3605
4126
  `;
3606
4127
 
4128
+ // src/ui/relative_time.ts
4129
+ function relativeTime(timestamp, now = Date.now()) {
4130
+ const seconds = Math.round((now - timestamp) / 1e3);
4131
+ if (seconds < 60) {
4132
+ return "just now";
4133
+ }
4134
+ const minutes = Math.round(seconds / 60);
4135
+ if (minutes < 60) {
4136
+ return `${minutes}m ago`;
4137
+ }
4138
+ const hours = Math.round(minutes / 60);
4139
+ if (hours < 24) {
4140
+ return `${hours}h ago`;
4141
+ }
4142
+ const days = Math.round(hours / 24);
4143
+ if (days < 7) {
4144
+ return `${days}d ago`;
4145
+ }
4146
+ return `${Math.round(days / 7)}w ago`;
4147
+ }
4148
+
4149
+ // src/ui/thread_drawer.ts
4150
+ var ThreadDrawer = class {
4151
+ /** The drawer root (backdrop + panel). Append to the chat shell; hidden until opened. */
4152
+ element;
4153
+ #callbacks;
4154
+ #list;
4155
+ #threads = [];
4156
+ #activeId = "";
4157
+ constructor(callbacks) {
4158
+ this.#callbacks = callbacks;
4159
+ this.element = document.createElement("div");
4160
+ this.element.className = "drawer";
4161
+ this.element.hidden = true;
4162
+ const backdrop = document.createElement("div");
4163
+ backdrop.className = "drawer-backdrop";
4164
+ backdrop.addEventListener("click", () => this.close());
4165
+ const panel = document.createElement("div");
4166
+ panel.className = "drawer-panel";
4167
+ panel.setAttribute("role", "dialog");
4168
+ panel.setAttribute("aria-label", "Chat history");
4169
+ const header = document.createElement("div");
4170
+ header.className = "drawer-header";
4171
+ const heading = document.createElement("span");
4172
+ heading.className = "drawer-title";
4173
+ heading.textContent = "Chats";
4174
+ const newButton = document.createElement("button");
4175
+ newButton.type = "button";
4176
+ newButton.className = "drawer-new";
4177
+ newButton.textContent = "New chat";
4178
+ newButton.addEventListener("click", () => {
4179
+ this.close();
4180
+ this.#callbacks.onNew();
4181
+ });
4182
+ header.append(heading, newButton);
4183
+ this.#list = document.createElement("div");
4184
+ this.#list.className = "drawer-list";
4185
+ panel.append(header, this.#list);
4186
+ this.element.append(backdrop, panel);
4187
+ }
4188
+ isOpen() {
4189
+ return !this.element.hidden;
4190
+ }
4191
+ open() {
4192
+ this.element.hidden = false;
4193
+ }
4194
+ close() {
4195
+ this.element.hidden = true;
4196
+ }
4197
+ toggle() {
4198
+ this.element.hidden = !this.element.hidden;
4199
+ }
4200
+ /** Render the rows (or the empty state), highlighting the active thread. */
4201
+ setThreads(threads, activeId) {
4202
+ this.#threads = threads;
4203
+ this.#activeId = activeId;
4204
+ this.#renderList();
4205
+ }
4206
+ #renderList() {
4207
+ this.#list.replaceChildren();
4208
+ if (this.#threads.length === 0) {
4209
+ const empty = document.createElement("div");
4210
+ empty.className = "drawer-empty";
4211
+ empty.textContent = "No conversations yet.";
4212
+ this.#list.appendChild(empty);
4213
+ return;
4214
+ }
4215
+ for (const meta of this.#threads) {
4216
+ this.#list.appendChild(this.#renderRow(meta));
4217
+ }
4218
+ }
4219
+ #renderRow(meta) {
4220
+ const row = document.createElement("div");
4221
+ row.className = "drawer-row";
4222
+ if (meta.threadId === this.#activeId) {
4223
+ row.classList.add("drawer-row--active");
4224
+ }
4225
+ const select = document.createElement("button");
4226
+ select.type = "button";
4227
+ select.className = "drawer-row-select";
4228
+ const title = document.createElement("span");
4229
+ title.className = "drawer-row-title";
4230
+ title.textContent = meta.title;
4231
+ const time = document.createElement("span");
4232
+ time.className = "drawer-row-time";
4233
+ time.textContent = relativeTime(meta.updatedAt);
4234
+ const preview = document.createElement("span");
4235
+ preview.className = "drawer-row-preview";
4236
+ preview.textContent = meta.preview;
4237
+ select.append(title, time, preview);
4238
+ select.addEventListener("click", () => {
4239
+ this.close();
4240
+ this.#callbacks.onSelect(meta.threadId);
4241
+ });
4242
+ const rename = document.createElement("button");
4243
+ rename.type = "button";
4244
+ rename.className = "drawer-row-rename";
4245
+ rename.title = "Rename";
4246
+ rename.setAttribute("aria-label", "Rename conversation");
4247
+ rename.textContent = "\u270E";
4248
+ rename.addEventListener("click", () => this.#startRename(row, meta));
4249
+ const remove = document.createElement("button");
4250
+ remove.type = "button";
4251
+ remove.className = "drawer-row-delete";
4252
+ remove.title = "Delete";
4253
+ remove.setAttribute("aria-label", "Delete conversation");
4254
+ remove.textContent = "\u{1F5D1}";
4255
+ remove.addEventListener("click", () => this.#confirmDelete(row, meta));
4256
+ const actions = document.createElement("div");
4257
+ actions.className = "drawer-row-actions";
4258
+ actions.append(rename, remove);
4259
+ row.append(select, actions);
4260
+ return row;
4261
+ }
4262
+ /** Swap a row for an inline rename input; Enter commits, Escape cancels. */
4263
+ #startRename(row, meta) {
4264
+ const input = document.createElement("input");
4265
+ input.type = "text";
4266
+ input.className = "drawer-rename-input";
4267
+ input.value = meta.title;
4268
+ input.addEventListener("keydown", (event) => {
4269
+ if (event.key === "Enter") {
4270
+ const value = input.value.trim();
4271
+ if (value === "") {
4272
+ this.#renderList();
4273
+ } else {
4274
+ this.#callbacks.onRename(meta.threadId, value);
4275
+ }
4276
+ } else if (event.key === "Escape") {
4277
+ this.#renderList();
4278
+ }
4279
+ });
4280
+ row.replaceChildren(input);
4281
+ input.focus();
4282
+ input.select();
4283
+ }
4284
+ /** Swap a row for an inline "Delete? [Delete] [Cancel]" confirm. */
4285
+ #confirmDelete(row, meta) {
4286
+ const confirm = document.createElement("div");
4287
+ confirm.className = "drawer-confirm";
4288
+ const label = document.createElement("span");
4289
+ label.className = "drawer-confirm-label";
4290
+ label.textContent = "Delete?";
4291
+ const yes = document.createElement("button");
4292
+ yes.type = "button";
4293
+ yes.className = "drawer-confirm-yes";
4294
+ yes.textContent = "Delete";
4295
+ yes.addEventListener("click", () => this.#callbacks.onDelete(meta.threadId));
4296
+ const no = document.createElement("button");
4297
+ no.type = "button";
4298
+ no.className = "drawer-confirm-no";
4299
+ no.textContent = "Cancel";
4300
+ no.addEventListener("click", () => this.#renderList());
4301
+ confirm.append(label, yes, no);
4302
+ row.replaceChildren(confirm);
4303
+ }
4304
+ };
4305
+
3607
4306
  // src/ui/tool_call_card.ts
3608
4307
  var STATUS_LABEL = {
3609
4308
  [TOOL_CALL_STATUS.PENDING]: "running\u2026",
@@ -3684,7 +4383,7 @@ ${text2}`;
3684
4383
  };
3685
4384
 
3686
4385
  // src/core/agui_client.ts
3687
- import { randomUUID } from "@ag-ui/client";
4386
+ import { randomUUID as randomUUID2 } from "@ag-ui/client";
3688
4387
  var AgUiClient = class {
3689
4388
  #agent;
3690
4389
  #handlers;
@@ -3718,9 +4417,18 @@ var AgUiClient = class {
3718
4417
  * When the agent calls frontend tools, this executes them and re-runs the
3719
4418
  * agent with the results, looping until the agent stops calling frontend
3720
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.
3721
4425
  */
3722
- async send(content) {
3723
- 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);
3724
4432
  this.#onPersist(this.#agent.messages);
3725
4433
  await this.#run();
3726
4434
  }
@@ -3734,7 +4442,7 @@ var AgUiClient = class {
3734
4442
  }
3735
4443
  /** Append a frontend tool result to history (used by the resume path). */
3736
4444
  addToolResult(toolCallId, content) {
3737
- this.#agent.addMessage({ id: randomUUID(), role: "tool", content, toolCallId });
4445
+ this.#agent.addMessage({ id: randomUUID2(), role: "tool", content, toolCallId });
3738
4446
  this.#onPersist(this.#agent.messages);
3739
4447
  }
3740
4448
  /**
@@ -3796,7 +4504,7 @@ var AgUiClient = class {
3796
4504
  return;
3797
4505
  }
3798
4506
  this.#agent.addMessage({
3799
- id: randomUUID(),
4507
+ id: randomUUID2(),
3800
4508
  role: "tool",
3801
4509
  content: result.content,
3802
4510
  toolCallId: call.id
@@ -3846,18 +4554,28 @@ function isAbortError(error) {
3846
4554
  return error instanceof Error && error.name === "AbortError";
3847
4555
  }
3848
4556
 
4557
+ // src/core/attachment.ts
4558
+ function messageAttachments(message) {
4559
+ const refs = message.attachments;
4560
+ return Array.isArray(refs) ? refs : [];
4561
+ }
4562
+
3849
4563
  // src/core/conversation_store.ts
3850
- import { randomUUID as randomUUID2 } from "@ag-ui/client";
4564
+ import { randomUUID as randomUUID3 } from "@ag-ui/client";
3851
4565
  var THREAD_KEY = "ag-ui-chat:thread";
4566
+ var THREADS_KEY = "ag-ui-chat:threads";
3852
4567
  var MESSAGES_PREFIX = "ag-ui-chat:messages:";
3853
4568
  var CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
4569
+ var TITLE_LIMIT = 60;
4570
+ var PREVIEW_LIMIT = 100;
4571
+ var DEFAULT_TITLE = "New conversation";
3854
4572
  var SessionStorageStore = class {
3855
4573
  threadId() {
3856
4574
  const existing = sessionStorage.getItem(THREAD_KEY);
3857
4575
  if (existing !== null) {
3858
4576
  return existing;
3859
4577
  }
3860
- const id = randomUUID2();
4578
+ const id = randomUUID3();
3861
4579
  sessionStorage.setItem(THREAD_KEY, id);
3862
4580
  return id;
3863
4581
  }
@@ -3866,6 +4584,7 @@ var SessionStorageStore = class {
3866
4584
  }
3867
4585
  saveMessages(threadId, messages) {
3868
4586
  sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
4587
+ this.#touchThread(threadId, messages);
3869
4588
  }
3870
4589
  loadCheckpoint(threadId) {
3871
4590
  return this.#readJson(CHECKPOINT_PREFIX + threadId);
@@ -3881,7 +4600,60 @@ var SessionStorageStore = class {
3881
4600
  clear(threadId) {
3882
4601
  sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
3883
4602
  sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
3884
- sessionStorage.removeItem(THREAD_KEY);
4603
+ this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
4604
+ if (sessionStorage.getItem(THREAD_KEY) === threadId) {
4605
+ sessionStorage.removeItem(THREAD_KEY);
4606
+ }
4607
+ }
4608
+ listThreads() {
4609
+ const metas = this.#readThreads().sort((a, b2) => b2.updatedAt - a.updatedAt).map(({ threadId, title, updatedAt, preview }) => ({ threadId, title, updatedAt, preview }));
4610
+ return Promise.resolve(metas);
4611
+ }
4612
+ setActiveThread(threadId) {
4613
+ sessionStorage.setItem(THREAD_KEY, threadId);
4614
+ }
4615
+ renameThread(threadId, title) {
4616
+ const threads = this.#readThreads();
4617
+ const entry = threads.find((thread) => thread.threadId === threadId);
4618
+ if (entry === void 0) {
4619
+ return;
4620
+ }
4621
+ entry.title = title;
4622
+ entry.titleCustom = true;
4623
+ this.#writeThreads(threads);
4624
+ }
4625
+ /** Add or refresh a thread's drawer metadata from its latest messages. */
4626
+ #touchThread(threadId, messages) {
4627
+ const threads = this.#readThreads();
4628
+ const entry = threads.find((thread) => thread.threadId === threadId);
4629
+ const preview = derivePreview(messages);
4630
+ const updatedAt = Date.now();
4631
+ if (entry === void 0) {
4632
+ threads.push({
4633
+ threadId,
4634
+ title: deriveTitle(messages),
4635
+ titleCustom: false,
4636
+ preview,
4637
+ updatedAt
4638
+ });
4639
+ } else {
4640
+ entry.preview = preview;
4641
+ entry.updatedAt = updatedAt;
4642
+ if (!entry.titleCustom) {
4643
+ entry.title = deriveTitle(messages);
4644
+ }
4645
+ }
4646
+ this.#writeThreads(threads);
4647
+ }
4648
+ #readThreads() {
4649
+ return this.#readJson(THREADS_KEY) ?? [];
4650
+ }
4651
+ #writeThreads(threads) {
4652
+ if (threads.length === 0) {
4653
+ sessionStorage.removeItem(THREADS_KEY);
4654
+ return;
4655
+ }
4656
+ sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
3885
4657
  }
3886
4658
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
3887
4659
  #readJson(key) {
@@ -3896,6 +4668,32 @@ var SessionStorageStore = class {
3896
4668
  }
3897
4669
  }
3898
4670
  };
4671
+ function deriveTitle(messages) {
4672
+ for (const message of messages) {
4673
+ if (message.role === "user") {
4674
+ const text2 = cleanText(message.content);
4675
+ if (text2 !== "") {
4676
+ return truncate(text2, TITLE_LIMIT);
4677
+ }
4678
+ }
4679
+ }
4680
+ return DEFAULT_TITLE;
4681
+ }
4682
+ function derivePreview(messages) {
4683
+ for (const message of [...messages].reverse()) {
4684
+ const text2 = cleanText(message.content);
4685
+ if (text2 !== "") {
4686
+ return truncate(text2, PREVIEW_LIMIT);
4687
+ }
4688
+ }
4689
+ return "";
4690
+ }
4691
+ function cleanText(content) {
4692
+ return typeof content === "string" ? content.replace(/\s+/g, " ").trim() : "";
4693
+ }
4694
+ function truncate(text2, limit) {
4695
+ return text2.length <= limit ? text2 : `${text2.slice(0, limit - 1).trimEnd()}\u2026`;
4696
+ }
3899
4697
 
3900
4698
  // src/core/create_http_agent.ts
3901
4699
  import { HttpAgent } from "@ag-ui/client";
@@ -3927,6 +4725,160 @@ function createHttpAgent(options) {
3927
4725
  });
3928
4726
  }
3929
4727
 
4728
+ // src/core/remote_conversation_store.ts
4729
+ var RemoteConversationStore = class {
4730
+ #url;
4731
+ #headers;
4732
+ #local;
4733
+ #dropped = /* @__PURE__ */ new Set();
4734
+ #renamed = /* @__PURE__ */ new Map();
4735
+ constructor(url, headers = () => ({}), local = new SessionStorageStore()) {
4736
+ this.#url = url.endsWith("/") ? url : `${url}/`;
4737
+ this.#headers = headers;
4738
+ this.#local = local;
4739
+ }
4740
+ threadId() {
4741
+ return this.#local.threadId();
4742
+ }
4743
+ setActiveThread(threadId) {
4744
+ this.#local.setActiveThread(threadId);
4745
+ }
4746
+ saveMessages(threadId, messages) {
4747
+ this.#local.saveMessages(threadId, messages);
4748
+ }
4749
+ loadCheckpoint(threadId) {
4750
+ return this.#local.loadCheckpoint(threadId);
4751
+ }
4752
+ saveCheckpoint(threadId, checkpoint) {
4753
+ this.#local.saveCheckpoint(threadId, checkpoint);
4754
+ }
4755
+ renameThread(threadId, title) {
4756
+ this.#local.renameThread(threadId, title);
4757
+ this.#renamed.set(threadId, title);
4758
+ void this.#mutate(threadId, "PATCH", { title });
4759
+ }
4760
+ clear(threadId) {
4761
+ this.#local.clear(threadId);
4762
+ this.#dropped.add(threadId);
4763
+ void this.#mutate(threadId, "DELETE");
4764
+ }
4765
+ async listThreads() {
4766
+ const rows = await this.#fetchThreads();
4767
+ if (rows === null) {
4768
+ return this.#local.listThreads();
4769
+ }
4770
+ return rows.filter((row) => !this.#dropped.has(row.thread_id)).map((row) => this.#toMeta(row));
4771
+ }
4772
+ async loadMessages(threadId) {
4773
+ const response = await this.#get(this.#url + encodeURIComponent(threadId) + "/");
4774
+ if (response === null || !response.ok) {
4775
+ return this.#local.loadMessages(threadId);
4776
+ }
4777
+ const body = await response.json();
4778
+ return body.messages ?? null;
4779
+ }
4780
+ async #fetchThreads() {
4781
+ const response = await this.#get(this.#url);
4782
+ if (response === null || !response.ok) {
4783
+ return null;
4784
+ }
4785
+ const body = await response.json();
4786
+ return body.threads ?? [];
4787
+ }
4788
+ #toMeta(row) {
4789
+ return {
4790
+ threadId: row.thread_id,
4791
+ title: this.#renamed.get(row.thread_id) ?? row.title,
4792
+ updatedAt: row.updated_at === null ? 0 : Date.parse(row.updated_at),
4793
+ preview: row.preview
4794
+ };
4795
+ }
4796
+ /** GET that resolves to the `Response`, or `null` on a network error. */
4797
+ async #get(url) {
4798
+ try {
4799
+ return await fetch(url, { headers: this.#headers() });
4800
+ } catch {
4801
+ return null;
4802
+ }
4803
+ }
4804
+ /** Fire a best-effort write to the thread endpoint; failures are tolerated. */
4805
+ async #mutate(threadId, method, body) {
4806
+ const headers = this.#headers();
4807
+ try {
4808
+ await fetch(this.#url + encodeURIComponent(threadId) + "/", {
4809
+ method,
4810
+ headers: body === void 0 ? headers : { ...headers, "content-type": "application/json" },
4811
+ body: body === void 0 ? null : JSON.stringify(body)
4812
+ });
4813
+ } catch {
4814
+ }
4815
+ }
4816
+ };
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
+
3930
4882
  // src/core/ag_ui_chat.ts
3931
4883
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
3932
4884
  var AgUiChat = class extends HTMLElement {
@@ -3967,9 +4919,14 @@ var AgUiChat = class extends HTMLElement {
3967
4919
  ];
3968
4920
  /**
3969
4921
  * Per-run context provider. Defaults to the compact page map (when a
3970
- * {@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.
3971
4925
  */
3972
- getContext = () => createPageMapContext(this.getPageMap, this.autoInjectPageMap);
4926
+ getContext = () => [
4927
+ ...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
4928
+ ...this.#attachmentContext()
4929
+ ];
3973
4930
  /**
3974
4931
  * Navigable routes the agent can jump to via the built-in `route.*` tools.
3975
4932
  * A compact summary also rides in each run's context.
@@ -3991,6 +4948,16 @@ var AgUiChat = class extends HTMLElement {
3991
4948
  * server-backed store for cross-tab/device durability.
3992
4949
  */
3993
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;
3994
4961
  /**
3995
4962
  * Builds the tool result a navigating tool resumes with after the page
3996
4963
  * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
@@ -4037,7 +5004,16 @@ var AgUiChat = class extends HTMLElement {
4037
5004
  #send;
4038
5005
  #title;
4039
5006
  #skillsMenu;
5007
+ #drawer;
4040
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 = [];
4041
5017
  #client = null;
4042
5018
  // Whether an interaction is in flight (first onRunStart → onSettled). Drives
4043
5019
  // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
@@ -4067,7 +5043,26 @@ var AgUiChat = class extends HTMLElement {
4067
5043
  this.#send = document.createElement("button");
4068
5044
  this.#title = document.createElement("span");
4069
5045
  this.#skillHint = document.createElement("div");
5046
+ this.#attachButton = document.createElement("button");
5047
+ this.#fileInput = document.createElement("input");
5048
+ this.#attachSlot = document.createElement("div");
4070
5049
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
5050
+ this.#drawer = new ThreadDrawer({
5051
+ onSelect: (threadId) => {
5052
+ void this.#switchThread(threadId);
5053
+ },
5054
+ onNew: () => {
5055
+ this.newChat();
5056
+ void this.#refreshDrawer();
5057
+ },
5058
+ onRename: (threadId, title) => {
5059
+ this.conversationStore.renameThread(threadId, title);
5060
+ void this.#refreshDrawer();
5061
+ },
5062
+ onDelete: (threadId) => {
5063
+ this.#deleteThread(threadId);
5064
+ }
5065
+ });
4071
5066
  }
4072
5067
  /** Attributes whose late changes must reflect in already-rendered chrome. */
4073
5068
  static get observedAttributes() {
@@ -4163,9 +5158,113 @@ var AgUiChat = class extends HTMLElement {
4163
5158
  }
4164
5159
  this.#initSkills();
4165
5160
  void this.#fetchToolCatalog();
5161
+ this.#wireThreadStore();
5162
+ this.#wireAttachments();
4166
5163
  this.#threadId = this.conversationStore.threadId();
4167
5164
  void this.#rehydrate();
4168
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
+ }
5252
+ /**
5253
+ * When `data-threads-url` is set, route thread enumeration / load / rename /
5254
+ * delete through that server endpoint (wrapping the current store as the
5255
+ * client-only fallback), so the history drawer shows durable, cross-device
5256
+ * threads. Without it, the client store's per-tab threads are used.
5257
+ */
5258
+ #wireThreadStore() {
5259
+ const url = this.getAttribute("data-threads-url");
5260
+ if (url !== null) {
5261
+ this.conversationStore = new RemoteConversationStore(
5262
+ url,
5263
+ () => this.headers,
5264
+ this.conversationStore
5265
+ );
5266
+ }
5267
+ }
4169
5268
  /** Fetch the server tool-label catalog from `data-tools-url`, if set. */
4170
5269
  async #fetchToolCatalog() {
4171
5270
  const url = this.getAttribute("data-tools-url");
@@ -4283,15 +5382,51 @@ var AgUiChat = class extends HTMLElement {
4283
5382
  newChat() {
4284
5383
  this.#cancelRun();
4285
5384
  this.conversationStore.clear(this.#threadId);
5385
+ this.#resetState();
5386
+ this.#threadId = this.conversationStore.threadId();
5387
+ this.#setRunning(false);
5388
+ }
5389
+ /** Drop the in-memory run + transcript, leaving the thread id untouched. */
5390
+ #resetState() {
4286
5391
  this.#client = null;
4287
5392
  this.#streamingBubble = null;
4288
5393
  this.#hidePending();
4289
5394
  this.#toolCards.clear();
4290
5395
  this.#serverSettled.clear();
4291
5396
  this.#initialMessages = [];
5397
+ this.#runAttachments = [];
5398
+ this.#attachTray?.clear();
4292
5399
  this.#messages.replaceChildren();
4293
- this.#threadId = this.conversationStore.threadId();
5400
+ }
5401
+ /** Switch the active conversation to an existing thread and replay it. */
5402
+ async #switchThread(threadId) {
5403
+ if (threadId === this.#threadId) {
5404
+ return;
5405
+ }
5406
+ this.#cancelRun();
5407
+ this.#resetState();
5408
+ this.conversationStore.setActiveThread(threadId);
5409
+ this.#threadId = threadId;
4294
5410
  this.#setRunning(false);
5411
+ await this.#rehydrate();
5412
+ }
5413
+ /** Delete a thread; if it was the active one, fall back to a fresh chat. */
5414
+ #deleteThread(threadId) {
5415
+ const wasActive = threadId === this.#threadId;
5416
+ if (wasActive) {
5417
+ this.#cancelRun();
5418
+ }
5419
+ this.conversationStore.clear(threadId);
5420
+ if (wasActive) {
5421
+ this.#resetState();
5422
+ this.#threadId = this.conversationStore.threadId();
5423
+ this.#setRunning(false);
5424
+ }
5425
+ void this.#refreshDrawer();
5426
+ }
5427
+ /** Reload the drawer's thread list, marking the active thread. */
5428
+ async #refreshDrawer() {
5429
+ this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
4295
5430
  }
4296
5431
  /**
4297
5432
  * Restore the conversation from the store on mount, then — if a navigating
@@ -4320,8 +5455,12 @@ var AgUiChat = class extends HTMLElement {
4320
5455
  #renderHistoricMessage(message) {
4321
5456
  const text2 = typeof message.content === "string" ? message.content : "";
4322
5457
  if (message.role === MESSAGE_ROLE.USER) {
4323
- if (text2 !== "") {
4324
- 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
+ }
4325
5464
  }
4326
5465
  return;
4327
5466
  }
@@ -4405,6 +5544,16 @@ var AgUiChat = class extends HTMLElement {
4405
5544
  title.textContent = this.getAttribute("title-text") ?? "Assistant";
4406
5545
  const controls = document.createElement("div");
4407
5546
  controls.className = "header-controls";
5547
+ const history = document.createElement("button");
5548
+ history.type = "button";
5549
+ history.className = "header-btn header-btn--history";
5550
+ history.title = "Chat history";
5551
+ history.setAttribute("aria-label", "Chat history");
5552
+ history.textContent = "\u2630";
5553
+ history.addEventListener("click", () => {
5554
+ void this.#refreshDrawer();
5555
+ this.#drawer.open();
5556
+ });
4408
5557
  const newChat = document.createElement("button");
4409
5558
  newChat.type = "button";
4410
5559
  newChat.className = "header-btn header-btn--new";
@@ -4419,7 +5568,7 @@ var AgUiChat = class extends HTMLElement {
4419
5568
  collapse.setAttribute("aria-label", "Collapse");
4420
5569
  collapse.textContent = "\u2014";
4421
5570
  collapse.addEventListener("click", () => this.toggleCollapsed());
4422
- controls.append(newChat, collapse);
5571
+ controls.append(history, newChat, collapse);
4423
5572
  header.append(title, controls);
4424
5573
  this.#messages.className = "messages";
4425
5574
  this.#messages.setAttribute("role", "log");
@@ -4447,14 +5596,29 @@ var AgUiChat = class extends HTMLElement {
4447
5596
  });
4448
5597
  this.#skillHint.className = "skill-hint";
4449
5598
  this.#skillHint.hidden = true;
4450
- 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);
4451
5613
  this.#chat.append(
4452
5614
  header,
4453
5615
  this.#messages,
4454
5616
  this.#skillsMenu.palette,
4455
5617
  this.#skillsMenu.chips,
4456
5618
  this.#skillHint,
4457
- inputRow
5619
+ this.#attachSlot,
5620
+ inputRow,
5621
+ this.#drawer.element
4458
5622
  );
4459
5623
  this.#root.append(style, this.#chat);
4460
5624
  }
@@ -4498,25 +5662,31 @@ var AgUiChat = class extends HTMLElement {
4498
5662
  }
4499
5663
  async #submit() {
4500
5664
  const content = this.#input.value.trim();
4501
- if (content === "") {
5665
+ const attachments = this.#attachTray?.readyRefs() ?? [];
5666
+ if (content === "" && attachments.length === 0) {
4502
5667
  return;
4503
5668
  }
4504
- 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
+ }
4505
5673
  this.#input.value = "";
5674
+ this.#attachTray?.clearReady();
5675
+ this.#runAttachments = attachments;
4506
5676
  this.dispatchEvent(
4507
5677
  new CustomEvent(SUBMIT_EVENT, {
4508
- detail: { content },
5678
+ detail: { content, attachments },
4509
5679
  bubbles: true,
4510
5680
  composed: true
4511
5681
  })
4512
5682
  );
4513
- await this.#client_send(content);
5683
+ await this.#client_send(content, attachments);
4514
5684
  }
4515
- async #client_send(content) {
5685
+ async #client_send(content, attachments) {
4516
5686
  if (this.endpoint === "") {
4517
5687
  return;
4518
5688
  }
4519
- await this.#ensureClient().send(content);
5689
+ await this.#ensureClient().send(content, attachments);
4520
5690
  }
4521
5691
  #ensureClient() {
4522
5692
  if (this.#client === null) {
@@ -4653,6 +5823,7 @@ var AgUiChat = class extends HTMLElement {
4653
5823
  this.#hidePending();
4654
5824
  this.#setRunning(false);
4655
5825
  this.#streamingBubble = null;
5826
+ this.#runAttachments = [];
4656
5827
  }
4657
5828
  };
4658
5829
  }
@@ -4886,7 +6057,7 @@ function setControlValue(el, value) {
4886
6057
  }
4887
6058
 
4888
6059
  // src/version.ts
4889
- var VERSION = "0.4.0";
6060
+ var VERSION = "0.6.0";
4890
6061
  export {
4891
6062
  AgUiChat,
4892
6063
  AgUiClient,
@@ -4894,6 +6065,7 @@ export {
4894
6065
  ELEMENT_TAG,
4895
6066
  MAX_TOOL_ROUNDS,
4896
6067
  MESSAGE_ROLE,
6068
+ RemoteConversationStore,
4897
6069
  SUBMIT_EVENT,
4898
6070
  SessionStorageStore,
4899
6071
  TOGGLE_EVENT,
@@ -4916,6 +6088,7 @@ export {
4916
6088
  highlightThenClick,
4917
6089
  isDestructive,
4918
6090
  isNavigates,
6091
+ messageAttachments,
4919
6092
  parseToolCatalog,
4920
6093
  prefersReducedMotion,
4921
6094
  pressButton,
@@ -4931,7 +6104,8 @@ export {
4931
6104
  setNativeValue,
4932
6105
  toggleCheckbox,
4933
6106
  toggleControl,
4934
- typeInto
6107
+ typeInto,
6108
+ uploadAttachment
4935
6109
  };
4936
6110
  /*! Bundled license information:
4937
6111