@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
@@ -28,9 +28,11 @@ import { renderMarkdown } from "../ui/render_markdown.js";
28
28
  import { wrapWords } from "../ui/reveal_words.js";
29
29
  import { SkillsMenu } from "../ui/skills_menu.js";
30
30
  import { STYLES } from "../ui/styles.js";
31
+ import { ThoughtsBlock } from "../ui/thoughts_block.js";
31
32
  import { ThreadDrawer } from "../ui/thread_drawer.js";
32
33
  import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
33
34
  import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
35
+ import { VoiceInput } from "../ui/voice_input.js";
34
36
  import {
35
37
  AgUiClient,
36
38
  type AgUiClientHandlers,
@@ -45,6 +47,7 @@ import {
45
47
  } from "./conversation_store.js";
46
48
  import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
47
49
  import { RemoteConversationStore } from "./remote_conversation_store.js";
50
+ import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
48
51
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
49
52
 
50
53
  /** The role a rendered chat message takes. */
@@ -65,6 +68,9 @@ export interface ToggleDetail {
65
68
  /** Per-tab persistence key for the collapsed state (survives MPA reloads). */
66
69
  const COLLAPSED_KEY = "ag-ui-chat:collapsed";
67
70
 
71
+ /** Per-tab persistence key for the built-in theme toggle. */
72
+ const THEME_KEY = "ag-ui-chat:theme";
73
+
68
74
  /**
69
75
  * `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
70
76
  *
@@ -169,6 +175,16 @@ export class AgUiChat extends HTMLElement {
169
175
  */
170
176
  uploadHandler: UploadHandler | null = null;
171
177
 
178
+ /**
179
+ * How recorded voice clips are transcribed. `null` (default) POSTs the clip
180
+ * to `data-transcribe-url` (django-ag-ui's `TranscribeView`). Set a custom
181
+ * {@link TranscribeHandler} — `(audio: Blob) => Promise<string>` — to swap the
182
+ * transport (a different STT endpoint, a browser Web Speech adapter) without
183
+ * touching the mic button. When set, the 🎤 affordance appears even with no
184
+ * `data-transcribe-url`.
185
+ */
186
+ transcribeHandler: TranscribeHandler | null = null;
187
+
172
188
  /**
173
189
  * Builds the tool result a navigating tool resumes with after the page
174
190
  * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
@@ -243,12 +259,18 @@ export class AgUiChat extends HTMLElement {
243
259
  readonly #attachButton: HTMLButtonElement;
244
260
  readonly #fileInput: HTMLInputElement;
245
261
  readonly #attachSlot: HTMLDivElement;
262
+ /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
263
+ readonly #themeToggle: HTMLButtonElement;
246
264
  /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
247
265
  readonly #rail: HTMLButtonElement;
248
266
  /** Empty-state region at the top of the message list; hidden once anything renders. */
249
267
  readonly #emptyWrap: HTMLDivElement;
250
268
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
251
269
  #attachTray: AttachmentTray | null = null;
270
+ /** Mic button mount point (input row); the control mounts on connect when enabled. */
271
+ readonly #voiceSlot: HTMLSpanElement;
272
+ /** Voice-input control; created on connect when transcription is available. */
273
+ #voice: VoiceInput | null = null;
252
274
  /** Refs attached to the message currently being sent (the context manifest). */
253
275
  #runAttachments: readonly AttachmentRef[] = [];
254
276
 
@@ -266,14 +288,25 @@ export class AgUiChat extends HTMLElement {
266
288
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
267
289
  #streamDeltas = 0;
268
290
  #pending: HTMLDivElement | null = null;
269
- // The current assistant turn's grouping container (WELL-1). One `.answer`
291
+ // The current assistant turn's grouping container. One `.answer`
270
292
  // wraps everything a single answer produces — streamed text, tool cards, the
271
293
  // pending indicator — so it can be boxed as one "well" by CSS. Opened on the
272
294
  // turn's first run start, closed at settle, so it spans the whole multi-round
273
295
  // frontend-tool loop (which is several AG-UI runs), not one run. `null`
274
296
  // between turns; user bubbles never enter it.
275
297
  #currentGroup: HTMLDivElement | null = null;
298
+ // The current turn's streamed-reasoning region, shown at the top of
299
+ // the answer group while a reasoning model thinks and collapsed once the
300
+ // answer's first text token arrives. `null` outside a reasoning turn.
301
+ #thoughts: ThoughtsBlock | null = null;
276
302
  #threadId = "";
303
+ // Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
304
+ // active thread), so two instances on one origin don't clobber each other.
305
+ // Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
306
+ #storageNs = "";
307
+ // Bumped on every #rehydrate; a replay whose generation is stale (a newer
308
+ // thread switch started while it awaited a slow store) drops its result.
309
+ #rehydrateGeneration = 0;
277
310
  #initialMessages: readonly Message[] = [];
278
311
  // Skill catalog by source; merged backend → embed → client (later wins).
279
312
  #backendSkills: readonly Skill[] = [];
@@ -292,6 +325,8 @@ export class AgUiChat extends HTMLElement {
292
325
  this.#attachButton = document.createElement("button");
293
326
  this.#fileInput = document.createElement("input");
294
327
  this.#attachSlot = document.createElement("div");
328
+ this.#voiceSlot = document.createElement("span");
329
+ this.#themeToggle = document.createElement("button");
295
330
  this.#rail = document.createElement("button");
296
331
  this.#emptyWrap = document.createElement("div");
297
332
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
@@ -442,23 +477,55 @@ export class AgUiChat extends HTMLElement {
442
477
  }
443
478
 
444
479
  connectedCallback(): void {
480
+ // Resolve the per-instance storage namespace (id, else endpoint) before any
481
+ // key read/write, so this instance doesn't share collapsed/theme/thread
482
+ // state with another on the same origin.
483
+ this.#storageNs = this.id !== "" ? this.id : this.endpoint;
445
484
  // Resolve the string table before rendering any chrome (defaults are the
446
485
  // floor; `data-strings` then the `strings` property layer over them).
447
486
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
487
+ // Restore a theme the built-in toggle persisted last visit (opt-in only, so
488
+ // it never overrides a host that drives `theme` itself).
489
+ if (this.getAttribute("data-theme-toggle") !== null) {
490
+ const saved = this.#readScopedItem(THEME_KEY);
491
+ if (saved !== null) {
492
+ this.setAttribute("theme", saved);
493
+ }
494
+ }
448
495
  this.#render();
449
496
  this.#drawer.setStrings(this.#strings);
450
- if (sessionStorage.getItem(COLLAPSED_KEY) === "1") {
497
+ if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
451
498
  this.setAttribute("collapsed", "");
452
499
  }
453
500
  this.#syncRail();
454
501
  this.#initSkills();
455
502
  void this.#fetchToolCatalog();
503
+ // Namespace the built-in default store too (a host-injected store is used
504
+ // verbatim). Must precede #wireThreadStore, which wraps the current store.
505
+ if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
506
+ this.conversationStore = new SessionStorageStore(this.#storageNs);
507
+ }
456
508
  this.#wireThreadStore();
457
509
  this.#wireAttachments();
510
+ this.#wireVoice();
458
511
  this.#threadId = this.conversationStore.threadId();
459
512
  void this.#rehydrate();
460
513
  }
461
514
 
515
+ /**
516
+ * Tear down live resources when the element leaves the DOM (a removed node, a
517
+ * client-side route swap): cancel the in-flight run so its SSE stream closes,
518
+ * abort any in-flight uploads so they don't orphan server-side files, and
519
+ * release the mic so the browser's recording indicator clears. Without this a
520
+ * removed `<ag-ui-chat>` leaks a streaming request, uploads, and a live
521
+ * `MediaRecorder`.
522
+ */
523
+ disconnectedCallback(): void {
524
+ this.#cancelRun();
525
+ this.#attachTray?.dispose();
526
+ this.#voice?.dispose();
527
+ }
528
+
462
529
  /** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
463
530
  #readStringOverrides(): Partial<UiStrings> {
464
531
  const raw = this.getAttribute("data-strings");
@@ -507,7 +574,47 @@ export class AgUiChat extends HTMLElement {
507
574
  if (url === null) {
508
575
  return null;
509
576
  }
510
- return (file, onProgress) => uploadAttachment(file, { url, headers: this.headers, onProgress });
577
+ // Forward the tray's abort signal so removing a chip (or tearing the
578
+ // element down) cancels the XHR.
579
+ return (file, onProgress, signal) =>
580
+ uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
581
+ }
582
+
583
+ /**
584
+ * Reveal the composer's 🎤 mic button when transcription is possible — either
585
+ * a custom {@link transcribeHandler} is set or `data-transcribe-url` provides
586
+ * the built-in POST endpoint. The control records via `MediaRecorder` and
587
+ * drops the transcript into the composer; with neither configured the mic
588
+ * stays hidden and the chat is text-only.
589
+ */
590
+ #wireVoice(): void {
591
+ const url = this.getAttribute("data-transcribe-url");
592
+ const transcribe = this.transcribeHandler ?? this.#defaultTranscribeHandler(url);
593
+ if (transcribe === null) {
594
+ return;
595
+ }
596
+ this.#voice = new VoiceInput({
597
+ transcribe,
598
+ onText: (text) => this.#insertVoiceText(text),
599
+ strings: this.#strings,
600
+ });
601
+ this.#voiceSlot.appendChild(this.#voice.element);
602
+ }
603
+
604
+ /** The built-in transcription handler for `data-transcribe-url`, or `null`. */
605
+ #defaultTranscribeHandler(url: string | null): TranscribeHandler | null {
606
+ if (url === null) {
607
+ return null;
608
+ }
609
+ return (audio) => transcribeAudio(audio, { url, headers: this.headers });
610
+ }
611
+
612
+ /** Drop a voice transcript into the composer (appended to any typed text). */
613
+ #insertVoiceText(text: string): void {
614
+ const current = this.#input.value.trim();
615
+ this.#input.value = current === "" ? text : `${current} ${text}`;
616
+ this.#onInput();
617
+ this.#input.focus();
511
618
  }
512
619
 
513
620
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
@@ -696,7 +803,7 @@ export class AgUiChat extends HTMLElement {
696
803
  } else {
697
804
  this.removeAttribute("collapsed");
698
805
  }
699
- sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
806
+ sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
700
807
  this.#syncRail();
701
808
  this.dispatchEvent(
702
809
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
@@ -712,6 +819,43 @@ export class AgUiChat extends HTMLElement {
712
819
  this.setCollapsed(!this.collapsed);
713
820
  }
714
821
 
822
+ /**
823
+ * Flip the `theme` attribute between `light` and `dark` and persist the choice
824
+ * per tab. Bound to the optional built-in header theme toggle
825
+ * (`data-theme-toggle`); any non-dark theme (incl. `auto` / `code`) flips to
826
+ * `dark` first.
827
+ */
828
+ toggleTheme(): void {
829
+ const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
830
+ this.setAttribute("theme", next);
831
+ sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
832
+ this.#syncThemeGlyph();
833
+ }
834
+
835
+ /** This instance's namespaced form of an origin-scoped storage key. */
836
+ #storageKey(base: string): string {
837
+ return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
838
+ }
839
+
840
+ /**
841
+ * Read a namespaced origin-scoped value, falling back once to the legacy
842
+ * pre-namespacing global key (left in place) so an existing collapsed/theme
843
+ * preference survives the upgrade.
844
+ */
845
+ #readScopedItem(base: string): string | null {
846
+ const scoped = sessionStorage.getItem(this.#storageKey(base));
847
+ if (scoped !== null || this.#storageNs === "") {
848
+ return scoped;
849
+ }
850
+ return sessionStorage.getItem(base);
851
+ }
852
+
853
+ /** Reflect the current theme on the toggle: show the destination's glyph. */
854
+ #syncThemeGlyph(): void {
855
+ const dark = this.getAttribute("theme") === "dark";
856
+ this.#themeToggle.textContent = dark ? "☀️" : "🌙";
857
+ }
858
+
715
859
  /**
716
860
  * Start a fresh conversation: forget the persisted history, drop the
717
861
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -731,6 +875,7 @@ export class AgUiChat extends HTMLElement {
731
875
  this.#client = null;
732
876
  this.#streamingBubble = null;
733
877
  this.#currentGroup = null;
878
+ this.#thoughts = null;
734
879
  this.#hidePending();
735
880
  this.#toolCards.clear();
736
881
  this.#serverSettled.clear();
@@ -781,7 +926,16 @@ export class AgUiChat extends HTMLElement {
781
926
  * result from the page we landed on.
782
927
  */
783
928
  async #rehydrate(): Promise<void> {
929
+ // Guard against a thread-switch race: with a slow remote store, picking
930
+ // thread B then C would interleave both replays into one transcript. Each
931
+ // rehydrate claims a generation before awaiting and bails if a newer one
932
+ // started meanwhile (its `#resetState` already cleared the transcript).
933
+ this.#rehydrateGeneration += 1;
934
+ const generation = this.#rehydrateGeneration;
784
935
  const messages = await this.conversationStore.loadMessages(this.#threadId);
936
+ if (generation !== this.#rehydrateGeneration) {
937
+ return;
938
+ }
785
939
  if (messages !== null) {
786
940
  this.#initialMessages = messages;
787
941
  for (const message of messages) {
@@ -879,7 +1033,7 @@ export class AgUiChat extends HTMLElement {
879
1033
  * stays literal text (no need to parse what the user typed, and it avoids
880
1034
  * rendering user-authored markup).
881
1035
  *
882
- * Assistant bubbles land in the current answer group (WELL-1), opening one if
1036
+ * Assistant bubbles land in the current answer group, opening one if
883
1037
  * needed; a user bubble closes the prior group and sits directly in the list
884
1038
  * (the well wraps the *assistant* turn, the user message precedes it).
885
1039
  */
@@ -965,7 +1119,20 @@ export class AgUiChat extends HTMLElement {
965
1119
  const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
966
1120
  collapse.addEventListener("click", () => this.toggleCollapsed());
967
1121
 
968
- controls.append(history, newChat, collapse);
1122
+ controls.append(history, newChat);
1123
+ // Optional built-in theme toggle: off unless the host opts in, so
1124
+ // it never competes with a host-supplied switch in `slot="header-actions"`.
1125
+ if (this.getAttribute("data-theme-toggle") !== null) {
1126
+ this.#themeToggle.type = "button";
1127
+ this.#themeToggle.className = "header-btn header-btn--theme";
1128
+ this.#themeToggle.setAttribute("part", "header-button theme-toggle");
1129
+ this.#themeToggle.title = this.#strings.toggleTheme;
1130
+ this.#themeToggle.setAttribute("aria-label", this.#strings.toggleTheme);
1131
+ this.#themeToggle.addEventListener("click", () => this.toggleTheme());
1132
+ this.#syncThemeGlyph();
1133
+ controls.append(this.#themeToggle);
1134
+ }
1135
+ controls.append(collapse);
969
1136
  header.append(title, headerActions, controls);
970
1137
 
971
1138
  this.#messages.className = "messages";
@@ -1035,11 +1202,14 @@ export class AgUiChat extends HTMLElement {
1035
1202
 
1036
1203
  this.#attachSlot.className = "attachment-slot";
1037
1204
 
1205
+ // Mic button mount point (kept empty until #wireVoice mounts the control).
1206
+ this.#voiceSlot.className = "voice-slot";
1207
+
1038
1208
  // A coarse footer slot below the composer.
1039
1209
  const footer = document.createElement("slot");
1040
1210
  footer.name = "footer";
1041
1211
 
1042
- inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
1212
+ inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
1043
1213
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
1044
1214
  // the missing-placeholder hint, and the pending-attachments tray.
1045
1215
  this.#chat.append(
@@ -1159,6 +1329,14 @@ export class AgUiChat extends HTMLElement {
1159
1329
  }
1160
1330
 
1161
1331
  async #submit(): Promise<void> {
1332
+ // Ignore a submit while a run is in flight — the single choke point for
1333
+ // both Enter and the Send button. The button already turns into Stop, but
1334
+ // Enter has no such guard; without this it would start a second concurrent
1335
+ // SSE run that orphans the first (unabortable) and lets the second run's
1336
+ // settle sweep corrupt the first's still-pending tool cards.
1337
+ if (this.#running) {
1338
+ return;
1339
+ }
1162
1340
  const content = this.#input.value.trim();
1163
1341
  const attachments = this.#attachTray?.readyRefs() ?? [];
1164
1342
  // Allow an attachments-only message (no typed text), but nothing empty.
@@ -1310,8 +1488,23 @@ export class AgUiChat extends HTMLElement {
1310
1488
  this.#ensureGroup();
1311
1489
  this.#showPending();
1312
1490
  },
1491
+ onReasoningStart: () => {
1492
+ // The model is thinking: swap the pending dots for a live thoughts
1493
+ // region at the top of the turn's answer group.
1494
+ this.#hidePending();
1495
+ this.#showThoughts();
1496
+ },
1497
+ onReasoningDelta: (buffer) => {
1498
+ this.#showThoughts().stream(buffer);
1499
+ },
1500
+ onReasoningEnd: () => {
1501
+ // Leave the region expanded until the answer text starts — it collapses
1502
+ // on the first text delta (onTextDelta).
1503
+ },
1313
1504
  onTextDelta: (buffer) => {
1314
1505
  this.#hidePending();
1506
+ // The answer has begun — fold the thoughts away so they don't crowd it.
1507
+ this.#thoughts?.collapse();
1315
1508
  this.#streamInto(buffer);
1316
1509
  this.#streamDeltas += 1;
1317
1510
  },
@@ -1380,6 +1573,7 @@ export class AgUiChat extends HTMLElement {
1380
1573
  this.#updateEmptyState();
1381
1574
  }
1382
1575
  this.#currentGroup = null;
1576
+ this.#thoughts = null;
1383
1577
  },
1384
1578
  };
1385
1579
  }
@@ -1427,6 +1621,22 @@ export class AgUiChat extends HTMLElement {
1427
1621
  this.#pending = null;
1428
1622
  }
1429
1623
 
1624
+ /**
1625
+ * The current turn's thoughts region, creating it (at the top of the answer
1626
+ * group, above any streamed text or tool cards) on first sight. Idempotent
1627
+ * across a turn's reasoning tokens.
1628
+ */
1629
+ #showThoughts(): ThoughtsBlock {
1630
+ if (this.#thoughts === null) {
1631
+ this.#thoughts = new ThoughtsBlock(this.#strings);
1632
+ const group = this.#ensureGroup();
1633
+ group.insertBefore(this.#thoughts.element, group.firstChild);
1634
+ this.#updateEmptyState();
1635
+ this.#messages.scrollTop = this.#messages.scrollHeight;
1636
+ }
1637
+ return this.#thoughts;
1638
+ }
1639
+
1430
1640
  #streamInto(buffer: string): HTMLDivElement {
1431
1641
  if (this.#streamingBubble === null) {
1432
1642
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
@@ -52,6 +52,12 @@ export interface AgUiClientHandlers {
52
52
  * their result itself — so this is the channel for server-executed output.
53
53
  */
54
54
  onToolResult(toolCallId: string, content: string): void;
55
+ /** Fired when a reasoning model starts emitting its chain-of-thought. */
56
+ onReasoningStart(): void;
57
+ /** Fired on every reasoning token; ``buffer`` is the full reasoning text so far. */
58
+ onReasoningDelta(buffer: string): void;
59
+ /** Fired when the reasoning block ends (before the answer text streams). */
60
+ onReasoningEnd(): void;
55
61
  onRunEnd(): void;
56
62
  onError(message: string): void;
57
63
  /**
@@ -242,7 +248,7 @@ export class AgUiClient {
242
248
  return;
243
249
  }
244
250
  const pending: AgUiToolCall[] = [];
245
- const runState = { terminal: false };
251
+ const runState = { terminal: false, errored: false };
246
252
  await this.#agent.runAgent(
247
253
  { tools: this.#getTools(), context: this.#getContext() },
248
254
  this.#buildSubscriber(pending, runState),
@@ -259,6 +265,13 @@ export class AgUiClient {
259
265
  if (!runState.terminal) {
260
266
  throw new ConnectionLostError(this.#connectionLostMessage);
261
267
  }
268
+ // RUN_ERROR is terminal: the agent already reported the failure via
269
+ // onError. Don't execute the tool calls collected before it or start
270
+ // another round — that would run into a broken context and surface a
271
+ // confusing second error. Any pending tool card is swept at onSettled.
272
+ if (runState.errored) {
273
+ return;
274
+ }
262
275
  if (this.#executeTool === null || pending.length === 0) {
263
276
  return;
264
277
  }
@@ -288,7 +301,10 @@ export class AgUiClient {
288
301
  }
289
302
  }
290
303
 
291
- #buildSubscriber(pending: AgUiToolCall[], runState: { terminal: boolean }): AgentSubscriber {
304
+ #buildSubscriber(
305
+ pending: AgUiToolCall[],
306
+ runState: { terminal: boolean; errored: boolean },
307
+ ): AgentSubscriber {
292
308
  const h = this.#handlers;
293
309
  return {
294
310
  onRunInitialized() {
@@ -312,8 +328,21 @@ export class AgUiClient {
312
328
  onToolCallResultEvent({ event }) {
313
329
  h.onToolResult(event.toolCallId, event.content);
314
330
  },
331
+ // Reasoning. `@ag-ui/client` already maps the deprecated
332
+ // THINKING_* events onto these REASONING_* callbacks, so handling the
333
+ // reasoning family alone covers both protocol versions.
334
+ onReasoningStartEvent() {
335
+ h.onReasoningStart();
336
+ },
337
+ onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
338
+ h.onReasoningDelta(reasoningMessageBuffer);
339
+ },
340
+ onReasoningEndEvent() {
341
+ h.onReasoningEnd();
342
+ },
315
343
  onRunErrorEvent({ event }) {
316
344
  runState.terminal = true;
345
+ runState.errored = true;
317
346
  h.onError(event.message);
318
347
  },
319
348
  onRunFinalized() {
@@ -32,8 +32,28 @@ export interface AttachmentRef {
32
32
  * restored conversation re-renders its attachment chips. The server's strict
33
33
  * `RunAgentInput` validation ignores the unknown field — the model learns the
34
34
  * ids from the run context manifest instead.
35
+ *
36
+ * The persisted array is untrusted (it can be hand-edited, truncated, or
37
+ * corrupted in storage), so every entry is validated and malformed ones are
38
+ * dropped — a `null` or shapeless entry would otherwise throw in `iconFor` and
39
+ * abort the whole history replay.
35
40
  */
36
41
  export function messageAttachments(message: Message): readonly AttachmentRef[] {
37
42
  const refs = (message as { attachments?: unknown }).attachments;
38
- return Array.isArray(refs) ? (refs as readonly AttachmentRef[]) : [];
43
+ return Array.isArray(refs) ? refs.filter(isAttachmentRef) : [];
44
+ }
45
+
46
+ /** Whether an unknown value is a structurally valid {@link AttachmentRef}. */
47
+ function isAttachmentRef(value: unknown): value is AttachmentRef {
48
+ if (typeof value !== "object" || value === null) {
49
+ return false;
50
+ }
51
+ const ref = value as Record<string, unknown>;
52
+ return (
53
+ typeof ref["id"] === "string" &&
54
+ typeof ref["name"] === "string" &&
55
+ typeof ref["mime"] === "string" &&
56
+ typeof ref["size"] === "number" &&
57
+ (ref["url"] === undefined || typeof ref["url"] === "string")
58
+ );
39
59
  }
@@ -64,10 +64,11 @@ export interface ClientConversationStore {
64
64
  renameThread(threadId: string, title: string): void;
65
65
  }
66
66
 
67
- const THREAD_KEY = "ag-ui-chat:thread";
68
- const THREADS_KEY = "ag-ui-chat:threads";
69
- const MESSAGES_PREFIX = "ag-ui-chat:messages:";
70
- const CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
67
+ const KEY_ROOT = "ag-ui-chat";
68
+ const THREAD_SUFFIX = "thread";
69
+ const THREADS_SUFFIX = "threads";
70
+ const MESSAGES_SUFFIX = "messages:";
71
+ const CHECKPOINT_SUFFIX = "checkpoint:";
71
72
 
72
73
  const TITLE_LIMIT = 60;
73
74
  const PREVIEW_LIMIT = 100;
@@ -90,33 +91,50 @@ interface StoredThread {
90
91
  * Tracks multiple threads per tab: the active id lives under one key, the
91
92
  * message history / checkpoint are namespaced by id, and a small index feeds
92
93
  * the drawer so it works with no server.
94
+ *
95
+ * An optional `namespace` scopes every key to one element (its `id`, else its
96
+ * endpoint), so two `<ag-ui-chat>` instances — or two apps — on the same origin
97
+ * keep separate active-thread pointers and drawer indexes instead of clobbering
98
+ * each other. Constructing with a namespace migrates any pre-namespacing
99
+ * (`ag-ui-chat:*`) keys into it once, so an existing conversation survives the
100
+ * upgrade; the default empty namespace keeps the legacy origin-global keys.
93
101
  */
94
102
  export class SessionStorageStore implements ClientConversationStore {
103
+ readonly #root: string;
104
+
105
+ constructor(namespace = "") {
106
+ this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
107
+ if (namespace !== "") {
108
+ this.#migrateLegacyKeys();
109
+ }
110
+ }
111
+
95
112
  threadId(): string {
96
- const existing = sessionStorage.getItem(THREAD_KEY);
113
+ const key = this.#key(THREAD_SUFFIX);
114
+ const existing = sessionStorage.getItem(key);
97
115
  if (existing !== null) {
98
116
  return existing;
99
117
  }
100
118
  const id = randomUUID();
101
- sessionStorage.setItem(THREAD_KEY, id);
119
+ sessionStorage.setItem(key, id);
102
120
  return id;
103
121
  }
104
122
 
105
123
  loadMessages(threadId: string): Promise<readonly Message[] | null> {
106
- return Promise.resolve(this.#readJson<Message[]>(MESSAGES_PREFIX + threadId));
124
+ return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
107
125
  }
108
126
 
109
127
  saveMessages(threadId: string, messages: readonly Message[]): void {
110
- sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
128
+ sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
111
129
  this.#touchThread(threadId, messages);
112
130
  }
113
131
 
114
132
  loadCheckpoint(threadId: string): NavigationCheckpoint | null {
115
- return this.#readJson<NavigationCheckpoint>(CHECKPOINT_PREFIX + threadId);
133
+ return this.#readJson<NavigationCheckpoint>(this.#key(CHECKPOINT_SUFFIX + threadId));
116
134
  }
117
135
 
118
136
  saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void {
119
- const key = CHECKPOINT_PREFIX + threadId;
137
+ const key = this.#key(CHECKPOINT_SUFFIX + threadId);
120
138
  if (checkpoint === null) {
121
139
  sessionStorage.removeItem(key);
122
140
  return;
@@ -125,14 +143,14 @@ export class SessionStorageStore implements ClientConversationStore {
125
143
  }
126
144
 
127
145
  clear(threadId: string): void {
128
- sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
129
- sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
146
+ sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
147
+ sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
130
148
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
131
149
  // Only drop the active pointer when the active thread itself is cleared, so
132
150
  // the next `threadId()` mints a fresh one. Deleting another thread from the
133
151
  // drawer must not disturb the conversation on screen.
134
- if (sessionStorage.getItem(THREAD_KEY) === threadId) {
135
- sessionStorage.removeItem(THREAD_KEY);
152
+ if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
153
+ sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
136
154
  }
137
155
  }
138
156
 
@@ -144,7 +162,7 @@ export class SessionStorageStore implements ClientConversationStore {
144
162
  }
145
163
 
146
164
  setActiveThread(threadId: string): void {
147
- sessionStorage.setItem(THREAD_KEY, threadId);
165
+ sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
148
166
  }
149
167
 
150
168
  renameThread(threadId: string, title: string): void {
@@ -183,15 +201,53 @@ export class SessionStorageStore implements ClientConversationStore {
183
201
  }
184
202
 
185
203
  #readThreads(): StoredThread[] {
186
- return this.#readJson<StoredThread[]>(THREADS_KEY) ?? [];
204
+ return this.#readJson<StoredThread[]>(this.#key(THREADS_SUFFIX)) ?? [];
187
205
  }
188
206
 
189
207
  #writeThreads(threads: readonly StoredThread[]): void {
208
+ const key = this.#key(THREADS_SUFFIX);
190
209
  if (threads.length === 0) {
191
- sessionStorage.removeItem(THREADS_KEY);
210
+ sessionStorage.removeItem(key);
192
211
  return;
193
212
  }
194
- sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
213
+ sessionStorage.setItem(key, JSON.stringify(threads));
214
+ }
215
+
216
+ /** This store's fully-qualified key for a suffix (namespaced when set). */
217
+ #key(suffix: string): string {
218
+ return `${this.#root}:${suffix}`;
219
+ }
220
+
221
+ /**
222
+ * One-time move of pre-namespacing (`ag-ui-chat:*`) keys into this instance's
223
+ * namespace, so an existing conversation isn't orphaned by the upgrade. Only
224
+ * this store's own keys move (thread pointer, drawer index, per-thread
225
+ * messages/checkpoints) — the element's `collapsed`/`theme` keys are left
226
+ * alone. The first namespaced instance to mount adopts the legacy data; a
227
+ * second namespace finds it gone and starts fresh.
228
+ */
229
+ #migrateLegacyKeys(): void {
230
+ const legacyRoot = `${KEY_ROOT}:`;
231
+ const moves: Array<readonly [string, string]> = [];
232
+ for (let i = 0; i < sessionStorage.length; i += 1) {
233
+ const key = sessionStorage.key(i);
234
+ if (key === null || !key.startsWith(legacyRoot)) {
235
+ continue;
236
+ }
237
+ const suffix = key.slice(legacyRoot.length);
238
+ if (isOwnedSuffix(suffix)) {
239
+ moves.push([key, this.#key(suffix)]);
240
+ }
241
+ }
242
+ // Collected first, mutated second — writing while iterating by index skips
243
+ // entries as the key list shifts.
244
+ for (const [from, to] of moves) {
245
+ const value = sessionStorage.getItem(from);
246
+ if (value !== null && sessionStorage.getItem(to) === null) {
247
+ sessionStorage.setItem(to, value);
248
+ }
249
+ sessionStorage.removeItem(from);
250
+ }
195
251
  }
196
252
 
197
253
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
@@ -208,6 +264,16 @@ export class SessionStorageStore implements ClientConversationStore {
208
264
  }
209
265
  }
210
266
 
267
+ /** Whether a legacy key suffix belongs to the store (vs the element's own keys). */
268
+ function isOwnedSuffix(suffix: string): boolean {
269
+ return (
270
+ suffix === THREAD_SUFFIX ||
271
+ suffix === THREADS_SUFFIX ||
272
+ suffix.startsWith(MESSAGES_SUFFIX) ||
273
+ suffix.startsWith(CHECKPOINT_SUFFIX)
274
+ );
275
+ }
276
+
211
277
  /** The thread title: the first user message, collapsed + truncated. */
212
278
  function deriveTitle(messages: readonly Message[]): string {
213
279
  for (const message of messages) {