@artooi/ag-ui-web-component 0.9.0 → 0.10.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 (42) hide show
  1. package/CHANGELOG.md +61 -7
  2. package/dist/ag-ui-web-component.bundle.js +56 -56
  3. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  4. package/dist/core/ag_ui_chat.d.ts +10 -1
  5. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  6. package/dist/core/agui_client.d.ts.map +1 -1
  7. package/dist/core/attachment.d.ts +5 -0
  8. package/dist/core/attachment.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +8 -0
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  12. package/dist/core/upload_attachment.d.ts +8 -2
  13. package/dist/core/upload_attachment.d.ts.map +1 -1
  14. package/dist/index.js +300 -58
  15. package/dist/index.js.map +2 -2
  16. package/dist/ui/attachment_tray.d.ts +7 -1
  17. package/dist/ui/attachment_tray.d.ts.map +1 -1
  18. package/dist/ui/relative_time.d.ts +5 -3
  19. package/dist/ui/relative_time.d.ts.map +1 -1
  20. package/dist/ui/styles.d.ts +1 -1
  21. package/dist/ui/styles.d.ts.map +1 -1
  22. package/dist/ui/thoughts_block.d.ts +2 -2
  23. package/dist/ui/thread_drawer.d.ts.map +1 -1
  24. package/dist/ui/tool_call_card.d.ts.map +1 -1
  25. package/dist/ui/voice_input.d.ts +9 -1
  26. package/dist/ui/voice_input.d.ts.map +1 -1
  27. package/dist/version.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/core/ag_ui_chat.ts +79 -11
  30. package/src/core/agui_client.ts +14 -3
  31. package/src/core/attachment.ts +21 -1
  32. package/src/core/conversation_store.ts +84 -18
  33. package/src/core/remote_conversation_store.ts +24 -3
  34. package/src/core/upload_attachment.ts +8 -1
  35. package/src/ui/attachment_tray.ts +42 -5
  36. package/src/ui/relative_time.ts +8 -3
  37. package/src/ui/styles.ts +9 -9
  38. package/src/ui/thoughts_block.ts +2 -2
  39. package/src/ui/thread_drawer.ts +83 -9
  40. package/src/ui/tool_call_card.ts +6 -0
  41. package/src/ui/voice_input.ts +21 -1
  42. package/src/version.ts +1 -1
package/dist/index.js CHANGED
@@ -591,7 +591,8 @@ var AttachmentTray = class {
591
591
  status: ATTACHMENT_STATUS.UPLOADING,
592
592
  progress: 0,
593
593
  ref: null,
594
- error: ""
594
+ error: "",
595
+ controller: null
595
596
  };
596
597
  this.#items.push(item);
597
598
  const rejection = this.#reject(file);
@@ -629,11 +630,24 @@ var AttachmentTray = class {
629
630
  this.#items = this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
630
631
  this.#render();
631
632
  }
632
- /** Drop every chip (a reset / new-chat). */
633
+ /** Drop every chip (a reset / new-chat), aborting any in-flight upload. */
633
634
  clear() {
635
+ for (const item of this.#items) {
636
+ item.controller?.abort();
637
+ }
634
638
  this.#items = [];
635
639
  this.#render();
636
640
  }
641
+ /**
642
+ * Abort every in-flight upload without touching the rendered chips — the
643
+ * teardown path when the host element is removed mid-upload, so a cancelled
644
+ * transfer doesn't orphan a server-side file.
645
+ */
646
+ dispose() {
647
+ for (const item of this.#items) {
648
+ item.controller?.abort();
649
+ }
650
+ }
637
651
  /** The size/type rejection reason for a file, or `null` when accepted. */
638
652
  #reject(file) {
639
653
  if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
@@ -645,25 +659,41 @@ var AttachmentTray = class {
645
659
  return null;
646
660
  }
647
661
  #upload(item) {
662
+ const rejection = this.#reject(item.file);
663
+ if (rejection !== null) {
664
+ item.status = ATTACHMENT_STATUS.ERROR;
665
+ item.error = rejection;
666
+ this.#render();
667
+ this.#config.onChange?.();
668
+ return;
669
+ }
648
670
  item.status = ATTACHMENT_STATUS.UPLOADING;
649
671
  item.progress = 0;
650
672
  item.error = "";
673
+ const controller = new AbortController();
674
+ item.controller = controller;
651
675
  this.#render();
652
- this.#config.upload(item.file, (fraction) => {
653
- item.progress = fraction;
654
- this.#render();
655
- }).then((ref) => {
676
+ this.#config.upload(
677
+ item.file,
678
+ (fraction) => {
679
+ item.progress = fraction;
680
+ this.#render();
681
+ },
682
+ controller.signal
683
+ ).then((ref) => {
656
684
  item.status = ATTACHMENT_STATUS.READY;
657
685
  item.ref = ref;
658
686
  }).catch((error) => {
659
687
  item.status = ATTACHMENT_STATUS.ERROR;
660
688
  item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
661
689
  }).finally(() => {
690
+ item.controller = null;
662
691
  this.#render();
663
692
  this.#config.onChange?.();
664
693
  });
665
694
  }
666
695
  #remove(item) {
696
+ item.controller?.abort();
667
697
  this.#items = this.#items.filter((other) => other !== item);
668
698
  this.#render();
669
699
  this.#config.onChange?.();
@@ -3617,7 +3647,7 @@ var STYLES = `
3617
3647
  --ag-ui-radius: 0;
3618
3648
  }
3619
3649
 
3620
- /* Page (PAGE-1): full-bleed background with a centred reading column. Unlike
3650
+ /* Page: full-bleed background with a centred reading column. Unlike
3621
3651
  "full" (edge-to-edge, left-aligned messages) the content sits in a column
3622
3652
  capped at --ag-ui-content-max-width. The column is produced by symmetric auto
3623
3653
  padding on the scroll area + composer (no per-row wrapper), so user pills
@@ -3659,7 +3689,7 @@ var STYLES = `
3659
3689
  max-width: 100%;
3660
3690
  }
3661
3691
 
3662
- /* Sidebar (CUST-3): a full-height docked panel that slides open/closed and
3692
+ /* Sidebar: a full-height docked panel that slides open/closed and
3663
3693
  collapses to a slim icon rail (not the floating launcher). Docked right by
3664
3694
  default; data-side="left" docks it left. Overlay by default \u2014 set
3665
3695
  --ag-ui-position: static (and place this element in your own layout) for a
@@ -3769,7 +3799,7 @@ var STYLES = `
3769
3799
  white-space: nowrap;
3770
3800
  }
3771
3801
 
3772
- /* Header / launcher icon holder (CUST-2): a slot, with a data-icon-url <img>
3802
+ /* Header / launcher icon holder: a slot, with a data-icon-url <img>
3773
3803
  fallback, sized via --ag-ui-icon-size. */
3774
3804
  .icon-holder {
3775
3805
  display: inline-flex;
@@ -3843,7 +3873,7 @@ var STYLES = `
3843
3873
  gap: var(--ag-ui-space);
3844
3874
  }
3845
3875
 
3846
- /* Empty-state region (CUST-1 slot): centred while it's the only thing in the
3876
+ /* Empty-state region (slot): centred while it's the only thing in the
3847
3877
  list, hidden as soon as a message, card, or pending indicator renders. */
3848
3878
  .empty {
3849
3879
  margin: auto;
@@ -3855,7 +3885,7 @@ var STYLES = `
3855
3885
  display: none;
3856
3886
  }
3857
3887
 
3858
- /* \u2500\u2500 Answer group / well (WELL-1) \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
3888
+ /* \u2500\u2500 Answer group / well \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
3859
3889
  One .answer per assistant turn wraps the streamed text, its tool cards,
3860
3890
  and the pending indicator so a whole answer reads (and can be boxed) as one
3861
3891
  unit. A flex column on the message-list gap, stretched to the list width so
@@ -4024,7 +4054,7 @@ var STYLES = `
4024
4054
  }
4025
4055
  }
4026
4056
 
4027
- /* \u2500\u2500 Thoughts region (THINK-1) \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
4057
+ /* \u2500\u2500 Thoughts region \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
4028
4058
  A muted, collapsible chain-of-thought at the top of the answer group: open
4029
4059
  while the model reasons, folded once the answer text starts. */
4030
4060
  .thoughts {
@@ -4117,7 +4147,7 @@ var STYLES = `
4117
4147
  word-break: break-word;
4118
4148
  }
4119
4149
 
4120
- /* Leading status icon (CARD-1). Empty in the DOM \u2014 the glyph/spinner is drawn
4150
+ /* Leading status icon. Empty in the DOM \u2014 the glyph/spinner is drawn
4121
4151
  here from the card's data-status, so it stays themeable. */
4122
4152
  .tool-call-icon {
4123
4153
  flex: none;
@@ -4165,7 +4195,7 @@ var STYLES = `
4165
4195
  }
4166
4196
  }
4167
4197
 
4168
- /* Inline display mode (CARD-1): the lightest card \u2014 drop the box chrome so the
4198
+ /* Inline display mode: the lightest card \u2014 drop the box chrome so the
4169
4199
  status row reads as one line of the answer; the result toggle still expands
4170
4200
  below it. */
4171
4201
  .tool-call[data-display="inline"] {
@@ -4295,7 +4325,7 @@ var STYLES = `
4295
4325
  display: none;
4296
4326
  }
4297
4327
 
4298
- /* The \u{1F3A4} mic button (VOICE-1); shown only once #wireVoice mounts it. */
4328
+ /* The \u{1F3A4} mic button; shown only once #wireVoice mounts it. */
4299
4329
  .voice-slot {
4300
4330
  display: contents;
4301
4331
  }
@@ -4810,6 +4840,9 @@ var ThoughtsBlock = class {
4810
4840
 
4811
4841
  // src/ui/relative_time.ts
4812
4842
  function relativeTime(timestamp, now = Date.now(), strings = DEFAULT_UI_STRINGS) {
4843
+ if (!Number.isFinite(timestamp)) {
4844
+ return strings.justNow;
4845
+ }
4813
4846
  const seconds = Math.round((now - timestamp) / 1e3);
4814
4847
  if (seconds < 60) {
4815
4848
  return strings.justNow;
@@ -4841,6 +4874,8 @@ var ThreadDrawer = class {
4841
4874
  #strings;
4842
4875
  #threads = [];
4843
4876
  #activeId = "";
4877
+ /** The element focused before the drawer opened, restored on close. */
4878
+ #lastFocused = null;
4844
4879
  constructor(callbacks, strings = DEFAULT_UI_STRINGS) {
4845
4880
  this.#callbacks = callbacks;
4846
4881
  this.#strings = strings;
@@ -4856,7 +4891,9 @@ var ThreadDrawer = class {
4856
4891
  this.#panel.className = "drawer-panel";
4857
4892
  this.#panel.setAttribute("part", "drawer-panel");
4858
4893
  this.#panel.setAttribute("role", "dialog");
4894
+ this.#panel.setAttribute("aria-modal", "true");
4859
4895
  this.#panel.setAttribute("aria-label", strings.chatHistory);
4896
+ this.#panel.addEventListener("keydown", (event) => this.#onPanelKeydown(event));
4860
4897
  const header = document.createElement("div");
4861
4898
  header.className = "drawer-header";
4862
4899
  header.setAttribute("part", "drawer-header");
@@ -4892,13 +4929,55 @@ var ThreadDrawer = class {
4892
4929
  return !this.element.hidden;
4893
4930
  }
4894
4931
  open() {
4932
+ if (this.isOpen()) {
4933
+ return;
4934
+ }
4935
+ this.#lastFocused = this.#activeElement();
4895
4936
  this.element.hidden = false;
4937
+ this.#newButton.focus();
4896
4938
  }
4897
4939
  close() {
4940
+ if (!this.isOpen()) {
4941
+ return;
4942
+ }
4898
4943
  this.element.hidden = true;
4944
+ this.#lastFocused?.focus();
4945
+ this.#lastFocused = null;
4899
4946
  }
4900
4947
  toggle() {
4901
- this.element.hidden = !this.element.hidden;
4948
+ if (this.isOpen()) {
4949
+ this.close();
4950
+ } else {
4951
+ this.open();
4952
+ }
4953
+ }
4954
+ /** The currently-focused element within the drawer's root (shadow-aware). */
4955
+ #activeElement() {
4956
+ return this.element.getRootNode().activeElement;
4957
+ }
4958
+ /** Escape-to-close and a Tab focus trap while the dialog is open. */
4959
+ #onPanelKeydown(event) {
4960
+ if (event.key === "Escape") {
4961
+ event.preventDefault();
4962
+ this.close();
4963
+ return;
4964
+ }
4965
+ if (event.key !== "Tab") {
4966
+ return;
4967
+ }
4968
+ const focusables = Array.from(
4969
+ this.#panel.querySelectorAll("button, input, [tabindex]")
4970
+ ).filter((el) => !el.hidden);
4971
+ const first = focusables[0];
4972
+ const last = focusables[focusables.length - 1];
4973
+ const active = this.#activeElement();
4974
+ if (event.shiftKey && active === first) {
4975
+ event.preventDefault();
4976
+ last?.focus();
4977
+ } else if (!event.shiftKey && active === last) {
4978
+ event.preventDefault();
4979
+ first?.focus();
4980
+ }
4902
4981
  }
4903
4982
  /** Render the rows (or the empty state), highlighting the active thread. */
4904
4983
  setThreads(threads, activeId) {
@@ -4965,24 +5044,43 @@ var ThreadDrawer = class {
4965
5044
  row.append(select, actions);
4966
5045
  return row;
4967
5046
  }
4968
- /** Swap a row for an inline rename input; Enter commits, Escape cancels. */
5047
+ /** Swap a row for an inline rename input; Enter/blur commits, Escape cancels. */
4969
5048
  #startRename(row, meta) {
4970
5049
  const input = document.createElement("input");
4971
5050
  input.type = "text";
4972
5051
  input.className = "drawer-rename-input";
4973
5052
  input.value = meta.title;
5053
+ let done = false;
5054
+ const commit = () => {
5055
+ if (done) {
5056
+ return;
5057
+ }
5058
+ done = true;
5059
+ const value = input.value.trim();
5060
+ if (value === "" || value === meta.title) {
5061
+ this.#renderList();
5062
+ } else {
5063
+ this.#callbacks.onRename(meta.threadId, value);
5064
+ }
5065
+ };
5066
+ const cancel = () => {
5067
+ if (done) {
5068
+ return;
5069
+ }
5070
+ done = true;
5071
+ this.#renderList();
5072
+ };
4974
5073
  input.addEventListener("keydown", (event) => {
4975
5074
  if (event.key === "Enter") {
4976
- const value = input.value.trim();
4977
- if (value === "") {
4978
- this.#renderList();
4979
- } else {
4980
- this.#callbacks.onRename(meta.threadId, value);
4981
- }
5075
+ event.preventDefault();
5076
+ commit();
4982
5077
  } else if (event.key === "Escape") {
4983
- this.#renderList();
5078
+ event.preventDefault();
5079
+ event.stopPropagation();
5080
+ cancel();
4984
5081
  }
4985
5082
  });
5083
+ input.addEventListener("blur", () => commit());
4986
5084
  row.replaceChildren(input);
4987
5085
  input.focus();
4988
5086
  input.select();
@@ -5078,6 +5176,9 @@ var ToolCallCard = class {
5078
5176
  * `inline`), or the args + result together (`compact`).
5079
5177
  */
5080
5178
  settle(status, text2) {
5179
+ if (this.#settled) {
5180
+ return;
5181
+ }
5081
5182
  this.#settled = true;
5082
5183
  this.element.setAttribute("data-status", status);
5083
5184
  this.#status.textContent = statusLabels(this.#strings)[status];
@@ -5122,6 +5223,7 @@ var VoiceInput = class {
5122
5223
  #recorder = null;
5123
5224
  #stream = null;
5124
5225
  #chunks = [];
5226
+ #disposed = false;
5125
5227
  constructor(options) {
5126
5228
  this.#transcribe = options.transcribe;
5127
5229
  this.#onText = options.onText;
@@ -5171,7 +5273,25 @@ var VoiceInput = class {
5171
5273
  #stop() {
5172
5274
  this.#recorder?.stop();
5173
5275
  }
5276
+ /**
5277
+ * Tear the control down — the teardown path when the host element is removed
5278
+ * mid-recording. Stops any live `MediaRecorder`, releases the mic tracks (so
5279
+ * the browser's recording indicator clears), and suppresses the pending
5280
+ * transcription: a disconnected control must not fire `onText` back into a
5281
+ * detached element.
5282
+ */
5283
+ dispose() {
5284
+ this.#disposed = true;
5285
+ if (this.#recorder !== null && this.#recorder.state !== "inactive") {
5286
+ this.#recorder.stop();
5287
+ }
5288
+ this.#recorder = null;
5289
+ this.#releaseStream();
5290
+ }
5174
5291
  async #finish(mimeType) {
5292
+ if (this.#disposed) {
5293
+ return;
5294
+ }
5175
5295
  this.#releaseStream();
5176
5296
  this.#setState("transcribing");
5177
5297
  const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
@@ -5329,7 +5449,7 @@ var AgUiClient = class {
5329
5449
  return;
5330
5450
  }
5331
5451
  const pending = [];
5332
- const runState = { terminal: false };
5452
+ const runState = { terminal: false, errored: false };
5333
5453
  await this.#agent.runAgent(
5334
5454
  { tools: this.#getTools(), context: this.#getContext() },
5335
5455
  this.#buildSubscriber(pending, runState)
@@ -5341,6 +5461,9 @@ var AgUiClient = class {
5341
5461
  if (!runState.terminal) {
5342
5462
  throw new ConnectionLostError(this.#connectionLostMessage);
5343
5463
  }
5464
+ if (runState.errored) {
5465
+ return;
5466
+ }
5344
5467
  if (this.#executeTool === null || pending.length === 0) {
5345
5468
  return;
5346
5469
  }
@@ -5391,7 +5514,7 @@ var AgUiClient = class {
5391
5514
  onToolCallResultEvent({ event }) {
5392
5515
  h.onToolResult(event.toolCallId, event.content);
5393
5516
  },
5394
- // Reasoning (THINK-1). `@ag-ui/client` already maps the deprecated
5517
+ // Reasoning. `@ag-ui/client` already maps the deprecated
5395
5518
  // THINKING_* events onto these REASONING_* callbacks, so handling the
5396
5519
  // reasoning family alone covers both protocol versions.
5397
5520
  onReasoningStartEvent() {
@@ -5405,6 +5528,7 @@ var AgUiClient = class {
5405
5528
  },
5406
5529
  onRunErrorEvent({ event }) {
5407
5530
  runState.terminal = true;
5531
+ runState.errored = true;
5408
5532
  h.onError(event.message);
5409
5533
  },
5410
5534
  onRunFinalized() {
@@ -5421,40 +5545,56 @@ function isAbortError(error) {
5421
5545
  // src/core/attachment.ts
5422
5546
  function messageAttachments(message) {
5423
5547
  const refs = message.attachments;
5424
- return Array.isArray(refs) ? refs : [];
5548
+ return Array.isArray(refs) ? refs.filter(isAttachmentRef) : [];
5549
+ }
5550
+ function isAttachmentRef(value) {
5551
+ if (typeof value !== "object" || value === null) {
5552
+ return false;
5553
+ }
5554
+ const ref = value;
5555
+ return typeof ref["id"] === "string" && typeof ref["name"] === "string" && typeof ref["mime"] === "string" && typeof ref["size"] === "number" && (ref["url"] === void 0 || typeof ref["url"] === "string");
5425
5556
  }
5426
5557
 
5427
5558
  // src/core/conversation_store.ts
5428
5559
  import { randomUUID as randomUUID3 } from "@ag-ui/client";
5429
- var THREAD_KEY = "ag-ui-chat:thread";
5430
- var THREADS_KEY = "ag-ui-chat:threads";
5431
- var MESSAGES_PREFIX = "ag-ui-chat:messages:";
5432
- var CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
5560
+ var KEY_ROOT = "ag-ui-chat";
5561
+ var THREAD_SUFFIX = "thread";
5562
+ var THREADS_SUFFIX = "threads";
5563
+ var MESSAGES_SUFFIX = "messages:";
5564
+ var CHECKPOINT_SUFFIX = "checkpoint:";
5433
5565
  var TITLE_LIMIT = 60;
5434
5566
  var PREVIEW_LIMIT = 100;
5435
5567
  var DEFAULT_TITLE = "New conversation";
5436
5568
  var SessionStorageStore = class {
5569
+ #root;
5570
+ constructor(namespace = "") {
5571
+ this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
5572
+ if (namespace !== "") {
5573
+ this.#migrateLegacyKeys();
5574
+ }
5575
+ }
5437
5576
  threadId() {
5438
- const existing = sessionStorage.getItem(THREAD_KEY);
5577
+ const key = this.#key(THREAD_SUFFIX);
5578
+ const existing = sessionStorage.getItem(key);
5439
5579
  if (existing !== null) {
5440
5580
  return existing;
5441
5581
  }
5442
5582
  const id = randomUUID3();
5443
- sessionStorage.setItem(THREAD_KEY, id);
5583
+ sessionStorage.setItem(key, id);
5444
5584
  return id;
5445
5585
  }
5446
5586
  loadMessages(threadId) {
5447
- return Promise.resolve(this.#readJson(MESSAGES_PREFIX + threadId));
5587
+ return Promise.resolve(this.#readJson(this.#key(MESSAGES_SUFFIX + threadId)));
5448
5588
  }
5449
5589
  saveMessages(threadId, messages) {
5450
- sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
5590
+ sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
5451
5591
  this.#touchThread(threadId, messages);
5452
5592
  }
5453
5593
  loadCheckpoint(threadId) {
5454
- return this.#readJson(CHECKPOINT_PREFIX + threadId);
5594
+ return this.#readJson(this.#key(CHECKPOINT_SUFFIX + threadId));
5455
5595
  }
5456
5596
  saveCheckpoint(threadId, checkpoint) {
5457
- const key = CHECKPOINT_PREFIX + threadId;
5597
+ const key = this.#key(CHECKPOINT_SUFFIX + threadId);
5458
5598
  if (checkpoint === null) {
5459
5599
  sessionStorage.removeItem(key);
5460
5600
  return;
@@ -5462,11 +5602,11 @@ var SessionStorageStore = class {
5462
5602
  sessionStorage.setItem(key, JSON.stringify(checkpoint));
5463
5603
  }
5464
5604
  clear(threadId) {
5465
- sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
5466
- sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
5605
+ sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
5606
+ sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
5467
5607
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
5468
- if (sessionStorage.getItem(THREAD_KEY) === threadId) {
5469
- sessionStorage.removeItem(THREAD_KEY);
5608
+ if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
5609
+ sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
5470
5610
  }
5471
5611
  }
5472
5612
  listThreads() {
@@ -5474,7 +5614,7 @@ var SessionStorageStore = class {
5474
5614
  return Promise.resolve(metas);
5475
5615
  }
5476
5616
  setActiveThread(threadId) {
5477
- sessionStorage.setItem(THREAD_KEY, threadId);
5617
+ sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
5478
5618
  }
5479
5619
  renameThread(threadId, title) {
5480
5620
  const threads = this.#readThreads();
@@ -5510,14 +5650,48 @@ var SessionStorageStore = class {
5510
5650
  this.#writeThreads(threads);
5511
5651
  }
5512
5652
  #readThreads() {
5513
- return this.#readJson(THREADS_KEY) ?? [];
5653
+ return this.#readJson(this.#key(THREADS_SUFFIX)) ?? [];
5514
5654
  }
5515
5655
  #writeThreads(threads) {
5656
+ const key = this.#key(THREADS_SUFFIX);
5516
5657
  if (threads.length === 0) {
5517
- sessionStorage.removeItem(THREADS_KEY);
5658
+ sessionStorage.removeItem(key);
5518
5659
  return;
5519
5660
  }
5520
- sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
5661
+ sessionStorage.setItem(key, JSON.stringify(threads));
5662
+ }
5663
+ /** This store's fully-qualified key for a suffix (namespaced when set). */
5664
+ #key(suffix) {
5665
+ return `${this.#root}:${suffix}`;
5666
+ }
5667
+ /**
5668
+ * One-time move of pre-namespacing (`ag-ui-chat:*`) keys into this instance's
5669
+ * namespace, so an existing conversation isn't orphaned by the upgrade. Only
5670
+ * this store's own keys move (thread pointer, drawer index, per-thread
5671
+ * messages/checkpoints) — the element's `collapsed`/`theme` keys are left
5672
+ * alone. The first namespaced instance to mount adopts the legacy data; a
5673
+ * second namespace finds it gone and starts fresh.
5674
+ */
5675
+ #migrateLegacyKeys() {
5676
+ const legacyRoot = `${KEY_ROOT}:`;
5677
+ const moves = [];
5678
+ for (let i = 0; i < sessionStorage.length; i += 1) {
5679
+ const key = sessionStorage.key(i);
5680
+ if (key === null || !key.startsWith(legacyRoot)) {
5681
+ continue;
5682
+ }
5683
+ const suffix = key.slice(legacyRoot.length);
5684
+ if (isOwnedSuffix(suffix)) {
5685
+ moves.push([key, this.#key(suffix)]);
5686
+ }
5687
+ }
5688
+ for (const [from, to] of moves) {
5689
+ const value = sessionStorage.getItem(from);
5690
+ if (value !== null && sessionStorage.getItem(to) === null) {
5691
+ sessionStorage.setItem(to, value);
5692
+ }
5693
+ sessionStorage.removeItem(from);
5694
+ }
5521
5695
  }
5522
5696
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
5523
5697
  #readJson(key) {
@@ -5532,6 +5706,9 @@ var SessionStorageStore = class {
5532
5706
  }
5533
5707
  }
5534
5708
  };
5709
+ function isOwnedSuffix(suffix) {
5710
+ return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX);
5711
+ }
5535
5712
  function deriveTitle(messages) {
5536
5713
  for (const message of messages) {
5537
5714
  if (message.role === "user") {
@@ -5638,7 +5815,10 @@ var RemoteConversationStore = class {
5638
5815
  if (response === null || !response.ok) {
5639
5816
  return this.#local.loadMessages(threadId);
5640
5817
  }
5641
- const body = await response.json();
5818
+ const body = await this.#readJson(response);
5819
+ if (body === null) {
5820
+ return this.#local.loadMessages(threadId);
5821
+ }
5642
5822
  return body.messages ?? null;
5643
5823
  }
5644
5824
  async #fetchThreads() {
@@ -5646,14 +5826,28 @@ var RemoteConversationStore = class {
5646
5826
  if (response === null || !response.ok) {
5647
5827
  return null;
5648
5828
  }
5649
- const body = await response.json();
5829
+ const body = await this.#readJson(response);
5830
+ if (body === null) {
5831
+ return null;
5832
+ }
5650
5833
  return body.threads ?? [];
5651
5834
  }
5835
+ /** Parse a `Response` body as JSON, or `null` when it isn't valid JSON. */
5836
+ async #readJson(response) {
5837
+ try {
5838
+ return await response.json();
5839
+ } catch {
5840
+ return null;
5841
+ }
5842
+ }
5652
5843
  #toMeta(row) {
5653
5844
  return {
5654
5845
  threadId: row.thread_id,
5655
5846
  title: this.#renamed.get(row.thread_id) ?? row.title,
5656
- updatedAt: row.updated_at === null ? 0 : Date.parse(row.updated_at),
5847
+ // `null` or an unparseable date both become `NaN` (Date.parse's own
5848
+ // signal), which `relativeTime` renders as a neutral label rather than
5849
+ // "~2950w ago" (epoch 0) or "NaNw ago".
5850
+ updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
5657
5851
  preview: row.preview
5658
5852
  };
5659
5853
  }
@@ -5929,7 +6123,7 @@ var AgUiChat = class extends HTMLElement {
5929
6123
  #attachButton;
5930
6124
  #fileInput;
5931
6125
  #attachSlot;
5932
- /** Optional built-in header theme toggle (THEME-1); shown only with `data-theme-toggle`. */
6126
+ /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
5933
6127
  #themeToggle;
5934
6128
  /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
5935
6129
  #rail;
@@ -5957,18 +6151,25 @@ var AgUiChat = class extends HTMLElement {
5957
6151
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
5958
6152
  #streamDeltas = 0;
5959
6153
  #pending = null;
5960
- // The current assistant turn's grouping container (WELL-1). One `.answer`
6154
+ // The current assistant turn's grouping container. One `.answer`
5961
6155
  // wraps everything a single answer produces — streamed text, tool cards, the
5962
6156
  // pending indicator — so it can be boxed as one "well" by CSS. Opened on the
5963
6157
  // turn's first run start, closed at settle, so it spans the whole multi-round
5964
6158
  // frontend-tool loop (which is several AG-UI runs), not one run. `null`
5965
6159
  // between turns; user bubbles never enter it.
5966
6160
  #currentGroup = null;
5967
- // The current turn's streamed-reasoning region (THINK-1), shown at the top of
6161
+ // The current turn's streamed-reasoning region, shown at the top of
5968
6162
  // the answer group while a reasoning model thinks and collapsed once the
5969
6163
  // answer's first text token arrives. `null` outside a reasoning turn.
5970
6164
  #thoughts = null;
5971
6165
  #threadId = "";
6166
+ // Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
6167
+ // active thread), so two instances on one origin don't clobber each other.
6168
+ // Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
6169
+ #storageNs = "";
6170
+ // Bumped on every #rehydrate; a replay whose generation is stale (a newer
6171
+ // thread switch started while it awaited a slow store) drops its result.
6172
+ #rehydrateGeneration = 0;
5972
6173
  #initialMessages = [];
5973
6174
  // Skill catalog by source; merged backend → embed → client (later wins).
5974
6175
  #backendSkills = [];
@@ -6112,27 +6313,44 @@ var AgUiChat = class extends HTMLElement {
6112
6313
  this.setAttribute("data-tool-display", value);
6113
6314
  }
6114
6315
  connectedCallback() {
6316
+ this.#storageNs = this.id !== "" ? this.id : this.endpoint;
6115
6317
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
6116
6318
  if (this.getAttribute("data-theme-toggle") !== null) {
6117
- const saved = sessionStorage.getItem(THEME_KEY);
6319
+ const saved = this.#readScopedItem(THEME_KEY);
6118
6320
  if (saved !== null) {
6119
6321
  this.setAttribute("theme", saved);
6120
6322
  }
6121
6323
  }
6122
6324
  this.#render();
6123
6325
  this.#drawer.setStrings(this.#strings);
6124
- if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
6326
+ if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
6125
6327
  this.setAttribute("collapsed", "");
6126
6328
  }
6127
6329
  this.#syncRail();
6128
6330
  this.#initSkills();
6129
6331
  void this.#fetchToolCatalog();
6332
+ if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
6333
+ this.conversationStore = new SessionStorageStore(this.#storageNs);
6334
+ }
6130
6335
  this.#wireThreadStore();
6131
6336
  this.#wireAttachments();
6132
6337
  this.#wireVoice();
6133
6338
  this.#threadId = this.conversationStore.threadId();
6134
6339
  void this.#rehydrate();
6135
6340
  }
6341
+ /**
6342
+ * Tear down live resources when the element leaves the DOM (a removed node, a
6343
+ * client-side route swap): cancel the in-flight run so its SSE stream closes,
6344
+ * abort any in-flight uploads so they don't orphan server-side files, and
6345
+ * release the mic so the browser's recording indicator clears. Without this a
6346
+ * removed `<ag-ui-chat>` leaks a streaming request, uploads, and a live
6347
+ * `MediaRecorder`.
6348
+ */
6349
+ disconnectedCallback() {
6350
+ this.#cancelRun();
6351
+ this.#attachTray?.dispose();
6352
+ this.#voice?.dispose();
6353
+ }
6136
6354
  /** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
6137
6355
  #readStringOverrides() {
6138
6356
  const raw = this.getAttribute("data-strings");
@@ -6178,7 +6396,7 @@ var AgUiChat = class extends HTMLElement {
6178
6396
  if (url === null) {
6179
6397
  return null;
6180
6398
  }
6181
- return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
6399
+ return (file, onProgress, signal) => uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
6182
6400
  }
6183
6401
  /**
6184
6402
  * Reveal the composer's 🎤 mic button when transcription is possible — either
@@ -6382,7 +6600,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6382
6600
  } else {
6383
6601
  this.removeAttribute("collapsed");
6384
6602
  }
6385
- sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
6603
+ sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
6386
6604
  this.#syncRail();
6387
6605
  this.dispatchEvent(
6388
6606
  new CustomEvent(TOGGLE_EVENT, {
@@ -6405,9 +6623,25 @@ Use the read_attachment tool with an id to read a file's contents.`
6405
6623
  toggleTheme() {
6406
6624
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
6407
6625
  this.setAttribute("theme", next);
6408
- sessionStorage.setItem(THEME_KEY, next);
6626
+ sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
6409
6627
  this.#syncThemeGlyph();
6410
6628
  }
6629
+ /** This instance's namespaced form of an origin-scoped storage key. */
6630
+ #storageKey(base) {
6631
+ return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
6632
+ }
6633
+ /**
6634
+ * Read a namespaced origin-scoped value, falling back once to the legacy
6635
+ * pre-namespacing global key (left in place) so an existing collapsed/theme
6636
+ * preference survives the upgrade.
6637
+ */
6638
+ #readScopedItem(base) {
6639
+ const scoped = sessionStorage.getItem(this.#storageKey(base));
6640
+ if (scoped !== null || this.#storageNs === "") {
6641
+ return scoped;
6642
+ }
6643
+ return sessionStorage.getItem(base);
6644
+ }
6411
6645
  /** Reflect the current theme on the toggle: show the destination's glyph. */
6412
6646
  #syncThemeGlyph() {
6413
6647
  const dark = this.getAttribute("theme") === "dark";
@@ -6475,7 +6709,12 @@ Use the read_attachment tool with an id to read a file's contents.`
6475
6709
  * result from the page we landed on.
6476
6710
  */
6477
6711
  async #rehydrate() {
6712
+ this.#rehydrateGeneration += 1;
6713
+ const generation = this.#rehydrateGeneration;
6478
6714
  const messages = await this.conversationStore.loadMessages(this.#threadId);
6715
+ if (generation !== this.#rehydrateGeneration) {
6716
+ return;
6717
+ }
6479
6718
  if (messages !== null) {
6480
6719
  this.#initialMessages = messages;
6481
6720
  for (const message of messages) {
@@ -6562,7 +6801,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6562
6801
  * stays literal text (no need to parse what the user typed, and it avoids
6563
6802
  * rendering user-authored markup).
6564
6803
  *
6565
- * Assistant bubbles land in the current answer group (WELL-1), opening one if
6804
+ * Assistant bubbles land in the current answer group, opening one if
6566
6805
  * needed; a user bubble closes the prior group and sits directly in the list
6567
6806
  * (the well wraps the *assistant* turn, the user message precedes it).
6568
6807
  */
@@ -6796,6 +7035,9 @@ Use the read_attachment tool with an id to read a file's contents.`
6796
7035
  this.#send.dataset["state"] = running ? "running" : "idle";
6797
7036
  }
6798
7037
  async #submit() {
7038
+ if (this.#running) {
7039
+ return;
7040
+ }
6799
7041
  const content = this.#input.value.trim();
6800
7042
  const attachments = this.#attachTray?.readyRefs() ?? [];
6801
7043
  if (content === "" && attachments.length === 0) {
@@ -7113,7 +7355,7 @@ function setControlValue(el, value) {
7113
7355
  }
7114
7356
 
7115
7357
  // src/version.ts
7116
- var VERSION = "0.9.0";
7358
+ var VERSION = "0.10.0";
7117
7359
  export {
7118
7360
  AgUiChat,
7119
7361
  AgUiClient,