@artooi/ag-ui-web-component 0.23.0 → 0.24.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 (124) hide show
  1. package/CHANGELOG.md +149 -36
  2. package/README.md +140 -9
  3. package/dist/ag-ui-web-component.bundle.js +113 -122
  4. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  5. package/dist/constants.d.ts +71 -85
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +105 -127
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +24 -30
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +9 -14
  12. package/dist/core/attachment.d.ts.map +1 -1
  13. package/dist/core/conversation_store.d.ts +38 -27
  14. package/dist/core/conversation_store.d.ts.map +1 -1
  15. package/dist/core/create_http_agent.d.ts +13 -15
  16. package/dist/core/create_http_agent.d.ts.map +1 -1
  17. package/dist/core/remote_conversation_store.d.ts +10 -9
  18. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  19. package/dist/core/run_index.d.ts +11 -20
  20. package/dist/core/run_index.d.ts.map +1 -1
  21. package/dist/core/transcribe_audio.d.ts +8 -8
  22. package/dist/core/transcribe_audio.d.ts.map +1 -1
  23. package/dist/core/upload_attachment.d.ts +15 -18
  24. package/dist/core/upload_attachment.d.ts.map +1 -1
  25. package/dist/core/utils.d.ts +4 -6
  26. package/dist/core/utils.d.ts.map +1 -1
  27. package/dist/dom/animations.d.ts +22 -30
  28. package/dist/dom/animations.d.ts.map +1 -1
  29. package/dist/dom/dom_driver.d.ts +7 -7
  30. package/dist/dom/native_setter.d.ts +2 -2
  31. package/dist/dom/native_setter.d.ts.map +1 -1
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +357 -383
  35. package/dist/index.js.map +2 -2
  36. package/dist/skills/fill_template.d.ts +4 -5
  37. package/dist/skills/fill_template.d.ts.map +1 -1
  38. package/dist/skills/parse_skills.d.ts.map +1 -1
  39. package/dist/skills/skill.d.ts +7 -8
  40. package/dist/skills/skill.d.ts.map +1 -1
  41. package/dist/tools/client_tool_registry.d.ts +2 -2
  42. package/dist/tools/page_action_tools.d.ts +7 -10
  43. package/dist/tools/page_action_tools.d.ts.map +1 -1
  44. package/dist/tools/page_state.d.ts +5 -8
  45. package/dist/tools/page_state.d.ts.map +1 -1
  46. package/dist/tools/route_map.d.ts +7 -10
  47. package/dist/tools/route_map.d.ts.map +1 -1
  48. package/dist/ui/approval_card.d.ts +15 -20
  49. package/dist/ui/approval_card.d.ts.map +1 -1
  50. package/dist/ui/attach_copy_buttons.d.ts +4 -10
  51. package/dist/ui/attach_copy_buttons.d.ts.map +1 -1
  52. package/dist/ui/attachment_chips.d.ts +7 -10
  53. package/dist/ui/attachment_chips.d.ts.map +1 -1
  54. package/dist/ui/attachment_tray.d.ts +6 -6
  55. package/dist/ui/checkpoint_menu.d.ts +7 -8
  56. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  57. package/dist/ui/confirmation_card.d.ts +10 -15
  58. package/dist/ui/confirmation_card.d.ts.map +1 -1
  59. package/dist/ui/question_card.d.ts +12 -15
  60. package/dist/ui/question_card.d.ts.map +1 -1
  61. package/dist/ui/relative_time.d.ts +5 -7
  62. package/dist/ui/relative_time.d.ts.map +1 -1
  63. package/dist/ui/render_markdown.d.ts +8 -8
  64. package/dist/ui/render_markdown.d.ts.map +1 -1
  65. package/dist/ui/resize_handle.d.ts +21 -34
  66. package/dist/ui/resize_handle.d.ts.map +1 -1
  67. package/dist/ui/run_notice.d.ts +5 -7
  68. package/dist/ui/run_notice.d.ts.map +1 -1
  69. package/dist/ui/skills_menu.d.ts +4 -5
  70. package/dist/ui/skills_menu.d.ts.map +1 -1
  71. package/dist/ui/styles.d.ts +1 -1
  72. package/dist/ui/styles.d.ts.map +1 -1
  73. package/dist/ui/thoughts_block.d.ts +9 -11
  74. package/dist/ui/thoughts_block.d.ts.map +1 -1
  75. package/dist/ui/thread_drawer.d.ts +6 -5
  76. package/dist/ui/thread_drawer.d.ts.map +1 -1
  77. package/dist/ui/tool_call_card.d.ts +17 -25
  78. package/dist/ui/tool_call_card.d.ts.map +1 -1
  79. package/dist/ui/ui_strings.d.ts +6 -12
  80. package/dist/ui/ui_strings.d.ts.map +1 -1
  81. package/dist/ui/voice_input.d.ts +10 -11
  82. package/dist/ui/voice_input.d.ts.map +1 -1
  83. package/package.json +1 -1
  84. package/src/constants.ts +74 -87
  85. package/src/core/ag_ui_chat.ts +313 -269
  86. package/src/core/agui_client.ts +60 -71
  87. package/src/core/attachment.ts +9 -14
  88. package/src/core/conversation_store.ts +55 -33
  89. package/src/core/create_http_agent.ts +18 -22
  90. package/src/core/remote_conversation_store.ts +28 -15
  91. package/src/core/run_index.ts +14 -23
  92. package/src/core/transcribe_audio.ts +9 -10
  93. package/src/core/upload_attachment.ts +18 -21
  94. package/src/core/utils.ts +4 -6
  95. package/src/dom/animations.ts +33 -43
  96. package/src/dom/dom_driver.ts +7 -7
  97. package/src/dom/native_setter.ts +11 -12
  98. package/src/index.ts +3 -0
  99. package/src/skills/fill_template.ts +4 -5
  100. package/src/skills/parse_skills.ts +3 -4
  101. package/src/skills/skill.ts +7 -8
  102. package/src/tools/client_tool_registry.ts +2 -2
  103. package/src/tools/page_action_tools.ts +12 -15
  104. package/src/tools/page_state.ts +5 -8
  105. package/src/tools/route_map.ts +15 -19
  106. package/src/ui/approval_card.ts +15 -20
  107. package/src/ui/attach_copy_buttons.ts +9 -18
  108. package/src/ui/attachment_chips.ts +7 -10
  109. package/src/ui/attachment_tray.ts +6 -6
  110. package/src/ui/checkpoint_menu.ts +7 -8
  111. package/src/ui/confirmation_card.ts +10 -15
  112. package/src/ui/question_card.ts +12 -15
  113. package/src/ui/relative_time.ts +5 -7
  114. package/src/ui/render_markdown.ts +25 -51
  115. package/src/ui/resize_handle.ts +25 -38
  116. package/src/ui/run_notice.ts +9 -12
  117. package/src/ui/skills_menu.ts +4 -5
  118. package/src/ui/styles.ts +92 -101
  119. package/src/ui/thoughts_block.ts +11 -13
  120. package/src/ui/thread_drawer.ts +6 -5
  121. package/src/ui/tool_call_card.ts +22 -32
  122. package/src/ui/ui_strings.ts +6 -12
  123. package/src/ui/voice_input.ts +10 -11
  124. package/src/version.ts +1 -1
@@ -33,9 +33,9 @@ export interface ToolExecution {
33
33
  /**
34
34
  * Executes a frontend tool call.
35
35
  *
36
- * Returns the {@link ToolExecution} to post back to the agent, or ``null`` when
37
- * the call is not a frontend tool the host owns (a server-side tool the server
38
- * already executed the client must not re-run for it).
36
+ * Returns the {@link ToolExecution} to post back to the agent, or `null` when
37
+ * the call is not a frontend tool the host owns a server-side tool the server
38
+ * already executed, which the client must not re-run.
39
39
  */
40
40
  export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
41
41
 
@@ -50,11 +50,10 @@ export type InterruptResponse = { status: "resolved"; payload?: unknown } | { st
50
50
  /**
51
51
  * Resolves the approval interrupts a run finished on, keyed by interrupt id.
52
52
  *
53
- * When a gated **server-side** tool defers instead of executing, the run
54
- * finishes on an AG-UI interrupt outcome; the host renders an approval card per
55
- * interrupt and returns each decision here, and the loop resumes the run with
56
- * the answers. Omit for agents that never gate server-side tools — an
57
- * unresolved interrupt then simply ends the loop.
53
+ * A gated server-side tool defers instead of executing, so the run finishes on
54
+ * an interrupt outcome; the host collects one decision per interrupt and the
55
+ * loop resumes with the answers. Omit for agents that never gate server-side
56
+ * tools an unresolved interrupt then ends the loop.
58
57
  */
59
58
  export type ResolveInterrupts = (
60
59
  interrupts: readonly Interrupt[],
@@ -67,9 +66,9 @@ export type ResolveInterrupts = (
67
66
  */
68
67
  export interface AgUiClientHandlers {
69
68
  onRunStart(): void;
70
- /** Fired on every streamed token; ``buffer`` is the full text so far. */
69
+ /** Fired on every streamed token; `buffer` is the full text so far. */
71
70
  onTextDelta(buffer: string): void;
72
- /** Fired when the assistant message completes; ``buffer`` is the final text. */
71
+ /** Fired when the assistant message completes; `buffer` is the final text. */
73
72
  onTextEnd(buffer: string): void;
74
73
  /** Fired when the agent finishes calling a tool (server- or frontend-side). */
75
74
  onToolCall(call: AgUiToolCall): void;
@@ -95,17 +94,15 @@ export interface AgUiClientHandlers {
95
94
  onError(message: string): void;
96
95
  /**
97
96
  * Fired when the user cancelled the run ({@link AgUiClient.cancel}) — the
98
- * deliberate-stop sibling of `onError`. Any partial assistant text already
99
- * streamed stays valid; the host should keep the bubble and show a muted
100
- * "stopped" affordance rather than an error. `onSettled` still follows.
97
+ * deliberate-stop sibling of `onError`. Partial assistant text already
98
+ * streamed stays valid, so the host keeps the bubble and shows a stopped
99
+ * affordance rather than an error. `onSettled` still follows.
101
100
  */
102
101
  onCancelled(): void;
103
102
  /**
104
- * Fired exactly once when the whole interaction settles after the run loop
105
- * ends for any reason (a server-only round, frontend-tool rounds exhausted,
106
- * a cancellation, or an error). The terminal guarantee that the UI returns
107
- * to rest (pending indicator cleared, input re-enabled) no matter how the
108
- * run finished.
103
+ * Fired exactly once when the interaction settles, however the run loop ended
104
+ * server-only round, rounds exhausted, cancellation, or error. The terminal
105
+ * guarantee that the UI returns to rest.
109
106
  */
110
107
  onSettled(): void;
111
108
  }
@@ -138,17 +135,15 @@ export interface AgUiClientConfig extends AgUiRunInputs {
138
135
  */
139
136
  onPersist?: (messages: readonly Message[]) => void;
140
137
  /**
141
- * Called whenever AG-UI shared state changes — the server streamed a
142
- * `STATE_SNAPSHOT` / `STATE_DELTA`, or {@link AgUiClient.setState} was
143
- * called. `@ag-ui/client` owns applying those events; this only forwards
144
- * the result so a host can react.
138
+ * Called whenever AG-UI shared state changes — a streamed `STATE_SNAPSHOT` /
139
+ * `STATE_DELTA`, or {@link AgUiClient.setState}. `@ag-ui/client` owns applying
140
+ * those events; this only forwards the result.
145
141
  */
146
142
  onStateChanged?: (state: Readonly<Record<string, unknown>>) => void;
147
143
  /**
148
- * Error text surfaced to {@link AgUiClientHandlers.onError} when a run's
149
- * stream closes without a terminal AG-UI event (`RUN_FINISHED`/`RUN_ERROR`)
150
- * a dropped connection. Defaults to `"Connection lost"`; the host passes its
151
- * localized string.
144
+ * Error text for {@link AgUiClientHandlers.onError} when a run's stream closes
145
+ * without a terminal AG-UI event. Defaults to `"Connection lost"`; the host
146
+ * passes its localized string.
152
147
  */
153
148
  connectionLostMessage?: string;
154
149
  }
@@ -182,10 +177,9 @@ export class AgUiClient {
182
177
  readonly #resolveInterrupts: ResolveInterrupts | null;
183
178
  readonly #onPersist: (messages: readonly Message[]) => void;
184
179
  /**
185
- * Message ids the server has already closed, so a reuse can be reported.
186
- *
187
- * Per client rather than per run: the merge happens across runs, which is the
188
- * case a per-run set would miss entirely.
180
+ * Message ids the server has already closed, so a reuse can be reported. Per
181
+ * client rather than per run — the merge happens across runs, which a per-run
182
+ * set would miss entirely.
189
183
  */
190
184
  readonly #closedMessageIds = new Set<string>();
191
185
  readonly #connectionLostMessage: string;
@@ -205,8 +199,8 @@ export class AgUiClient {
205
199
  const onStateChanged = config.onStateChanged;
206
200
  if (onStateChanged !== undefined) {
207
201
  // The agent applies STATE_SNAPSHOT / STATE_DELTA itself; subscribing is
208
- // how we learn the result rather than re-deriving it from the event
209
- // stream. Lives for the agent's lifetime, which is this client's.
202
+ // how the result is learned rather than re-derived from the event stream.
203
+ // Lives for the agent's lifetime, which is this client's.
210
204
  this.#agent.subscribe({
211
205
  onStateChanged: ({ state }) => {
212
206
  onStateChanged(state as Record<string, unknown>);
@@ -243,9 +237,8 @@ export class AgUiClient {
243
237
  * tools (bounded by {@link MAX_TOOL_ROUNDS}).
244
238
  *
245
239
  * `attachments` ride on the user message as a non-standard field so the
246
- * default client store round-trips them for history replay; the agent learns
247
- * the ids from the run context (the server's strict validation ignores the
248
- * unknown message field), then reads bytes via the `read_attachment` tool.
240
+ * default store round-trips them for history replay; see
241
+ * {@link messageAttachments}.
249
242
  */
250
243
  async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
251
244
  // Cast at the AG-UI boundary: `attachments` is a web-component augmentation
@@ -263,7 +256,7 @@ export class AgUiClient {
263
256
  /**
264
257
  * Resume the run loop after a navigating tool's result was supplied
265
258
  * post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
266
- * message it simply continues the conversation already in history.
259
+ * message; it continues the conversation already in history.
267
260
  */
268
261
  async resume(): Promise<void> {
269
262
  await this.#run();
@@ -309,21 +302,21 @@ export class AgUiClient {
309
302
  }
310
303
 
311
304
  #onCancelled(): void {
312
- // The truncated exchange (including any partial assistant text the agent
313
- // already applied) survives a reload.
305
+ // Persist so the truncated exchange, partial assistant text included,
306
+ // survives a reload.
314
307
  this.#onPersist(this.#agent.messages);
315
308
  this.#handlers.onCancelled();
316
309
  }
317
310
 
318
311
  async #runLoop(): Promise<void> {
319
- // Carries the resolved approval answers into the *next* run when a round
320
- // finished on a server-side-tool interrupt. Distinct from the public
321
- // resume() navigation-reload path (which continues an unfinished
322
- // frontend-tool round after a page load) — this stays inside one #run().
312
+ // Carries resolved approval answers into the next run when a round finished
313
+ // on a server-side-tool interrupt. Distinct from the public resume(), which
314
+ // continues an unfinished frontend-tool round after a page load; this stays
315
+ // inside one #run().
323
316
  let resume: ResumeEntry[] | undefined;
324
317
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
325
- // A cancel during the previous round's frontend-tool execution lands
326
- // here: the running handler completed, but no further round starts.
318
+ // A cancel during the previous round's tool execution lands here: the
319
+ // running handler completed, but no further round starts.
327
320
  if (this.#cancelled) {
328
321
  return;
329
322
  }
@@ -339,29 +332,28 @@ export class AgUiClient {
339
332
  await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
340
333
  resume = undefined;
341
334
  this.#onPersist(this.#agent.messages);
342
- // Cancelled mid-stream: the user said stop — don't execute the tool
343
- // calls collected before the abort.
335
+ // Cancelled mid-stream: don't execute the tool calls collected before the
336
+ // abort.
344
337
  if (this.#cancelled) {
345
338
  return;
346
339
  }
347
- // The stream resolved without RUN_FINISHED / RUN_ERROR: the transport
348
- // dropped mid-run. Surface it as an error so the UI doesn't rest silently
349
- // with a stuck pending indicator (caught by #run → onError).
340
+ // The stream resolved without RUN_FINISHED / RUN_ERROR, so the transport
341
+ // dropped mid-run. Surface it as an error (via #run) rather than resting
342
+ // silently with a stuck pending indicator.
350
343
  if (!runState.terminal) {
351
344
  throw new ConnectionLostError(this.#connectionLostMessage);
352
345
  }
353
- // RUN_ERROR is terminal: the agent already reported the failure via
354
- // onError. Don't execute the tool calls collected before it or start
355
- // another round — that would run into a broken context and surface a
356
- // confusing second error. Any pending tool card is swept at onSettled.
346
+ // RUN_ERROR is terminal and the agent already reported it via onError.
347
+ // Executing the calls collected before it, or starting another round,
348
+ // would run into a broken context and raise a confusing second error.
349
+ // Pending tool cards are swept at onSettled.
357
350
  if (runState.errored) {
358
351
  return;
359
352
  }
360
- // A gated server-side tool deferred instead of executing: the run finished
361
- // on an interrupt outcome. Ask the host to resolve each interrupt, then
362
- // re-enter the loop carrying the answers the follow-up run runs or denies
363
- // the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
364
- // over the frontend-tool sweep below: a server-side tool isn't ours to run.
353
+ // A gated server-side tool deferred, so the run finished on an interrupt
354
+ // outcome: resolve each interrupt, then re-enter carrying the answers so
355
+ // the follow-up run executes or denies the tool. Must precede the
356
+ // frontend-tool sweep below a server-side tool isn't ours to run.
365
357
  if (runState.interrupts.length > 0) {
366
358
  if (this.#resolveInterrupts === null) {
367
359
  return;
@@ -410,13 +402,10 @@ export class AgUiClient {
410
402
  h.onRunStart();
411
403
  },
412
404
  onTextMessageStartEvent({ event }) {
413
- // A server that reuses a message id gets its two answers merged into
414
- // one transcript entry, silently, and that merged entry is what gets
415
- // persisted. The protocol has no rule to enforce here and refusing the
416
- // event would be worse than the merge, so this warns and continues —
417
- // but it should not be silent, because the corruption outlives the
418
- // session and reads as a client bug. Found by a demo harness doing
419
- // exactly this.
405
+ // A reused message id merges two answers into one persisted transcript
406
+ // entry. The protocol has no rule to enforce and refusing the event
407
+ // would be worse than the merge, so warn and continue — but do not stay
408
+ // silent, since the corruption outlives the session.
420
409
  if (closed.has(event.messageId)) {
421
410
  console.warn(
422
411
  `<ag-ui-chat>: the server reused message id "${event.messageId}", which was ` +
@@ -448,9 +437,9 @@ export class AgUiClient {
448
437
  onActivitySnapshotEvent({ event }) {
449
438
  h.onActivity(event.activityType, event.content);
450
439
  },
451
- // Reasoning. `@ag-ui/client` already maps the deprecated
452
- // THINKING_* events onto these REASONING_* callbacks, so handling the
453
- // reasoning family alone covers both protocol versions.
440
+ // `@ag-ui/client` maps the deprecated THINKING_* events onto these
441
+ // REASONING_* callbacks, so the reasoning family alone covers both
442
+ // protocol versions.
454
443
  onReasoningStartEvent() {
455
444
  h.onReasoningStart();
456
445
  },
@@ -462,9 +451,9 @@ export class AgUiClient {
462
451
  },
463
452
  onRunFinishedEvent(params) {
464
453
  // RUN_FINISHED is terminal for both a normal finish and an interrupt.
465
- // Capturing the interrupts here (rather than reading the agent's
466
- // `pendingInterrupts` field afterwards) keeps the loop self-contained
467
- // and independent of that field's cross-run clearing semantics.
454
+ // Capture the interrupts here rather than reading the agent's
455
+ // `pendingInterrupts` afterwards, to stay independent of that field's
456
+ // cross-run clearing semantics.
468
457
  runState.terminal = true;
469
458
  if (params.outcome === "interrupt") {
470
459
  runState.interrupts = params.interrupts;
@@ -4,11 +4,9 @@ import type { Message } from "@ag-ui/core";
4
4
  * A durable, lightweight reference to one uploaded file — what an upload
5
5
  * returns and what rides on a sent message, never the bytes.
6
6
  *
7
- * Mirrors django-ag-ui's `AttachmentRef`: a file uploads out-of-band to the
7
+ * Mirrors django-ag-ui's `AttachmentRef`: the file uploads out-of-band to the
8
8
  * attachments endpoint, the server hands back this ref, and the agent reads the
9
- * actual content server-side via the `read_attachment` tool. Keeping the AG-UI
10
- * message stream free of base64 mirrors how the tool catalog keeps schemas off
11
- * the wire.
9
+ * content server-side via the `read_attachment` tool.
12
10
  */
13
11
  export interface AttachmentRef {
14
12
  /** Opaque, owner-scoped handle the server resolves back to bytes. */
@@ -26,17 +24,14 @@ export interface AttachmentRef {
26
24
  /**
27
25
  * The attachment refs a user message carries.
28
26
  *
29
- * Refs are stored on the user message as a non-standard `attachments` field: a
30
- * web-component augmentation that the default client store round-trips and
31
- * `@ag-ui/client` preserves through `addMessage` / `structuredClone`, so a
32
- * restored conversation re-renders its attachment chips. The server's strict
33
- * `RunAgentInput` validation ignores the unknown field — the model learns the
34
- * ids from the run context manifest instead.
27
+ * Refs ride on the message as a non-standard `attachments` field, which the
28
+ * default store round-trips and `@ag-ui/client` preserves through `addMessage`
29
+ * / `structuredClone`, so a restored conversation re-renders its chips. The
30
+ * server's strict `RunAgentInput` validation ignores the unknown field.
35
31
  *
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.
32
+ * Storage is untrusted hand-edited, truncated, or corrupted — so malformed
33
+ * entries are dropped here; a shapeless one would throw in `iconFor` and abort
34
+ * the whole history replay.
40
35
  */
41
36
  export function messageAttachments(message: Message): readonly AttachmentRef[] {
42
37
  const refs = (message as { attachments?: unknown }).attachments;
@@ -13,13 +13,11 @@ export interface NavigationCheckpoint {
13
13
  }
14
14
 
15
15
  /**
16
- * Lightweight metadata for one conversation — the thread-drawer row shape.
17
- *
18
- * Returned by {@link ClientConversationStore.listThreads} so the drawer can
19
- * render a list without loading message bodies. `title` defaults to a
20
- * truncation of the first user message (until an explicit rename); `preview`
21
- * is a one-line excerpt of the latest message; `updatedAt` is epoch ms of the
22
- * last change, used to order the list.
16
+ * Lightweight metadata for one conversation — the thread-drawer row shape, so
17
+ * the drawer renders without loading message bodies. `title` defaults to a
18
+ * truncation of the first user message until an explicit rename, `preview` is a
19
+ * one-line excerpt of the latest message, and `updatedAt` is epoch ms of the
20
+ * last change, which orders the list.
23
21
  */
24
22
  export interface ThreadMeta {
25
23
  readonly threadId: string;
@@ -32,16 +30,14 @@ export interface ThreadMeta {
32
30
  * Client-side persistence seam for the conversation and a pending-navigation
33
31
  * checkpoint, keyed by `thread_id`.
34
32
  *
35
- * The default {@link SessionStorageStore} keeps everything per-tab in
36
- * `sessionStorage`, so the chat survives the full page reloads of a
37
- * multi-page app. A host may inject a server-backed store instead (e.g. one
38
- * that rehydrates from a history endpoint); `loadMessages` and `listThreads`
39
- * are therefore async-friendly. The checkpoint methods stay synchronous — the
40
- * marker is a tiny local hint a server store can derive from history and no-op.
33
+ * The default {@link SessionStorageStore} keeps everything per-tab. A host may
34
+ * inject a server-backed store instead, which is why `loadMessages` and
35
+ * `listThreads` are async. The checkpoint methods stay synchronous: the marker
36
+ * is a small local hint a server store can derive from history and no-op.
41
37
  *
42
- * Thread enumeration (`listThreads` / `setActiveThread` / `renameThread`) backs
43
- * the chat-history drawer; "delete a thread" reuses {@link clear} and "new
44
- * chat" reuses {@link threadId} after clearing the active thread.
38
+ * Thread enumeration backs the chat-history drawer; deleting a thread reuses
39
+ * {@link clear} and "new chat" reuses {@link threadId} after clearing the
40
+ * active thread.
45
41
  */
46
42
  export interface ClientConversationStore {
47
43
  /** The active conversation id, generated and persisted on first read. */
@@ -62,6 +58,23 @@ export interface ClientConversationStore {
62
58
  setActiveThread(threadId: string): void;
63
59
  /** Set a thread's display title (the drawer renaming a row). */
64
60
  renameThread(threadId: string, title: string): void;
61
+ /**
62
+ * Whether `threadId` was minted here and has never been saved — a thread in
63
+ * which nothing has been sent yet.
64
+ *
65
+ * Exists so a server-backed store can skip asking a server for history that
66
+ * cannot be there: the element mints an id on first mount and immediately tries
67
+ * to restore it, which answers `404` and logs one in the console on every first
68
+ * visit. Nothing is wrong, and it looks exactly like something being wrong.
69
+ *
70
+ * Optional, and deliberately narrow. A store that cannot tell omits it and the
71
+ * fetch happens as before, which is also the right answer for a store that
72
+ * holds nothing locally: "I have no messages for this id" is not the same claim
73
+ * as "this id is new", and only the store that minted the id can make the
74
+ * second one. A thread picked from the drawer was never minted here, so it is
75
+ * still fetched.
76
+ */
77
+ isUnsent?(threadId: string): boolean;
65
78
  }
66
79
 
67
80
  const KEY_ROOT = "ag-ui-chat";
@@ -69,6 +82,9 @@ const THREAD_SUFFIX = "thread";
69
82
  const THREADS_SUFFIX = "threads";
70
83
  const MESSAGES_SUFFIX = "messages:";
71
84
  const CHECKPOINT_SUFFIX = "checkpoint:";
85
+ // Marks an id this store minted and nothing has been sent in yet. Dropped on
86
+ // the first save, so it never outlives the one question it answers.
87
+ const MINTED_SUFFIX = "minted:";
72
88
 
73
89
  const TITLE_LIMIT = 60;
74
90
  const PREVIEW_LIMIT = 100;
@@ -86,18 +102,15 @@ interface StoredThread {
86
102
  /**
87
103
  * Default {@link ClientConversationStore}: per-tab `sessionStorage`.
88
104
  *
89
- * Survives full page reloads and same-tab navigation, clears on tab close
90
- * the right scope for an embedded agent's conversation in a multi-page app.
91
- * Tracks multiple threads per tab: the active id lives under one key, the
92
- * message history / checkpoint are namespaced by id, and a small index feeds
93
- * the drawer so it works with no server.
105
+ * Survives full page reloads and same-tab navigation, clears on tab close.
106
+ * Tracks multiple threads per tab: the active id under one key, message history
107
+ * and checkpoint namespaced by id, and a small index feeding the drawer with no
108
+ * server involved.
94
109
  *
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.
110
+ * An optional `namespace` scopes every key to one element, so two
111
+ * `<ag-ui-chat>` instances on the same origin keep separate active-thread
112
+ * pointers and drawer indexes instead of clobbering each other. The default
113
+ * empty namespace keeps the origin-global keys; see {@link #migrateLegacyKeys}.
101
114
  */
102
115
  export class SessionStorageStore implements ClientConversationStore {
103
116
  readonly #root: string;
@@ -117,15 +130,24 @@ export class SessionStorageStore implements ClientConversationStore {
117
130
  }
118
131
  const id = randomUUID();
119
132
  sessionStorage.setItem(key, id);
133
+ sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
120
134
  return id;
121
135
  }
122
136
 
137
+ isUnsent(threadId: string): boolean {
138
+ return (
139
+ sessionStorage.getItem(this.#key(MINTED_SUFFIX + threadId)) !== null &&
140
+ sessionStorage.getItem(this.#key(MESSAGES_SUFFIX + threadId)) === null
141
+ );
142
+ }
143
+
123
144
  loadMessages(threadId: string): Promise<readonly Message[] | null> {
124
145
  return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
125
146
  }
126
147
 
127
148
  saveMessages(threadId: string, messages: readonly Message[]): void {
128
149
  sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
150
+ sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
129
151
  this.#touchThread(threadId, messages);
130
152
  }
131
153
 
@@ -145,6 +167,7 @@ export class SessionStorageStore implements ClientConversationStore {
145
167
  clear(threadId: string): void {
146
168
  sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
147
169
  sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
170
+ sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
148
171
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
149
172
  // Only drop the active pointer when the active thread itself is cleared, so
150
173
  // the next `threadId()` mints a fresh one. Deleting another thread from the
@@ -219,12 +242,11 @@ export class SessionStorageStore implements ClientConversationStore {
219
242
  }
220
243
 
221
244
  /**
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.
245
+ * One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
246
+ * namespace, so an existing conversation isn't orphaned. Only this store's own
247
+ * keys move — the element's `collapsed` / `theme` keys are left alone. The
248
+ * first namespaced instance to mount adopts the data; a second namespace
249
+ * finds it gone and starts fresh.
228
250
  */
229
251
  #migrateLegacyKeys(): void {
230
252
  const legacyRoot = `${KEY_ROOT}:`;
@@ -8,20 +8,18 @@ export interface HttpAgentOptions {
8
8
  headers?: Record<string, string>;
9
9
  /**
10
10
  * Cookie policy for the run request, as `fetch`'s own `credentials` mode.
11
- * Unset leaves the browser default (`same-origin`) alone — which sends **no**
12
- * cookies when the agent endpoint is on a different origin (or a different
13
- * subdomain) from the page. A cookie-authenticated cross-origin deployment
14
- * needs `"include"` here, and a server that answers it with
15
- * `Access-Control-Allow-Credentials: true` and a concrete origin.
11
+ * Unset leaves the browser default (`same-origin`), which sends no cookies
12
+ * to an agent endpoint on a different origin or subdomain. A
13
+ * cookie-authenticated cross-origin deployment needs `"include"` here plus a
14
+ * server answering with `Access-Control-Allow-Credentials: true` and a
15
+ * concrete origin.
16
16
  */
17
17
  credentials?: RequestCredentials;
18
18
  /**
19
- * Live header source, re-read on **every** request. `HttpAgent` bakes the
20
- * static `headers` into its constructor and the element caches the agent
21
- * for the whole conversation so a rotated token (CSRF, short-lived JWT)
22
- * would otherwise never reach the agent endpoint and a long session 401s
23
- * mid-conversation. When set, the fetch wrapper overlays these values on
24
- * each call; `headers` still seeds the initial/static configuration.
19
+ * Live header source, re-read on every request, overlaid on `headers`.
20
+ * `HttpAgent` bakes static `headers` in at construction and the element
21
+ * caches the agent for the whole conversation, so rotating credentials (CSRF,
22
+ * short-lived JWT) must come through here.
25
23
  */
26
24
  getHeaders?: () => Record<string, string>;
27
25
  /** Stable conversation id, so the agent's runs share a thread. */
@@ -37,11 +35,11 @@ export interface HttpAgentOptions {
37
35
  }
38
36
 
39
37
  /**
40
- * Build an AG-UI {@link HttpAgent} pointed at ``endpoint``.
38
+ * Build an AG-UI {@link HttpAgent} pointed at `endpoint`.
41
39
  *
42
- * This is the default agent factory used by ``<ag-ui-chat>``. Tests and
43
- * advanced hosts override the element's ``agentFactory`` to inject a
44
- * different {@link AbstractAgent} (e.g. a fake, or a middleware-wrapped one).
40
+ * The default agent factory for `<ag-ui-chat>`; tests and advanced hosts
41
+ * override the element's `agentFactory` to inject a different
42
+ * {@link AbstractAgent}.
45
43
  */
46
44
  export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
47
45
  return new HttpAgent({
@@ -49,12 +47,10 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
49
47
  headers: options.headers ?? {},
50
48
  initialState: { ...(options.initialState ?? {}) },
51
49
  // HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
52
- // which would rebind the global `fetch` to the agent instance and trigger
53
- // "Illegal invocation" in browsers. Wrap it so `fetch` is always called as
54
- // a free function with the correct receiver. The wrapper also overlays
55
- // `getHeaders()` per request, so header rotation (CSRF, short-lived JWT)
56
- // reaches the stream even though the agent instance is cached, and applies
57
- // the configured cookie policy — the agent's own config has no seam for it.
50
+ // rebinding the global `fetch` to the agent instance "Illegal invocation"
51
+ // in browsers. The wrapper keeps `fetch` a free call, and is also where the
52
+ // per-request `getHeaders()` overlay and the cookie policy go, the agent's
53
+ // own config having no seam for either.
58
54
  fetch: (url, init) => {
59
55
  const fresh = options.getHeaders?.();
60
56
  if (fresh === undefined) {
@@ -67,7 +63,7 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
67
63
  return fetch(url, withCredentials({ ...init, headers }, options.credentials));
68
64
  },
69
65
  // Spread conditionally: under `exactOptionalPropertyTypes` an explicit
70
- // `undefined` is not assignable to these optional config fields.
66
+ // `undefined` is not assignable to an optional field.
71
67
  ...(options.threadId !== undefined ? { threadId: options.threadId } : {}),
72
68
  ...(options.initialMessages !== undefined
73
69
  ? { initialMessages: [...options.initialMessages] }
@@ -19,10 +19,9 @@ interface ServerThreadRow {
19
19
  type HeadersProvider = () => Record<string, string>;
20
20
 
21
21
  /**
22
- * Live cookie policy, read per request. A provider rather than a value because
23
- * the store is built once (on connect) and kept, while a host may configure the
24
- * element after inserting it a captured value would pin whatever was set
25
- * during that first frame.
22
+ * Live cookie policy, read per request. A provider rather than a value: the
23
+ * store is built once on connect, but a host may configure the element after
24
+ * inserting it, and a captured value would pin that first frame's setting.
26
25
  */
27
26
  type CredentialsProvider = () => RequestCredentials | undefined;
28
27
 
@@ -36,11 +35,11 @@ type CredentialsProvider = () => RequestCredentials | undefined;
36
35
  * - `PATCH <url><id>/` → rename (`{ "title": … }`);
37
36
  * - `DELETE <url><id>/` → delete.
38
37
  *
39
- * It wraps a local store (default {@link SessionStorageStore}) for the
40
- * client-only concerns — the active thread id, the navigation checkpoint, and a
41
- * message cache — and as the graceful fallback when a request fails. Rename and
42
- * delete apply **optimistically** (a small local overlay) so the drawer
43
- * reflects them at once, before the fire-and-forget server round-trip lands.
38
+ * Wraps a local store (default {@link SessionStorageStore}) for the client-only
39
+ * concerns — active thread id, navigation checkpoint, message cache — and as
40
+ * the fallback when a request fails. Rename and delete apply optimistically via
41
+ * a local overlay, so the drawer reflects them before the fire-and-forget
42
+ * round-trip lands.
44
43
  */
45
44
  export class RemoteConversationStore implements ClientConversationStore {
46
45
  readonly #url: string;
@@ -70,6 +69,11 @@ export class RemoteConversationStore implements ClientConversationStore {
70
69
  this.#local.setActiveThread(threadId);
71
70
  }
72
71
 
72
+ /** Delegated, so wrapping a store does not lose what it knows about its own ids. */
73
+ isUnsent(threadId: string): boolean {
74
+ return this.#local.isUnsent?.(threadId) === true;
75
+ }
76
+
73
77
  saveMessages(threadId: string, messages: readonly Message[]): void {
74
78
  // The agent run persists server-side; keep a local cache for offline replay.
75
79
  this.#local.saveMessages(threadId, messages);
@@ -104,13 +108,22 @@ export class RemoteConversationStore implements ClientConversationStore {
104
108
  }
105
109
 
106
110
  async loadMessages(threadId: string): Promise<readonly Message[] | null> {
111
+ // Don't ask the server for a thread it cannot have. The element mints an id
112
+ // on first mount and immediately tries to restore it, so every first visit
113
+ // spent a request to be told `404` — and logged one in the console, on a page
114
+ // where nothing had gone wrong. Only the store that minted the id can say
115
+ // that; a thread chosen from the drawer, or one created on another device, is
116
+ // still fetched. See `ClientConversationStore.isUnsent`.
117
+ if (this.#local.isUnsent?.(threadId) === true) {
118
+ return null;
119
+ }
107
120
  const response = await this.#get(`${this.#url}${encodeURIComponent(threadId)}/`);
108
121
  if (response === null || !response.ok) {
109
122
  return this.#local.loadMessages(threadId);
110
123
  }
111
- // A 200 whose body isn't the expected JSON (a proxy's HTML error page, a
112
- // truncated stream) must not throw an unhandled rejection that the caller's
113
- // `void #rehydrate()` swallows fall back to the local cache instead.
124
+ // A 200 whose body isn't JSON (a proxy's HTML error page, a truncated
125
+ // stream) must not throw an unhandled rejection that the caller's
126
+ // `void #rehydrate()` swallows; fall back to the local cache.
114
127
  const body = await this.#readJson<{ messages?: readonly Message[] }>(response);
115
128
  if (body === null) {
116
129
  return this.#local.loadMessages(threadId);
@@ -143,9 +156,9 @@ export class RemoteConversationStore implements ClientConversationStore {
143
156
  return {
144
157
  threadId: row.thread_id,
145
158
  title: this.#renamed.get(row.thread_id) ?? row.title,
146
- // `null` or an unparseable date both become `NaN` (Date.parse's own
147
- // signal), which `relativeTime` renders as a neutral label rather than
148
- // "~2950w ago" (epoch 0) or "NaNw ago".
159
+ // `null` and an unparseable date both become NaN, Date.parse's own
160
+ // signal, which `relativeTime` renders as a neutral label rather than an
161
+ // epoch-0 or NaN duration.
149
162
  updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
150
163
  preview: row.preview,
151
164
  };