@artooi/ag-ui-web-component 0.8.1 → 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 (54) hide show
  1. package/CHANGELOG.md +82 -4
  2. package/README.md +45 -7
  3. package/dist/ag-ui-web-component.bundle.js +163 -57
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +27 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +6 -0
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/attachment.d.ts +5 -0
  10. package/dist/core/attachment.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +8 -0
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  14. package/dist/core/transcribe_audio.d.ts +25 -0
  15. package/dist/core/transcribe_audio.d.ts.map +1 -0
  16. package/dist/core/upload_attachment.d.ts +8 -2
  17. package/dist/core/upload_attachment.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +735 -55
  21. package/dist/index.js.map +4 -4
  22. package/dist/ui/attachment_tray.d.ts +7 -1
  23. package/dist/ui/attachment_tray.d.ts.map +1 -1
  24. package/dist/ui/relative_time.d.ts +5 -3
  25. package/dist/ui/relative_time.d.ts.map +1 -1
  26. package/dist/ui/styles.d.ts +1 -1
  27. package/dist/ui/styles.d.ts.map +1 -1
  28. package/dist/ui/thoughts_block.d.ts +30 -0
  29. package/dist/ui/thoughts_block.d.ts.map +1 -0
  30. package/dist/ui/thread_drawer.d.ts.map +1 -1
  31. package/dist/ui/tool_call_card.d.ts.map +1 -1
  32. package/dist/ui/ui_strings.d.ts +14 -1
  33. package/dist/ui/ui_strings.d.ts.map +1 -1
  34. package/dist/ui/voice_input.d.ts +41 -0
  35. package/dist/ui/voice_input.d.ts.map +1 -0
  36. package/dist/version.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/core/ag_ui_chat.ts +217 -7
  39. package/src/core/agui_client.ts +31 -2
  40. package/src/core/attachment.ts +21 -1
  41. package/src/core/conversation_store.ts +84 -18
  42. package/src/core/remote_conversation_store.ts +24 -3
  43. package/src/core/transcribe_audio.ts +62 -0
  44. package/src/core/upload_attachment.ts +8 -1
  45. package/src/index.ts +5 -0
  46. package/src/ui/attachment_tray.ts +42 -5
  47. package/src/ui/relative_time.ts +8 -3
  48. package/src/ui/styles.ts +113 -7
  49. package/src/ui/thoughts_block.ts +83 -0
  50. package/src/ui/thread_drawer.ts +83 -9
  51. package/src/ui/tool_call_card.ts +6 -0
  52. package/src/ui/ui_strings.ts +20 -1
  53. package/src/ui/voice_input.ts +169 -0
  54. package/src/version.ts +1 -1
package/dist/index.js CHANGED
@@ -506,8 +506,10 @@ var DEFAULT_UI_STRINGS = {
506
506
  newChat: "New chat",
507
507
  collapse: "Collapse",
508
508
  expand: "Expand",
509
+ toggleTheme: "Toggle theme",
509
510
  conversation: "Conversation",
510
511
  thinking: "Assistant is thinking\u2026",
512
+ thoughts: "Thoughts",
511
513
  stopped: "\u23F9 Stopped",
512
514
  connectionLost: "Connection lost",
513
515
  noResult: "No result returned.",
@@ -519,6 +521,10 @@ var DEFAULT_UI_STRINGS = {
519
521
  send: "Send",
520
522
  stop: "Stop",
521
523
  attachFiles: "Attach files",
524
+ recordVoice: "Record voice",
525
+ stopRecording: "Stop recording",
526
+ transcribing: "Transcribing\u2026",
527
+ transcriptionFailed: "Transcription failed",
522
528
  toolRunning: "running\u2026",
523
529
  toolDone: "\u2713 done",
524
530
  toolError: "\u26A0 error",
@@ -585,7 +591,8 @@ var AttachmentTray = class {
585
591
  status: ATTACHMENT_STATUS.UPLOADING,
586
592
  progress: 0,
587
593
  ref: null,
588
- error: ""
594
+ error: "",
595
+ controller: null
589
596
  };
590
597
  this.#items.push(item);
591
598
  const rejection = this.#reject(file);
@@ -623,11 +630,24 @@ var AttachmentTray = class {
623
630
  this.#items = this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
624
631
  this.#render();
625
632
  }
626
- /** Drop every chip (a reset / new-chat). */
633
+ /** Drop every chip (a reset / new-chat), aborting any in-flight upload. */
627
634
  clear() {
635
+ for (const item of this.#items) {
636
+ item.controller?.abort();
637
+ }
628
638
  this.#items = [];
629
639
  this.#render();
630
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
+ }
631
651
  /** The size/type rejection reason for a file, or `null` when accepted. */
632
652
  #reject(file) {
633
653
  if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
@@ -639,25 +659,41 @@ var AttachmentTray = class {
639
659
  return null;
640
660
  }
641
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
+ }
642
670
  item.status = ATTACHMENT_STATUS.UPLOADING;
643
671
  item.progress = 0;
644
672
  item.error = "";
673
+ const controller = new AbortController();
674
+ item.controller = controller;
645
675
  this.#render();
646
- this.#config.upload(item.file, (fraction) => {
647
- item.progress = fraction;
648
- this.#render();
649
- }).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) => {
650
684
  item.status = ATTACHMENT_STATUS.READY;
651
685
  item.ref = ref;
652
686
  }).catch((error) => {
653
687
  item.status = ATTACHMENT_STATUS.ERROR;
654
688
  item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
655
689
  }).finally(() => {
690
+ item.controller = null;
656
691
  this.#render();
657
692
  this.#config.onChange?.();
658
693
  });
659
694
  }
660
695
  #remove(item) {
696
+ item.controller?.abort();
661
697
  this.#items = this.#items.filter((other) => other !== item);
662
698
  this.#render();
663
699
  this.#config.onChange?.();
@@ -3611,7 +3647,7 @@ var STYLES = `
3611
3647
  --ag-ui-radius: 0;
3612
3648
  }
3613
3649
 
3614
- /* Page (PAGE-1): full-bleed background with a centred reading column. Unlike
3650
+ /* Page: full-bleed background with a centred reading column. Unlike
3615
3651
  "full" (edge-to-edge, left-aligned messages) the content sits in a column
3616
3652
  capped at --ag-ui-content-max-width. The column is produced by symmetric auto
3617
3653
  padding on the scroll area + composer (no per-row wrapper), so user pills
@@ -3653,7 +3689,7 @@ var STYLES = `
3653
3689
  max-width: 100%;
3654
3690
  }
3655
3691
 
3656
- /* 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
3657
3693
  collapses to a slim icon rail (not the floating launcher). Docked right by
3658
3694
  default; data-side="left" docks it left. Overlay by default \u2014 set
3659
3695
  --ag-ui-position: static (and place this element in your own layout) for a
@@ -3763,7 +3799,7 @@ var STYLES = `
3763
3799
  white-space: nowrap;
3764
3800
  }
3765
3801
 
3766
- /* 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>
3767
3803
  fallback, sized via --ag-ui-icon-size. */
3768
3804
  .icon-holder {
3769
3805
  display: inline-flex;
@@ -3837,7 +3873,7 @@ var STYLES = `
3837
3873
  gap: var(--ag-ui-space);
3838
3874
  }
3839
3875
 
3840
- /* 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
3841
3877
  list, hidden as soon as a message, card, or pending indicator renders. */
3842
3878
  .empty {
3843
3879
  margin: auto;
@@ -3849,7 +3885,7 @@ var STYLES = `
3849
3885
  display: none;
3850
3886
  }
3851
3887
 
3852
- /* \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
3853
3889
  One .answer per assistant turn wraps the streamed text, its tool cards,
3854
3890
  and the pending indicator so a whole answer reads (and can be boxed) as one
3855
3891
  unit. A flex column on the message-list gap, stretched to the list width so
@@ -4018,6 +4054,69 @@ var STYLES = `
4018
4054
  }
4019
4055
  }
4020
4056
 
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
4058
+ A muted, collapsible chain-of-thought at the top of the answer group: open
4059
+ while the model reasons, folded once the answer text starts. */
4060
+ .thoughts {
4061
+ align-self: stretch;
4062
+ display: flex;
4063
+ flex-direction: column;
4064
+ gap: 4px;
4065
+ font-size: 12px;
4066
+ color: var(--ag-ui-muted);
4067
+ }
4068
+
4069
+ .thoughts-toggle {
4070
+ align-self: flex-start;
4071
+ border: none;
4072
+ padding: 0;
4073
+ background: none;
4074
+ font: inherit;
4075
+ font-size: 12px;
4076
+ font-weight: 600;
4077
+ color: var(--ag-ui-muted);
4078
+ cursor: pointer;
4079
+ }
4080
+
4081
+ .thoughts-toggle::before {
4082
+ content: "\u25BE ";
4083
+ }
4084
+
4085
+ .thoughts-toggle[aria-expanded="false"]::before {
4086
+ content: "\u25B8 ";
4087
+ }
4088
+
4089
+ /* A gentle pulse on the label while reasoning is still streaming. */
4090
+ .thoughts[data-streaming] .thoughts-label {
4091
+ animation: ag-ui-thoughts-pulse 1.4s ease-in-out infinite;
4092
+ }
4093
+
4094
+ @keyframes ag-ui-thoughts-pulse {
4095
+ 0%, 100% { opacity: 0.55; }
4096
+ 50% { opacity: 1; }
4097
+ }
4098
+
4099
+ .thoughts-body {
4100
+ margin: 0;
4101
+ padding: 4px 0 4px 10px;
4102
+ border-left: 2px solid var(--ag-ui-border);
4103
+ max-height: 220px;
4104
+ overflow: auto;
4105
+ white-space: pre-wrap;
4106
+ word-break: break-word;
4107
+ font-family: inherit;
4108
+ }
4109
+
4110
+ .thoughts-body[hidden] {
4111
+ display: none;
4112
+ }
4113
+
4114
+ @media (prefers-reduced-motion: reduce) {
4115
+ .thoughts[data-streaming] .thoughts-label {
4116
+ animation: none;
4117
+ }
4118
+ }
4119
+
4021
4120
  .tool-call {
4022
4121
  align-self: flex-start;
4023
4122
  max-width: 80%;
@@ -4048,7 +4147,7 @@ var STYLES = `
4048
4147
  word-break: break-word;
4049
4148
  }
4050
4149
 
4051
- /* 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
4052
4151
  here from the card's data-status, so it stays themeable. */
4053
4152
  .tool-call-icon {
4054
4153
  flex: none;
@@ -4096,7 +4195,7 @@ var STYLES = `
4096
4195
  }
4097
4196
  }
4098
4197
 
4099
- /* 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
4100
4199
  status row reads as one line of the answer; the result toggle still expands
4101
4200
  below it. */
4102
4201
  .tool-call[data-display="inline"] {
@@ -4226,6 +4325,49 @@ var STYLES = `
4226
4325
  display: none;
4227
4326
  }
4228
4327
 
4328
+ /* The \u{1F3A4} mic button; shown only once #wireVoice mounts it. */
4329
+ .voice-slot {
4330
+ display: contents;
4331
+ }
4332
+
4333
+ .voice-btn {
4334
+ border: 1px solid var(--ag-ui-border);
4335
+ border-radius: 8px;
4336
+ padding: 0 10px;
4337
+ background: var(--ag-ui-input-bg);
4338
+ color: inherit;
4339
+ font: inherit;
4340
+ cursor: pointer;
4341
+ }
4342
+
4343
+ .voice-btn:hover {
4344
+ border-color: var(--ag-ui-accent);
4345
+ }
4346
+
4347
+ .voice-btn:disabled {
4348
+ cursor: default;
4349
+ opacity: 0.6;
4350
+ }
4351
+
4352
+ /* Recording: a red tint + a gentle pulse so it's clearly "live". */
4353
+ .voice-btn[data-state="recording"] {
4354
+ border-color: var(--ag-ui-danger);
4355
+ background: var(--ag-ui-danger);
4356
+ color: #ffffff;
4357
+ animation: ag-ui-voice-pulse 1.2s ease-in-out infinite;
4358
+ }
4359
+
4360
+ @keyframes ag-ui-voice-pulse {
4361
+ 0%, 100% { opacity: 1; }
4362
+ 50% { opacity: 0.6; }
4363
+ }
4364
+
4365
+ @media (prefers-reduced-motion: reduce) {
4366
+ .voice-btn[data-state="recording"] {
4367
+ animation: none;
4368
+ }
4369
+ }
4370
+
4229
4371
  /* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
4230
4372
  .attachment-slot {
4231
4373
  display: contents;
@@ -4640,8 +4782,67 @@ var STYLES = `
4640
4782
  }
4641
4783
  `;
4642
4784
 
4785
+ // src/ui/thoughts_block.ts
4786
+ var ThoughtsBlock = class {
4787
+ /** The block's root element; insert this at the top of the answer group. */
4788
+ element;
4789
+ #label;
4790
+ #body;
4791
+ #toggle;
4792
+ #strings;
4793
+ #collapsed = false;
4794
+ constructor(strings = DEFAULT_UI_STRINGS) {
4795
+ this.#strings = strings;
4796
+ this.element = document.createElement("div");
4797
+ this.element.className = "thoughts";
4798
+ this.element.setAttribute("part", "thoughts");
4799
+ this.element.setAttribute("data-streaming", "");
4800
+ this.#toggle = document.createElement("button");
4801
+ this.#toggle.type = "button";
4802
+ this.#toggle.className = "thoughts-toggle";
4803
+ this.#toggle.setAttribute("part", "thoughts-toggle");
4804
+ this.#toggle.setAttribute("aria-expanded", "true");
4805
+ this.#label = document.createElement("span");
4806
+ this.#label.className = "thoughts-label";
4807
+ this.#label.textContent = strings.thinking;
4808
+ this.#toggle.append(this.#label);
4809
+ this.#body = document.createElement("pre");
4810
+ this.#body.className = "thoughts-body";
4811
+ this.#body.setAttribute("part", "thoughts-body");
4812
+ this.#toggle.addEventListener("click", () => {
4813
+ this.#setCollapsed(!this.#collapsed);
4814
+ });
4815
+ this.element.append(this.#toggle, this.#body);
4816
+ }
4817
+ /** Replace the reasoning body with the running buffer (the full text so far). */
4818
+ stream(buffer) {
4819
+ this.#body.textContent = buffer;
4820
+ }
4821
+ /**
4822
+ * Fold the region away — called when the answer's first text token arrives.
4823
+ * Idempotent (the per-token text handler calls it repeatedly) and flips the
4824
+ * header label from "thinking…" to the settled "Thoughts" affordance.
4825
+ */
4826
+ collapse() {
4827
+ if (this.#collapsed) {
4828
+ return;
4829
+ }
4830
+ this.element.removeAttribute("data-streaming");
4831
+ this.#label.textContent = this.#strings.thoughts;
4832
+ this.#setCollapsed(true);
4833
+ }
4834
+ #setCollapsed(collapsed) {
4835
+ this.#collapsed = collapsed;
4836
+ this.#body.hidden = collapsed;
4837
+ this.#toggle.setAttribute("aria-expanded", String(!collapsed));
4838
+ }
4839
+ };
4840
+
4643
4841
  // src/ui/relative_time.ts
4644
4842
  function relativeTime(timestamp, now = Date.now(), strings = DEFAULT_UI_STRINGS) {
4843
+ if (!Number.isFinite(timestamp)) {
4844
+ return strings.justNow;
4845
+ }
4645
4846
  const seconds = Math.round((now - timestamp) / 1e3);
4646
4847
  if (seconds < 60) {
4647
4848
  return strings.justNow;
@@ -4673,6 +4874,8 @@ var ThreadDrawer = class {
4673
4874
  #strings;
4674
4875
  #threads = [];
4675
4876
  #activeId = "";
4877
+ /** The element focused before the drawer opened, restored on close. */
4878
+ #lastFocused = null;
4676
4879
  constructor(callbacks, strings = DEFAULT_UI_STRINGS) {
4677
4880
  this.#callbacks = callbacks;
4678
4881
  this.#strings = strings;
@@ -4688,7 +4891,9 @@ var ThreadDrawer = class {
4688
4891
  this.#panel.className = "drawer-panel";
4689
4892
  this.#panel.setAttribute("part", "drawer-panel");
4690
4893
  this.#panel.setAttribute("role", "dialog");
4894
+ this.#panel.setAttribute("aria-modal", "true");
4691
4895
  this.#panel.setAttribute("aria-label", strings.chatHistory);
4896
+ this.#panel.addEventListener("keydown", (event) => this.#onPanelKeydown(event));
4692
4897
  const header = document.createElement("div");
4693
4898
  header.className = "drawer-header";
4694
4899
  header.setAttribute("part", "drawer-header");
@@ -4724,13 +4929,55 @@ var ThreadDrawer = class {
4724
4929
  return !this.element.hidden;
4725
4930
  }
4726
4931
  open() {
4932
+ if (this.isOpen()) {
4933
+ return;
4934
+ }
4935
+ this.#lastFocused = this.#activeElement();
4727
4936
  this.element.hidden = false;
4937
+ this.#newButton.focus();
4728
4938
  }
4729
4939
  close() {
4940
+ if (!this.isOpen()) {
4941
+ return;
4942
+ }
4730
4943
  this.element.hidden = true;
4944
+ this.#lastFocused?.focus();
4945
+ this.#lastFocused = null;
4731
4946
  }
4732
4947
  toggle() {
4733
- 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
+ }
4734
4981
  }
4735
4982
  /** Render the rows (or the empty state), highlighting the active thread. */
4736
4983
  setThreads(threads, activeId) {
@@ -4797,24 +5044,43 @@ var ThreadDrawer = class {
4797
5044
  row.append(select, actions);
4798
5045
  return row;
4799
5046
  }
4800
- /** 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. */
4801
5048
  #startRename(row, meta) {
4802
5049
  const input = document.createElement("input");
4803
5050
  input.type = "text";
4804
5051
  input.className = "drawer-rename-input";
4805
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
+ };
4806
5073
  input.addEventListener("keydown", (event) => {
4807
5074
  if (event.key === "Enter") {
4808
- const value = input.value.trim();
4809
- if (value === "") {
4810
- this.#renderList();
4811
- } else {
4812
- this.#callbacks.onRename(meta.threadId, value);
4813
- }
5075
+ event.preventDefault();
5076
+ commit();
4814
5077
  } else if (event.key === "Escape") {
4815
- this.#renderList();
5078
+ event.preventDefault();
5079
+ event.stopPropagation();
5080
+ cancel();
4816
5081
  }
4817
5082
  });
5083
+ input.addEventListener("blur", () => commit());
4818
5084
  row.replaceChildren(input);
4819
5085
  input.focus();
4820
5086
  input.select();
@@ -4910,6 +5176,9 @@ var ToolCallCard = class {
4910
5176
  * `inline`), or the args + result together (`compact`).
4911
5177
  */
4912
5178
  settle(status, text2) {
5179
+ if (this.#settled) {
5180
+ return;
5181
+ }
4913
5182
  this.#settled = true;
4914
5183
  this.element.setAttribute("data-status", status);
4915
5184
  this.#status.textContent = statusLabels(this.#strings)[status];
@@ -4943,6 +5212,134 @@ ${text2}`;
4943
5212
  }
4944
5213
  };
4945
5214
 
5215
+ // src/ui/voice_input.ts
5216
+ var VoiceInput = class {
5217
+ /** The mic button; mount this in the composer. */
5218
+ element;
5219
+ #transcribe;
5220
+ #onText;
5221
+ #strings;
5222
+ #state = "idle";
5223
+ #recorder = null;
5224
+ #stream = null;
5225
+ #chunks = [];
5226
+ #disposed = false;
5227
+ constructor(options) {
5228
+ this.#transcribe = options.transcribe;
5229
+ this.#onText = options.onText;
5230
+ this.#strings = options.strings ?? DEFAULT_UI_STRINGS;
5231
+ this.element = document.createElement("button");
5232
+ this.element.type = "button";
5233
+ this.element.className = "voice-btn";
5234
+ this.element.setAttribute("part", "voice-button");
5235
+ this.element.textContent = "\u{1F3A4}";
5236
+ this.#setState("idle");
5237
+ this.element.addEventListener("click", () => {
5238
+ void this.toggle();
5239
+ });
5240
+ }
5241
+ /** Start recording when idle, stop (and transcribe) when recording. */
5242
+ async toggle() {
5243
+ if (this.#state === "recording") {
5244
+ this.#stop();
5245
+ return;
5246
+ }
5247
+ if (this.#state === "transcribing") {
5248
+ return;
5249
+ }
5250
+ await this.#start();
5251
+ }
5252
+ async #start() {
5253
+ let stream;
5254
+ try {
5255
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
5256
+ } catch {
5257
+ this.#fail(this.#strings.transcriptionFailed);
5258
+ return;
5259
+ }
5260
+ this.#stream = stream;
5261
+ this.#chunks = [];
5262
+ const recorder = new MediaRecorder(stream);
5263
+ recorder.addEventListener("dataavailable", (event) => {
5264
+ this.#chunks.push(event.data);
5265
+ });
5266
+ recorder.addEventListener("stop", () => {
5267
+ void this.#finish(recorder.mimeType);
5268
+ });
5269
+ this.#recorder = recorder;
5270
+ recorder.start();
5271
+ this.#setState("recording");
5272
+ }
5273
+ #stop() {
5274
+ this.#recorder?.stop();
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
+ }
5291
+ async #finish(mimeType) {
5292
+ if (this.#disposed) {
5293
+ return;
5294
+ }
5295
+ this.#releaseStream();
5296
+ this.#setState("transcribing");
5297
+ const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
5298
+ try {
5299
+ const text2 = await this.#transcribe(audio);
5300
+ this.#setState("idle");
5301
+ if (text2 !== "") {
5302
+ this.#onText(text2);
5303
+ }
5304
+ } catch (error) {
5305
+ this.#fail(error instanceof Error ? error.message : this.#strings.transcriptionFailed);
5306
+ } finally {
5307
+ this.#recorder = null;
5308
+ }
5309
+ }
5310
+ /** Stop the mic tracks so the browser's recording indicator clears. */
5311
+ #releaseStream() {
5312
+ for (const track of this.#stream?.getTracks() ?? []) {
5313
+ track.stop();
5314
+ }
5315
+ this.#stream = null;
5316
+ }
5317
+ #fail(message) {
5318
+ this.#releaseStream();
5319
+ this.#recorder = null;
5320
+ this.#setState("idle");
5321
+ this.element.title = message;
5322
+ }
5323
+ #setState(state) {
5324
+ this.#state = state;
5325
+ this.element.dataset["state"] = state;
5326
+ const label = this.#labelFor(state);
5327
+ this.element.title = label;
5328
+ this.element.setAttribute("aria-label", label);
5329
+ this.element.setAttribute("aria-pressed", String(state === "recording"));
5330
+ this.element.disabled = state === "transcribing";
5331
+ }
5332
+ #labelFor(state) {
5333
+ if (state === "recording") {
5334
+ return this.#strings.stopRecording;
5335
+ }
5336
+ if (state === "transcribing") {
5337
+ return this.#strings.transcribing;
5338
+ }
5339
+ return this.#strings.recordVoice;
5340
+ }
5341
+ };
5342
+
4946
5343
  // src/core/agui_client.ts
4947
5344
  import { randomUUID as randomUUID2 } from "@ag-ui/client";
4948
5345
  var ConnectionLostError = class extends Error {
@@ -5052,7 +5449,7 @@ var AgUiClient = class {
5052
5449
  return;
5053
5450
  }
5054
5451
  const pending = [];
5055
- const runState = { terminal: false };
5452
+ const runState = { terminal: false, errored: false };
5056
5453
  await this.#agent.runAgent(
5057
5454
  { tools: this.#getTools(), context: this.#getContext() },
5058
5455
  this.#buildSubscriber(pending, runState)
@@ -5064,6 +5461,9 @@ var AgUiClient = class {
5064
5461
  if (!runState.terminal) {
5065
5462
  throw new ConnectionLostError(this.#connectionLostMessage);
5066
5463
  }
5464
+ if (runState.errored) {
5465
+ return;
5466
+ }
5067
5467
  if (this.#executeTool === null || pending.length === 0) {
5068
5468
  return;
5069
5469
  }
@@ -5114,8 +5514,21 @@ var AgUiClient = class {
5114
5514
  onToolCallResultEvent({ event }) {
5115
5515
  h.onToolResult(event.toolCallId, event.content);
5116
5516
  },
5517
+ // Reasoning. `@ag-ui/client` already maps the deprecated
5518
+ // THINKING_* events onto these REASONING_* callbacks, so handling the
5519
+ // reasoning family alone covers both protocol versions.
5520
+ onReasoningStartEvent() {
5521
+ h.onReasoningStart();
5522
+ },
5523
+ onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
5524
+ h.onReasoningDelta(reasoningMessageBuffer);
5525
+ },
5526
+ onReasoningEndEvent() {
5527
+ h.onReasoningEnd();
5528
+ },
5117
5529
  onRunErrorEvent({ event }) {
5118
5530
  runState.terminal = true;
5531
+ runState.errored = true;
5119
5532
  h.onError(event.message);
5120
5533
  },
5121
5534
  onRunFinalized() {
@@ -5132,40 +5545,56 @@ function isAbortError(error) {
5132
5545
  // src/core/attachment.ts
5133
5546
  function messageAttachments(message) {
5134
5547
  const refs = message.attachments;
5135
- 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");
5136
5556
  }
5137
5557
 
5138
5558
  // src/core/conversation_store.ts
5139
5559
  import { randomUUID as randomUUID3 } from "@ag-ui/client";
5140
- var THREAD_KEY = "ag-ui-chat:thread";
5141
- var THREADS_KEY = "ag-ui-chat:threads";
5142
- var MESSAGES_PREFIX = "ag-ui-chat:messages:";
5143
- 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:";
5144
5565
  var TITLE_LIMIT = 60;
5145
5566
  var PREVIEW_LIMIT = 100;
5146
5567
  var DEFAULT_TITLE = "New conversation";
5147
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
+ }
5148
5576
  threadId() {
5149
- const existing = sessionStorage.getItem(THREAD_KEY);
5577
+ const key = this.#key(THREAD_SUFFIX);
5578
+ const existing = sessionStorage.getItem(key);
5150
5579
  if (existing !== null) {
5151
5580
  return existing;
5152
5581
  }
5153
5582
  const id = randomUUID3();
5154
- sessionStorage.setItem(THREAD_KEY, id);
5583
+ sessionStorage.setItem(key, id);
5155
5584
  return id;
5156
5585
  }
5157
5586
  loadMessages(threadId) {
5158
- return Promise.resolve(this.#readJson(MESSAGES_PREFIX + threadId));
5587
+ return Promise.resolve(this.#readJson(this.#key(MESSAGES_SUFFIX + threadId)));
5159
5588
  }
5160
5589
  saveMessages(threadId, messages) {
5161
- sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
5590
+ sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
5162
5591
  this.#touchThread(threadId, messages);
5163
5592
  }
5164
5593
  loadCheckpoint(threadId) {
5165
- return this.#readJson(CHECKPOINT_PREFIX + threadId);
5594
+ return this.#readJson(this.#key(CHECKPOINT_SUFFIX + threadId));
5166
5595
  }
5167
5596
  saveCheckpoint(threadId, checkpoint) {
5168
- const key = CHECKPOINT_PREFIX + threadId;
5597
+ const key = this.#key(CHECKPOINT_SUFFIX + threadId);
5169
5598
  if (checkpoint === null) {
5170
5599
  sessionStorage.removeItem(key);
5171
5600
  return;
@@ -5173,11 +5602,11 @@ var SessionStorageStore = class {
5173
5602
  sessionStorage.setItem(key, JSON.stringify(checkpoint));
5174
5603
  }
5175
5604
  clear(threadId) {
5176
- sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
5177
- sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
5605
+ sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
5606
+ sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
5178
5607
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
5179
- if (sessionStorage.getItem(THREAD_KEY) === threadId) {
5180
- sessionStorage.removeItem(THREAD_KEY);
5608
+ if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
5609
+ sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
5181
5610
  }
5182
5611
  }
5183
5612
  listThreads() {
@@ -5185,7 +5614,7 @@ var SessionStorageStore = class {
5185
5614
  return Promise.resolve(metas);
5186
5615
  }
5187
5616
  setActiveThread(threadId) {
5188
- sessionStorage.setItem(THREAD_KEY, threadId);
5617
+ sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
5189
5618
  }
5190
5619
  renameThread(threadId, title) {
5191
5620
  const threads = this.#readThreads();
@@ -5221,14 +5650,48 @@ var SessionStorageStore = class {
5221
5650
  this.#writeThreads(threads);
5222
5651
  }
5223
5652
  #readThreads() {
5224
- return this.#readJson(THREADS_KEY) ?? [];
5653
+ return this.#readJson(this.#key(THREADS_SUFFIX)) ?? [];
5225
5654
  }
5226
5655
  #writeThreads(threads) {
5656
+ const key = this.#key(THREADS_SUFFIX);
5227
5657
  if (threads.length === 0) {
5228
- sessionStorage.removeItem(THREADS_KEY);
5658
+ sessionStorage.removeItem(key);
5229
5659
  return;
5230
5660
  }
5231
- 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
+ }
5232
5695
  }
5233
5696
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
5234
5697
  #readJson(key) {
@@ -5243,6 +5706,9 @@ var SessionStorageStore = class {
5243
5706
  }
5244
5707
  }
5245
5708
  };
5709
+ function isOwnedSuffix(suffix) {
5710
+ return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX);
5711
+ }
5246
5712
  function deriveTitle(messages) {
5247
5713
  for (const message of messages) {
5248
5714
  if (message.role === "user") {
@@ -5349,7 +5815,10 @@ var RemoteConversationStore = class {
5349
5815
  if (response === null || !response.ok) {
5350
5816
  return this.#local.loadMessages(threadId);
5351
5817
  }
5352
- const body = await response.json();
5818
+ const body = await this.#readJson(response);
5819
+ if (body === null) {
5820
+ return this.#local.loadMessages(threadId);
5821
+ }
5353
5822
  return body.messages ?? null;
5354
5823
  }
5355
5824
  async #fetchThreads() {
@@ -5357,14 +5826,28 @@ var RemoteConversationStore = class {
5357
5826
  if (response === null || !response.ok) {
5358
5827
  return null;
5359
5828
  }
5360
- const body = await response.json();
5829
+ const body = await this.#readJson(response);
5830
+ if (body === null) {
5831
+ return null;
5832
+ }
5361
5833
  return body.threads ?? [];
5362
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
+ }
5363
5843
  #toMeta(row) {
5364
5844
  return {
5365
5845
  threadId: row.thread_id,
5366
5846
  title: this.#renamed.get(row.thread_id) ?? row.title,
5367
- 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),
5368
5851
  preview: row.preview
5369
5852
  };
5370
5853
  }
@@ -5390,6 +5873,35 @@ var RemoteConversationStore = class {
5390
5873
  }
5391
5874
  };
5392
5875
 
5876
+ // src/core/transcribe_audio.ts
5877
+ async function transcribeAudio(audio, options) {
5878
+ const form = new FormData();
5879
+ form.append("audio", audio, "recording.webm");
5880
+ const response = await fetch(options.url, {
5881
+ method: "POST",
5882
+ headers: { ...options.headers ?? {} },
5883
+ body: form
5884
+ });
5885
+ if (!response.ok) {
5886
+ throw new Error(await errorMessage(response));
5887
+ }
5888
+ const body = await response.json();
5889
+ if (typeof body === "object" && body !== null && typeof body.text === "string") {
5890
+ return body.text;
5891
+ }
5892
+ throw new Error("transcription returned an unreadable response");
5893
+ }
5894
+ async function errorMessage(response) {
5895
+ try {
5896
+ const body = await response.json();
5897
+ if (typeof body.error === "string") {
5898
+ return body.error;
5899
+ }
5900
+ } catch {
5901
+ }
5902
+ return `transcription failed (${response.status})`;
5903
+ }
5904
+
5393
5905
  // src/core/upload_attachment.ts
5394
5906
  function uploadAttachment(file, options) {
5395
5907
  return new Promise((resolve, reject) => {
@@ -5416,7 +5928,7 @@ function uploadAttachment(file, options) {
5416
5928
  reject(new Error("upload returned an unreadable response"));
5417
5929
  }
5418
5930
  } else {
5419
- reject(new Error(errorMessage(xhr)));
5931
+ reject(new Error(errorMessage2(xhr)));
5420
5932
  }
5421
5933
  });
5422
5934
  xhr.addEventListener("error", () => reject(new Error("upload failed")));
@@ -5443,7 +5955,7 @@ function parseRef(body) {
5443
5955
  }
5444
5956
  return typeof url === "string" ? { id, name, mime, size, url } : { id, name, mime, size };
5445
5957
  }
5446
- function errorMessage(xhr) {
5958
+ function errorMessage2(xhr) {
5447
5959
  try {
5448
5960
  const body = JSON.parse(xhr.responseText);
5449
5961
  if (typeof body.error === "string") {
@@ -5456,6 +5968,7 @@ function errorMessage(xhr) {
5456
5968
 
5457
5969
  // src/core/ag_ui_chat.ts
5458
5970
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
5971
+ var THEME_KEY = "ag-ui-chat:theme";
5459
5972
  var AgUiChat = class extends HTMLElement {
5460
5973
  /** Agent factory; override to inject a custom or fake agent (tests). */
5461
5974
  agentFactory = createHttpAgent;
@@ -5533,6 +6046,15 @@ var AgUiChat = class extends HTMLElement {
5533
6046
  * with no `data-attachments-url`; the handler owns its own endpoint + headers.
5534
6047
  */
5535
6048
  uploadHandler = null;
6049
+ /**
6050
+ * How recorded voice clips are transcribed. `null` (default) POSTs the clip
6051
+ * to `data-transcribe-url` (django-ag-ui's `TranscribeView`). Set a custom
6052
+ * {@link TranscribeHandler} — `(audio: Blob) => Promise<string>` — to swap the
6053
+ * transport (a different STT endpoint, a browser Web Speech adapter) without
6054
+ * touching the mic button. When set, the 🎤 affordance appears even with no
6055
+ * `data-transcribe-url`.
6056
+ */
6057
+ transcribeHandler = null;
5536
6058
  /**
5537
6059
  * Builds the tool result a navigating tool resumes with after the page
5538
6060
  * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
@@ -5601,12 +6123,18 @@ var AgUiChat = class extends HTMLElement {
5601
6123
  #attachButton;
5602
6124
  #fileInput;
5603
6125
  #attachSlot;
6126
+ /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
6127
+ #themeToggle;
5604
6128
  /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
5605
6129
  #rail;
5606
6130
  /** Empty-state region at the top of the message list; hidden once anything renders. */
5607
6131
  #emptyWrap;
5608
6132
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
5609
6133
  #attachTray = null;
6134
+ /** Mic button mount point (input row); the control mounts on connect when enabled. */
6135
+ #voiceSlot;
6136
+ /** Voice-input control; created on connect when transcription is available. */
6137
+ #voice = null;
5610
6138
  /** Refs attached to the message currently being sent (the context manifest). */
5611
6139
  #runAttachments = [];
5612
6140
  #client = null;
@@ -5623,14 +6151,25 @@ var AgUiChat = class extends HTMLElement {
5623
6151
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
5624
6152
  #streamDeltas = 0;
5625
6153
  #pending = null;
5626
- // The current assistant turn's grouping container (WELL-1). One `.answer`
6154
+ // The current assistant turn's grouping container. One `.answer`
5627
6155
  // wraps everything a single answer produces — streamed text, tool cards, the
5628
6156
  // pending indicator — so it can be boxed as one "well" by CSS. Opened on the
5629
6157
  // turn's first run start, closed at settle, so it spans the whole multi-round
5630
6158
  // frontend-tool loop (which is several AG-UI runs), not one run. `null`
5631
6159
  // between turns; user bubbles never enter it.
5632
6160
  #currentGroup = null;
6161
+ // The current turn's streamed-reasoning region, shown at the top of
6162
+ // the answer group while a reasoning model thinks and collapsed once the
6163
+ // answer's first text token arrives. `null` outside a reasoning turn.
6164
+ #thoughts = null;
5633
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;
5634
6173
  #initialMessages = [];
5635
6174
  // Skill catalog by source; merged backend → embed → client (later wins).
5636
6175
  #backendSkills = [];
@@ -5648,6 +6187,8 @@ var AgUiChat = class extends HTMLElement {
5648
6187
  this.#attachButton = document.createElement("button");
5649
6188
  this.#fileInput = document.createElement("input");
5650
6189
  this.#attachSlot = document.createElement("div");
6190
+ this.#voiceSlot = document.createElement("span");
6191
+ this.#themeToggle = document.createElement("button");
5651
6192
  this.#rail = document.createElement("button");
5652
6193
  this.#emptyWrap = document.createElement("div");
5653
6194
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
@@ -5772,20 +6313,44 @@ var AgUiChat = class extends HTMLElement {
5772
6313
  this.setAttribute("data-tool-display", value);
5773
6314
  }
5774
6315
  connectedCallback() {
6316
+ this.#storageNs = this.id !== "" ? this.id : this.endpoint;
5775
6317
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
6318
+ if (this.getAttribute("data-theme-toggle") !== null) {
6319
+ const saved = this.#readScopedItem(THEME_KEY);
6320
+ if (saved !== null) {
6321
+ this.setAttribute("theme", saved);
6322
+ }
6323
+ }
5776
6324
  this.#render();
5777
6325
  this.#drawer.setStrings(this.#strings);
5778
- if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
6326
+ if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
5779
6327
  this.setAttribute("collapsed", "");
5780
6328
  }
5781
6329
  this.#syncRail();
5782
6330
  this.#initSkills();
5783
6331
  void this.#fetchToolCatalog();
6332
+ if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
6333
+ this.conversationStore = new SessionStorageStore(this.#storageNs);
6334
+ }
5784
6335
  this.#wireThreadStore();
5785
6336
  this.#wireAttachments();
6337
+ this.#wireVoice();
5786
6338
  this.#threadId = this.conversationStore.threadId();
5787
6339
  void this.#rehydrate();
5788
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
+ }
5789
6354
  /** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
5790
6355
  #readStringOverrides() {
5791
6356
  const raw = this.getAttribute("data-strings");
@@ -5831,7 +6396,41 @@ var AgUiChat = class extends HTMLElement {
5831
6396
  if (url === null) {
5832
6397
  return null;
5833
6398
  }
5834
- return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
6399
+ return (file, onProgress, signal) => uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
6400
+ }
6401
+ /**
6402
+ * Reveal the composer's 🎤 mic button when transcription is possible — either
6403
+ * a custom {@link transcribeHandler} is set or `data-transcribe-url` provides
6404
+ * the built-in POST endpoint. The control records via `MediaRecorder` and
6405
+ * drops the transcript into the composer; with neither configured the mic
6406
+ * stays hidden and the chat is text-only.
6407
+ */
6408
+ #wireVoice() {
6409
+ const url = this.getAttribute("data-transcribe-url");
6410
+ const transcribe = this.transcribeHandler ?? this.#defaultTranscribeHandler(url);
6411
+ if (transcribe === null) {
6412
+ return;
6413
+ }
6414
+ this.#voice = new VoiceInput({
6415
+ transcribe,
6416
+ onText: (text2) => this.#insertVoiceText(text2),
6417
+ strings: this.#strings
6418
+ });
6419
+ this.#voiceSlot.appendChild(this.#voice.element);
6420
+ }
6421
+ /** The built-in transcription handler for `data-transcribe-url`, or `null`. */
6422
+ #defaultTranscribeHandler(url) {
6423
+ if (url === null) {
6424
+ return null;
6425
+ }
6426
+ return (audio) => transcribeAudio(audio, { url, headers: this.headers });
6427
+ }
6428
+ /** Drop a voice transcript into the composer (appended to any typed text). */
6429
+ #insertVoiceText(text2) {
6430
+ const current = this.#input.value.trim();
6431
+ this.#input.value = current === "" ? text2 : `${current} ${text2}`;
6432
+ this.#onInput();
6433
+ this.#input.focus();
5835
6434
  }
5836
6435
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
5837
6436
  #attachmentMaxBytes() {
@@ -6001,7 +6600,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6001
6600
  } else {
6002
6601
  this.removeAttribute("collapsed");
6003
6602
  }
6004
- sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
6603
+ sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
6005
6604
  this.#syncRail();
6006
6605
  this.dispatchEvent(
6007
6606
  new CustomEvent(TOGGLE_EVENT, {
@@ -6015,6 +6614,39 @@ Use the read_attachment tool with an id to read a file's contents.`
6015
6614
  toggleCollapsed() {
6016
6615
  this.setCollapsed(!this.collapsed);
6017
6616
  }
6617
+ /**
6618
+ * Flip the `theme` attribute between `light` and `dark` and persist the choice
6619
+ * per tab. Bound to the optional built-in header theme toggle
6620
+ * (`data-theme-toggle`); any non-dark theme (incl. `auto` / `code`) flips to
6621
+ * `dark` first.
6622
+ */
6623
+ toggleTheme() {
6624
+ const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
6625
+ this.setAttribute("theme", next);
6626
+ sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
6627
+ this.#syncThemeGlyph();
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
+ }
6645
+ /** Reflect the current theme on the toggle: show the destination's glyph. */
6646
+ #syncThemeGlyph() {
6647
+ const dark = this.getAttribute("theme") === "dark";
6648
+ this.#themeToggle.textContent = dark ? "\u2600\uFE0F" : "\u{1F319}";
6649
+ }
6018
6650
  /**
6019
6651
  * Start a fresh conversation: forget the persisted history, drop the
6020
6652
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -6031,6 +6663,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6031
6663
  this.#client = null;
6032
6664
  this.#streamingBubble = null;
6033
6665
  this.#currentGroup = null;
6666
+ this.#thoughts = null;
6034
6667
  this.#hidePending();
6035
6668
  this.#toolCards.clear();
6036
6669
  this.#serverSettled.clear();
@@ -6076,7 +6709,12 @@ Use the read_attachment tool with an id to read a file's contents.`
6076
6709
  * result from the page we landed on.
6077
6710
  */
6078
6711
  async #rehydrate() {
6712
+ this.#rehydrateGeneration += 1;
6713
+ const generation = this.#rehydrateGeneration;
6079
6714
  const messages = await this.conversationStore.loadMessages(this.#threadId);
6715
+ if (generation !== this.#rehydrateGeneration) {
6716
+ return;
6717
+ }
6080
6718
  if (messages !== null) {
6081
6719
  this.#initialMessages = messages;
6082
6720
  for (const message of messages) {
@@ -6163,7 +6801,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6163
6801
  * stays literal text (no need to parse what the user typed, and it avoids
6164
6802
  * rendering user-authored markup).
6165
6803
  *
6166
- * 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
6167
6805
  * needed; a user bubble closes the prior group and sits directly in the list
6168
6806
  * (the well wraps the *assistant* turn, the user message precedes it).
6169
6807
  */
@@ -6230,7 +6868,18 @@ Use the read_attachment tool with an id to read a file's contents.`
6230
6868
  newChat.addEventListener("click", () => this.newChat());
6231
6869
  const collapse = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
6232
6870
  collapse.addEventListener("click", () => this.toggleCollapsed());
6233
- controls.append(history, newChat, collapse);
6871
+ controls.append(history, newChat);
6872
+ if (this.getAttribute("data-theme-toggle") !== null) {
6873
+ this.#themeToggle.type = "button";
6874
+ this.#themeToggle.className = "header-btn header-btn--theme";
6875
+ this.#themeToggle.setAttribute("part", "header-button theme-toggle");
6876
+ this.#themeToggle.title = this.#strings.toggleTheme;
6877
+ this.#themeToggle.setAttribute("aria-label", this.#strings.toggleTheme);
6878
+ this.#themeToggle.addEventListener("click", () => this.toggleTheme());
6879
+ this.#syncThemeGlyph();
6880
+ controls.append(this.#themeToggle);
6881
+ }
6882
+ controls.append(collapse);
6234
6883
  header.append(title, headerActions, controls);
6235
6884
  this.#messages.className = "messages";
6236
6885
  this.#messages.setAttribute("part", "messages");
@@ -6282,9 +6931,10 @@ Use the read_attachment tool with an id to read a file's contents.`
6282
6931
  this.#fileInput.hidden = true;
6283
6932
  this.#fileInput.addEventListener("change", () => this.#onFilesPicked());
6284
6933
  this.#attachSlot.className = "attachment-slot";
6934
+ this.#voiceSlot.className = "voice-slot";
6285
6935
  const footer = document.createElement("slot");
6286
6936
  footer.name = "footer";
6287
- inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
6937
+ inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
6288
6938
  this.#chat.append(
6289
6939
  header,
6290
6940
  this.#messages,
@@ -6385,6 +7035,9 @@ Use the read_attachment tool with an id to read a file's contents.`
6385
7035
  this.#send.dataset["state"] = running ? "running" : "idle";
6386
7036
  }
6387
7037
  async #submit() {
7038
+ if (this.#running) {
7039
+ return;
7040
+ }
6388
7041
  const content = this.#input.value.trim();
6389
7042
  const attachments = this.#attachTray?.readyRefs() ?? [];
6390
7043
  if (content === "" && attachments.length === 0) {
@@ -6509,8 +7162,18 @@ Use the read_attachment tool with an id to read a file's contents.`
6509
7162
  this.#ensureGroup();
6510
7163
  this.#showPending();
6511
7164
  },
7165
+ onReasoningStart: () => {
7166
+ this.#hidePending();
7167
+ this.#showThoughts();
7168
+ },
7169
+ onReasoningDelta: (buffer) => {
7170
+ this.#showThoughts().stream(buffer);
7171
+ },
7172
+ onReasoningEnd: () => {
7173
+ },
6512
7174
  onTextDelta: (buffer) => {
6513
7175
  this.#hidePending();
7176
+ this.#thoughts?.collapse();
6514
7177
  this.#streamInto(buffer);
6515
7178
  this.#streamDeltas += 1;
6516
7179
  },
@@ -6562,6 +7225,7 @@ Use the read_attachment tool with an id to read a file's contents.`
6562
7225
  this.#updateEmptyState();
6563
7226
  }
6564
7227
  this.#currentGroup = null;
7228
+ this.#thoughts = null;
6565
7229
  }
6566
7230
  };
6567
7231
  }
@@ -6605,6 +7269,21 @@ Use the read_attachment tool with an id to read a file's contents.`
6605
7269
  this.#pending?.remove();
6606
7270
  this.#pending = null;
6607
7271
  }
7272
+ /**
7273
+ * The current turn's thoughts region, creating it (at the top of the answer
7274
+ * group, above any streamed text or tool cards) on first sight. Idempotent
7275
+ * across a turn's reasoning tokens.
7276
+ */
7277
+ #showThoughts() {
7278
+ if (this.#thoughts === null) {
7279
+ this.#thoughts = new ThoughtsBlock(this.#strings);
7280
+ const group = this.#ensureGroup();
7281
+ group.insertBefore(this.#thoughts.element, group.firstChild);
7282
+ this.#updateEmptyState();
7283
+ this.#messages.scrollTop = this.#messages.scrollHeight;
7284
+ }
7285
+ return this.#thoughts;
7286
+ }
6608
7287
  #streamInto(buffer) {
6609
7288
  if (this.#streamingBubble === null) {
6610
7289
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
@@ -6676,7 +7355,7 @@ function setControlValue(el, value) {
6676
7355
  }
6677
7356
 
6678
7357
  // src/version.ts
6679
- var VERSION = "0.8.1";
7358
+ var VERSION = "0.10.0";
6680
7359
  export {
6681
7360
  AgUiChat,
6682
7361
  AgUiClient,
@@ -6728,6 +7407,7 @@ export {
6728
7407
  setNativeValue,
6729
7408
  toggleCheckbox,
6730
7409
  toggleControl,
7410
+ transcribeAudio,
6731
7411
  typeInto,
6732
7412
  uploadAttachment
6733
7413
  };