@artooi/ag-ui-web-component 0.38.0 → 0.40.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 (142) hide show
  1. package/CHANGELOG.md +515 -1
  2. package/README.md +232 -56
  3. package/dist/ag-ui-web-component.bundle.js +370 -139
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +17 -14
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/activity_registry.d.ts +46 -0
  8. package/dist/core/activity_registry.d.ts.map +1 -0
  9. package/dist/core/ag_ui_chat.d.ts +23 -10
  10. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  11. package/dist/core/agui_client.d.ts +28 -0
  12. package/dist/core/agui_client.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +27 -0
  14. package/dist/core/client_seed.d.ts.map +1 -0
  15. package/dist/core/read_max_tool_rounds.d.ts +9 -0
  16. package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
  17. package/dist/core/run_handlers.d.ts +80 -0
  18. package/dist/core/run_handlers.d.ts.map +1 -0
  19. package/dist/core/storage_scope.d.ts +110 -0
  20. package/dist/core/storage_scope.d.ts.map +1 -0
  21. package/dist/core/tool_outcome.d.ts +2 -3
  22. package/dist/core/tool_outcome.d.ts.map +1 -1
  23. package/dist/core/utils.d.ts +46 -0
  24. package/dist/core/utils.d.ts.map +1 -1
  25. package/dist/index.js +16268 -14747
  26. package/dist/index.js.map +4 -4
  27. package/dist/skills/skill_catalog.d.ts +67 -0
  28. package/dist/skills/skill_catalog.d.ts.map +1 -0
  29. package/dist/skills/skill_name_from.d.ts +12 -0
  30. package/dist/skills/skill_name_from.d.ts.map +1 -0
  31. package/dist/tools/tool_catalog.d.ts +79 -0
  32. package/dist/tools/tool_catalog.d.ts.map +1 -0
  33. package/dist/tools/tool_dispatch.d.ts +104 -0
  34. package/dist/tools/tool_dispatch.d.ts.map +1 -0
  35. package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
  36. package/dist/ui/composer/auto_grow.d.ts +10 -0
  37. package/dist/ui/composer/auto_grow.d.ts.map +1 -0
  38. package/dist/ui/composer/composer_attachments.d.ts +57 -0
  39. package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
  40. package/dist/ui/composer/composer_voice.d.ts +48 -0
  41. package/dist/ui/composer/composer_voice.d.ts.map +1 -0
  42. package/dist/ui/composer/voice_input.d.ts.map +1 -1
  43. package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
  44. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
  45. package/dist/ui/fill_ui_string.d.ts +32 -0
  46. package/dist/ui/fill_ui_string.d.ts.map +1 -0
  47. package/dist/ui/history/conversation_history.d.ts +203 -0
  48. package/dist/ui/history/conversation_history.d.ts.map +1 -0
  49. package/dist/ui/history/relative_time.d.ts.map +1 -1
  50. package/dist/ui/history/thread_drawer.d.ts +0 -14
  51. package/dist/ui/history/thread_drawer.d.ts.map +1 -1
  52. package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
  53. package/dist/ui/interrupts/pending_decision.d.ts +23 -0
  54. package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
  55. package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
  56. package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
  57. package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
  58. package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
  59. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  60. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  61. package/dist/ui/placement/panel_placement.d.ts +119 -0
  62. package/dist/ui/placement/panel_placement.d.ts.map +1 -0
  63. package/dist/ui/progress/run_announcer.d.ts +41 -0
  64. package/dist/ui/progress/run_announcer.d.ts.map +1 -0
  65. package/dist/ui/progress/subagent_progress.d.ts +58 -0
  66. package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
  67. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  68. package/dist/ui/shell/adopt_styles.d.ts +23 -0
  69. package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
  70. package/dist/ui/shell/glyph_slot.d.ts +7 -0
  71. package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
  72. package/dist/ui/shell/header_button.d.ts +10 -0
  73. package/dist/ui/shell/header_button.d.ts.map +1 -0
  74. package/dist/ui/shell/icon_element.d.ts +12 -0
  75. package/dist/ui/shell/icon_element.d.ts.map +1 -0
  76. package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
  77. package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
  78. package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
  79. package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
  80. package/dist/ui/styles.d.ts +1 -1
  81. package/dist/ui/styles.d.ts.map +1 -1
  82. package/dist/ui/transcript/answer_actions.d.ts +39 -0
  83. package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
  84. package/dist/ui/transcript/answer_stream.d.ts +56 -0
  85. package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
  86. package/dist/ui/transcript/render_or_warn.d.ts +16 -0
  87. package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
  88. package/dist/ui/transcript/starter_chips.d.ts +21 -0
  89. package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
  90. package/dist/ui/transcript/transcript.d.ts +171 -0
  91. package/dist/ui/transcript/transcript.d.ts.map +1 -0
  92. package/dist/ui/ui_strings.d.ts +29 -1
  93. package/dist/ui/ui_strings.d.ts.map +1 -1
  94. package/package.json +1 -1
  95. package/src/constants.ts +17 -15
  96. package/src/core/activity_registry.ts +133 -0
  97. package/src/core/ag_ui_chat.ts +945 -4419
  98. package/src/core/agui_client.ts +172 -16
  99. package/src/core/client_seed.ts +27 -0
  100. package/src/core/read_max_tool_rounds.ts +13 -0
  101. package/src/core/remote_conversation_store.ts +1 -1
  102. package/src/core/run_handlers.ts +501 -0
  103. package/src/core/storage_scope.ts +279 -0
  104. package/src/core/tool_outcome.ts +5 -3
  105. package/src/core/utils.ts +109 -0
  106. package/src/skills/skill_catalog.ts +166 -0
  107. package/src/skills/skill_name_from.ts +19 -0
  108. package/src/tools/tool_catalog.ts +354 -0
  109. package/src/tools/tool_dispatch.ts +521 -0
  110. package/src/ui/composer/attachment_tray.ts +6 -5
  111. package/src/ui/composer/auto_grow.ts +12 -0
  112. package/src/ui/composer/composer_attachments.ts +324 -0
  113. package/src/ui/composer/composer_voice.ts +93 -0
  114. package/src/ui/composer/voice_input.ts +4 -4
  115. package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
  116. package/src/ui/fill_ui_string.ts +41 -0
  117. package/src/ui/history/conversation_history.ts +699 -0
  118. package/src/ui/history/relative_time.ts +5 -4
  119. package/src/ui/history/thread_drawer.ts +3 -50
  120. package/src/ui/interrupts/confirmation_card.ts +4 -2
  121. package/src/ui/interrupts/pending_decision.ts +34 -0
  122. package/src/ui/placement/is_collapsible_placement.ts +19 -0
  123. package/src/ui/placement/is_draggable_placement.ts +23 -0
  124. package/src/ui/placement/launcher_drag.ts +104 -89
  125. package/src/ui/placement/panel_placement.ts +1130 -0
  126. package/src/ui/progress/run_announcer.ts +67 -0
  127. package/src/ui/progress/subagent_progress.ts +201 -0
  128. package/src/ui/progress/tool_call_card.ts +2 -0
  129. package/src/ui/shell/adopt_styles.ts +28 -0
  130. package/src/ui/shell/glyph_slot.ts +12 -0
  131. package/src/ui/shell/header_button.ts +21 -0
  132. package/src/ui/shell/icon_element.ts +33 -0
  133. package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
  134. package/src/ui/shell/read_launcher_icon_url.ts +9 -0
  135. package/src/ui/styles.ts +301 -70
  136. package/src/ui/transcript/answer_actions.ts +139 -0
  137. package/src/ui/transcript/answer_stream.ts +123 -0
  138. package/src/ui/transcript/render_or_warn.ts +22 -0
  139. package/src/ui/transcript/starter_chips.ts +42 -0
  140. package/src/ui/transcript/transcript.ts +429 -0
  141. package/src/ui/ui_strings.ts +38 -1
  142. package/src/version.ts +1 -1
@@ -0,0 +1,699 @@
1
+ import { randomUUID } from "@ag-ui/client";
2
+ import type { Message } from "@ag-ui/core";
3
+ import { MESSAGE_ROLE, TOOL_OUTCOME } from "../../constants.js";
4
+ import type { ActivityRegistry } from "../../core/activity_registry.js";
5
+ import type { AgUiClient } from "../../core/agui_client.js";
6
+ import { messageAttachments } from "../../core/attachment.js";
7
+ import type { ClientSeed } from "../../core/client_seed.js";
8
+ import type {
9
+ ClientConversationStore,
10
+ NavigationCheckpoint,
11
+ } from "../../core/conversation_store.js";
12
+ import type { MessageRole } from "../../core/message_role.js";
13
+ import { RunIndex } from "../../core/run_index.js";
14
+ import { toolStatusFromOutcome } from "../../core/tool_outcome.js";
15
+ import { answerUnansweredCalls, mintThread } from "../../core/utils.js";
16
+ import type { ToolCatalog } from "../../tools/tool_catalog.js";
17
+ import type { AnswerActions } from "../transcript/answer_actions.js";
18
+ import { renderAttachmentChips } from "../transcript/attachment_chips.js";
19
+ import type { Transcript } from "../transcript/transcript.js";
20
+ import type { UiStrings } from "../ui_strings.js";
21
+ import type { CheckpointMenu, CheckpointVerb } from "./checkpoint_menu.js";
22
+ import type { RelativeTimeFormatter } from "./relative_time.js";
23
+ import type { ThreadDrawer } from "./thread_drawer.js";
24
+
25
+ /** What conversation history needs from the element that owns it. */
26
+ export interface ConversationHistoryHost {
27
+ /**
28
+ * The custom element: the attributes history reads and stamps, and the
29
+ * `this` a host's `agentFactory` and `navigationResult` are called with.
30
+ */
31
+ readonly element: HTMLElement;
32
+ /** The conversation list, which the element builds and renders. */
33
+ readonly drawer: ThreadDrawer;
34
+ /** The checkpoint panel, which the element builds and renders. */
35
+ readonly checkpoints: CheckpointMenu;
36
+ /** The transcript a restored conversation is replayed into. */
37
+ readonly transcript: Transcript;
38
+ /** The action row under each finished answer. */
39
+ readonly actions: AnswerActions;
40
+ /** The activity renderers, which redraw a restored activity. */
41
+ readonly activities: ActivityRegistry;
42
+ /** The frontend tools, whose renderers a restored call is redrawn with. */
43
+ readonly tools: ToolCatalog;
44
+ /** The composer, whose text a continuation sends. */
45
+ readonly input: HTMLTextAreaElement;
46
+ /** The one-line hint above the composer. */
47
+ readonly hint: HTMLDivElement;
48
+ /** The resolved string table. */
49
+ readonly strings: () => UiStrings;
50
+ /** The element's `conversationStore`, read per use. */
51
+ readonly conversationStore: () => ClientConversationStore;
52
+ /** The element's `formatRelativeTime`. */
53
+ readonly formatRelativeTime: () => RelativeTimeFormatter | null;
54
+ /** The element's `navigationResult`. */
55
+ readonly navigationResult: () => (checkpoint: NavigationCheckpoint) => unknown;
56
+ /** The request headers, having first reported the destination if it is foreign. */
57
+ readonly headersFor: (url: string) => Record<string, string>;
58
+ /** The configured cookie policy as `fetch` spells it. */
59
+ readonly requestCredentials: () => RequestCredentials | undefined;
60
+ /** The element's `appendMessage`, which a restored bubble opens through. */
61
+ readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
62
+ /** Resize the composer to its content. */
63
+ readonly autoGrow: () => void;
64
+ /**
65
+ * A continuation has ended, whether or not it ever ran.
66
+ *
67
+ * The composer parks a turn typed while one is in flight, and learns a run
68
+ * has settled from its own events -- which never arrive for a continuation
69
+ * that failed before starting one. Without this, such a turn stayed parked
70
+ * with nothing left to release it.
71
+ */
72
+ readonly continuationEnded: () => void;
73
+ /** The conversation's own client, or `null` until one is built. */
74
+ readonly client: () => AgUiClient | null;
75
+ /** The conversation's own client, built on first use. */
76
+ readonly ensureClient: () => AgUiClient;
77
+ /**
78
+ * A client from the same construction as the conversation's own, differing
79
+ * only in what `seed` says. A continuation's comes from here so that nothing
80
+ * added to one can be missing from the other.
81
+ */
82
+ readonly buildClient: (seed: ClientSeed) => AgUiClient;
83
+ /**
84
+ * Forget the conversation's own client, so the next one is built from
85
+ * {@link ConversationHistory.restored}. Nothing is cancelled: this is for a
86
+ * client that is not running.
87
+ */
88
+ readonly releaseClient: () => void;
89
+ /** Whether an interaction is in flight, which the composer owns. */
90
+ readonly running: () => boolean;
91
+ /** Stop the in-flight run. */
92
+ readonly cancelRun: () => void;
93
+ /** Drop the in-memory run and transcript, leaving the thread untouched. */
94
+ readonly resetState: () => void;
95
+ /** Swap the composer between Send and Stop, which the composer owns. */
96
+ readonly setRunning: (running: boolean) => void;
97
+ }
98
+
99
+ /**
100
+ * Which conversation is on screen, and how one gets there: restoring it from
101
+ * the store, switching to another from the conversation list, and continuing
102
+ * a run from the checkpoint panel.
103
+ *
104
+ * The one writer of the active thread's id and of the messages a restore
105
+ * seeded the next client with. The element reads both when it builds its
106
+ * client, and asks for a transition -- adopt the store's thread, mint a new
107
+ * one -- rather than writing either. Clearing the run, the transcript and the
108
+ * composer around a transition stays the element's, because it spans every
109
+ * part of it; history calls back for that.
110
+ *
111
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
112
+ * instance.
113
+ */
114
+ export class ConversationHistory {
115
+ readonly #host: ConversationHistoryHost;
116
+ /** The active thread's id. Empty until the element connects. */
117
+ #threadId = "";
118
+ /**
119
+ * The conversation as last written for the next client the element builds to
120
+ * start from: what the last restore replayed, or what a checkpoint
121
+ * continuation last saved after it. Emptied with the rest of the in-memory run.
122
+ */
123
+ #restored: readonly Message[] = [];
124
+ /**
125
+ * Counts the conversations cleared away, so a continuation can tell whether
126
+ * the one it continued is still on screen when it saves.
127
+ */
128
+ #cleared = 0;
129
+ // Bumped on every rehydrate; a replay whose generation is stale (a newer
130
+ // thread switch started while it awaited a slow store) drops its result.
131
+ #generation = 0;
132
+ /** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
133
+ #runIndex: RunIndex | null = null;
134
+ /**
135
+ * The checkpoint continuation in flight, so stopping the conversation's run
136
+ * reaches it. `null` when none is running.
137
+ */
138
+ #continuation: AgUiClient | null = null;
139
+
140
+ constructor(host: ConversationHistoryHost) {
141
+ this.#host = host;
142
+ }
143
+
144
+ /** The active thread's id. */
145
+ get threadId(): string {
146
+ return this.#threadId;
147
+ }
148
+
149
+ /** The conversation as last written, for seeding the next client. */
150
+ get restored(): readonly Message[] {
151
+ return this.#restored;
152
+ }
153
+
154
+ /** The checkpoint continuation in flight, or `null` when none is running. */
155
+ get continuation(): AgUiClient | null {
156
+ return this.#continuation;
157
+ }
158
+
159
+ /** Point at the thread the store says is active. */
160
+ adoptActiveThread(): void {
161
+ this.#threadId = this.#host.conversationStore().threadId();
162
+ }
163
+
164
+ /** Make a freshly minted thread the active one. */
165
+ startThread(): void {
166
+ this.#threadId = mintThread(this.#host.conversationStore());
167
+ }
168
+
169
+ /**
170
+ * Stop the checkpoint continuation in flight, if there is one.
171
+ *
172
+ * A continuation runs on a client of its own, which the element never held,
173
+ * so Stop cancelled the conversation's client and left this one streaming:
174
+ * the button read Stop and did nothing, and New chat, a thread switch or
175
+ * removing the element carried on drawing the resumed answer into whatever
176
+ * came next. The element calls this wherever it stops its own run.
177
+ */
178
+ stopContinuation(): void {
179
+ this.#continuation?.cancel();
180
+ this.#continuation = null;
181
+ }
182
+
183
+ /** Forget the messages the last restore seeded, with the rest of the run. */
184
+ forgetRestored(): void {
185
+ this.#restored = [];
186
+ // The element clears the conversation through here, so a continuation
187
+ // still saving afterwards learns the conversation is no longer this one.
188
+ this.#cleared += 1;
189
+ }
190
+
191
+ /** Delete the active thread if nothing was ever sent in it. */
192
+ reapUnsent(): void {
193
+ // A thread nothing was ever sent in has nothing to come back to, and the
194
+ // drawer never listed it — so reap it here rather than strand one record
195
+ // per press of a button whose whole use is being pressed again.
196
+ if (this.#host.conversationStore().isUnsent?.(this.#threadId) === true) {
197
+ this.#host.conversationStore().clear(this.#threadId);
198
+ }
199
+ }
200
+
201
+ /** The run index, built once from `data-runs-url`; `null` when unset. */
202
+ runs(): RunIndex | null {
203
+ const url = this.#host.element.getAttribute("data-runs-url");
204
+ if (url === null || url === "") {
205
+ return null;
206
+ }
207
+ if (this.#runIndex === null) {
208
+ this.#runIndex = new RunIndex(
209
+ url,
210
+ () => this.#host.headersFor(url),
211
+ () => this.#host.requestCredentials(),
212
+ );
213
+ }
214
+ return this.#runIndex;
215
+ }
216
+
217
+ /** Open the conversation list, dismissing the checkpoint panel. */
218
+ openThreads(): void {
219
+ // Two overlapping surfaces, so opening one dismisses the other. Clicking away
220
+ // already covers the built-in buttons, but a host driving its own chrome
221
+ // through these methods raises no pointer event — and the drawer would then
222
+ // open *underneath* a popover still floating over it.
223
+ this.#host.checkpoints.close();
224
+ void this.refreshDrawer();
225
+ this.#host.drawer.open();
226
+ }
227
+
228
+ /** Open the checkpoint panel, dismissing the conversation list. */
229
+ openCheckpoints(): void {
230
+ // The other half of the pair — see `openThreads`.
231
+ this.#host.drawer.close();
232
+ void this.#refreshCheckpoints();
233
+ this.#host.checkpoints.open();
234
+ }
235
+
236
+ /** Switch the active conversation to an existing thread and replay it. */
237
+ async switchThread(threadId: string): Promise<void> {
238
+ if (threadId === this.#threadId) {
239
+ return;
240
+ }
241
+ this.#host.cancelRun();
242
+ this.#host.resetState();
243
+ this.#host.conversationStore().setActiveThread(threadId);
244
+ this.#threadId = threadId;
245
+ this.#host.setRunning(false);
246
+ await this.rehydrate();
247
+ }
248
+
249
+ /** Rename a thread, then reload the list that shows its title. */
250
+ renameThread(threadId: string, title: string): void {
251
+ this.#host.conversationStore().renameThread(threadId, title);
252
+ void this.refreshDrawer();
253
+ }
254
+
255
+ /** Delete a thread; if it was the active one, fall back to a fresh chat. */
256
+ deleteThread(threadId: string): void {
257
+ const wasActive = threadId === this.#threadId;
258
+ if (wasActive) {
259
+ this.#host.cancelRun();
260
+ }
261
+ this.#host.conversationStore().clear(threadId);
262
+ if (wasActive) {
263
+ this.#host.resetState();
264
+ this.adoptActiveThread();
265
+ this.#host.setRunning(false);
266
+ }
267
+ void this.refreshDrawer();
268
+ }
269
+
270
+ /** Reload the drawer's thread list, marking the active thread. */
271
+ async refreshDrawer(): Promise<void> {
272
+ this.#host.drawer.setRelativeTimeFormatter(this.#host.formatRelativeTime());
273
+ this.#host.drawer.setThreads(
274
+ await this.#host.conversationStore().listThreads(),
275
+ this.#threadId,
276
+ );
277
+ }
278
+
279
+ /** Load the checkpoint panel with the runs that can actually be continued. */
280
+ async #refreshCheckpoints(): Promise<void> {
281
+ const index = this.runs();
282
+ // Pushed at render rather than at connect: `formatRelativeTime` is a
283
+ // property, so a host may set it long after the element mounted.
284
+ this.#host.checkpoints.setRelativeTimeFormatter(this.#host.formatRelativeTime());
285
+ this.#host.checkpoints.setRuns(index === null ? [] : await index.continuable());
286
+ }
287
+
288
+ /**
289
+ * Continue `runId` as a **new** run, seeded server-side from its snapshot.
290
+ *
291
+ * Uses a short-lived agent pointed at the resume / fork endpoint and seeded
292
+ * with no history, because those endpoints supply the prior turns from the
293
+ * snapshot and re-sending them would duplicate. A separate agent makes that
294
+ * structural and mints the fresh `run_id` the endpoints also require.
295
+ *
296
+ * Built by the same construction as the conversation's own client, so the
297
+ * continuation streams into the same transcript the user is looking at and
298
+ * runs under the same state, tools and bounds. It is the run in flight while
299
+ * it lasts: {@link stopContinuation} is how the element's Stop reaches it.
300
+ *
301
+ * What it adds joins the conversation. Its saves write the conversation on
302
+ * screen ahead of its exchange, and each one hands that whole list to the
303
+ * next client the element builds, so the next ordinary message is sent with
304
+ * the exchange it follows. The conversation's own client never held it, and
305
+ * was sending without it.
306
+ */
307
+ async continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
308
+ const index = this.runs();
309
+ if (index === null) {
310
+ // Unreachable from the built-in control: the header button is only
311
+ // rendered when `runs()` is configured, so a row to pick cannot exist
312
+ // without one. A host calling `openCheckpoints()` regardless gets the
313
+ // documented empty panel, which has no rows either. Typed, not silent.
314
+ return;
315
+ }
316
+ if (this.#host.running() || this.#continuation !== null) {
317
+ // Refused rather than started beside it. The panel opens and its rows
318
+ // take a pick while a run streams, and a second run would draw its answer
319
+ // into the turn still arriving; a second continuation would also replace
320
+ // the one Stop reaches, leaving the first streaming where nothing could
321
+ // end it. Nor is the earlier run cancelled for it: a pick in a panel is not
322
+ // a Stop, and what is streaming may be the answer the user is waiting on.
323
+ //
324
+ // Both checks, because they see different moments. `running` is the
325
+ // composer's own state and spans every round of an interaction, but it
326
+ // is set when the run's first event arrives; a continuation is recorded
327
+ // here the moment it starts.
328
+ //
329
+ // Said at the composer, as an empty composer is below, because the row
330
+ // closed the panel before this ran. The typed turn stays where it is --
331
+ // it is what the user wants sent once the run is done -- and the caret
332
+ // goes back to it, where Escape stops the run.
333
+ this.#refuse(this.#host.strings().continueWhileRunning);
334
+ return;
335
+ }
336
+ const content = this.#host.input.value.trim();
337
+ if (content === "") {
338
+ // A continuation sends *only* the next turn -- the snapshot supplies
339
+ // everything before it -- so with an empty composer there is nothing to
340
+ // send. Returning here was the same failure the endpoint guard above had:
341
+ // the row's button closes the panel before this runs, so the widget
342
+ // visibly reacted and then did nothing, which reads as a resume that was
343
+ // attempted and lost rather than one that never started.
344
+ //
345
+ // Said at the composer rather than in the transcript, because that is
346
+ // where the fix goes and because the hint clears itself on the first
347
+ // keystroke -- a transcript notice for a recoverable slip would outlive
348
+ // the slip. Focus follows for the same reason applying a skill moves it when
349
+ // a template is short of a field.
350
+ this.#refuse(this.#host.strings().continueNeedsTurn);
351
+ return;
352
+ }
353
+ this.#host.input.value = "";
354
+ this.#host.autoGrow();
355
+ const cleared = this.#cleared;
356
+ const client = this.#host.buildClient({
357
+ endpoint: verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId),
358
+ // The seed the endpoints assume: nothing. The snapshot is the history.
359
+ initialMessages: [],
360
+ // What its saves write ahead of the exchange: the conversation on screen,
361
+ // in the form the store holds it. From the conversation's own client when
362
+ // there is one, because that client keeps how each call ended beside its
363
+ // messages rather than on them; otherwise what the last restore or
364
+ // continuation wrote, which is already in that form.
365
+ //
366
+ // All of it, even where this forks an earlier run and the server's
367
+ // snapshot stops there: what is saved is what the screen shows.
368
+ follows: this.#host.client()?.annotatedMessages ?? this.#restored,
369
+ onSaved: (conversation) => {
370
+ // A continuation stopped by New chat or a thread switch saves once its
371
+ // request closes, into its own thread; the conversation now on screen
372
+ // is not the one it continued.
373
+ if (cleared !== this.#cleared) {
374
+ return;
375
+ }
376
+ // The conversation's own client holds the conversation without this
377
+ // exchange, and would send it that way. Released rather than patched:
378
+ // the next client is built from this list exactly as a reload builds
379
+ // one from the store, outcomes included.
380
+ //
381
+ // Nothing can run on the released client in between, because the
382
+ // composer refuses to start one while a continuation is in flight --
383
+ // it parks the turn instead. It said here that only a script in the
384
+ // same task could, which was wrong by a whole request: `running` does
385
+ // not turn on until the continuation's first event, and a person who
386
+ // typed in that window got a second run against the snapshot
387
+ // `follows` had already frozen, so whichever saved last dropped the
388
+ // other's turn.
389
+ this.#restored = conversation;
390
+ this.#host.releaseClient();
391
+ },
392
+ });
393
+ this.#continuation = client;
394
+ try {
395
+ await client.send(content);
396
+ } finally {
397
+ // Only if it is still the one in flight: stopping forgets it at once, and
398
+ // a continuation started after that one is not this one to forget.
399
+ //
400
+ // In a finally because the send can fail before its run ever starts. The
401
+ // first save goes through the host's `conversationStore` synchronously
402
+ // inside it, and a store is the host's to replace: the built-in one
403
+ // swallows a write the browser refused, a server-backed one need not. A
404
+ // throw there left this pointing at a client that would never run, so
405
+ // every later pick was refused with no Stop to clear it -- the composer's
406
+ // button is Send until a run reports a start -- and `#liveClient` went on
407
+ // handing that dead client the shared state a host wrote.
408
+ if (this.#continuation === client) {
409
+ this.#continuation = null;
410
+ this.#host.continuationEnded();
411
+ }
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Say at the composer why a picked run did not continue, and put the caret
417
+ * there. The hint clears itself on the next keystroke.
418
+ */
419
+ #refuse(reason: string): void {
420
+ this.#host.hint.textContent = reason;
421
+ this.#host.hint.hidden = false;
422
+ this.#host.input.focus();
423
+ }
424
+
425
+ /**
426
+ * Restore the conversation from the store on mount, then — if a navigating
427
+ * tool reloaded the page mid-run — resume the loop by supplying that tool's
428
+ * result from the page we landed on.
429
+ *
430
+ * Any other call the stored run left unanswered is answered on the way in, as
431
+ * not finished, because no run is left to answer it.
432
+ *
433
+ * **What the store holds after a reload mid-run.** The run loop persists a
434
+ * round when its stream ends, which is *before* it asks about a gated call,
435
+ * runs a frontend tool, or collects a server-side approval. A reload in that
436
+ * window leaves the round's calls stored with no result, and the request that
437
+ * would have produced one died with the page.
438
+ *
439
+ * **Why not finished, and not declined.** Stop declines an open card because
440
+ * pressing it is a person answering the question. A reload answers nothing, and
441
+ * the stored shape is the same whether the round waited on a person or on a
442
+ * handler the reload killed, so declined would be unproven for the first and
443
+ * false for the second. A marker saved while a card is open could tell them
444
+ * apart, but every host store would have to round-trip it, and a store that
445
+ * dropped it would fall back to this wording anyway -- which is true of both.
446
+ *
447
+ * The answers come from the same helper the client runs before each request,
448
+ * so a restored card and the result the next request carries cannot disagree.
449
+ * Giving them to the replay, rather than leaving the client to add them, is
450
+ * what settles each card from its result. The checkpointed call is excluded,
451
+ * because the resume path answers it from the page the reload landed on; that
452
+ * exclusion is held by "still resumes with the landed page's result, and is not
453
+ * declined" in `ag_ui_chat_reload_mid_run.test.ts`.
454
+ */
455
+ async rehydrate(): Promise<void> {
456
+ // Guard against a thread-switch race: with a slow remote store, picking
457
+ // thread B then C would interleave both replays into one transcript. Each
458
+ // rehydrate claims a generation before awaiting and bails if a newer one
459
+ // started meanwhile (its reset already cleared the transcript).
460
+ this.#generation += 1;
461
+ const generation = this.#generation;
462
+ // Held while the store answers. A remote store answers after first paint,
463
+ // and a conversation it is still fetching is more likely to have messages
464
+ // than not, so without this the page would paint the greeting and a centred
465
+ // composer and then drop the composer the moment they land. The built-in
466
+ // store answers in a microtask, before paint, so for it this never reaches
467
+ // the screen. Released in `finally` so a store that rejects cannot leave the
468
+ // layout held for good, and only by the restore that is still current.
469
+ this.#host.element.setAttribute("data-restoring", "");
470
+ let messages: readonly Message[] | null;
471
+ try {
472
+ messages = await this.#host.conversationStore().loadMessages(this.#threadId);
473
+ } finally {
474
+ if (generation === this.#generation) {
475
+ this.#host.element.removeAttribute("data-restoring");
476
+ }
477
+ }
478
+ if (generation !== this.#generation) {
479
+ return;
480
+ }
481
+ // Read before the replay rather than after it: the checkpointed call is the
482
+ // one unanswered call a reload was *expected* by, and the replay has to know
483
+ // which it is so as not to settle it as abandoned.
484
+ const checkpoint = this.#host.conversationStore().loadCheckpoint(this.#threadId);
485
+ if (messages !== null) {
486
+ const unfinished = this.#host.strings().callNotFinished;
487
+ const restored = answerUnansweredCalls(
488
+ messages,
489
+ // The shape the store holds for a call the client answered the same way:
490
+ // its tool message, with the outcome it annotates onto the stored copy.
491
+ // Cast at the AG-UI boundary, as the client's own annotation is.
492
+ (toolCallId) =>
493
+ ({
494
+ id: randomUUID(),
495
+ role: "tool",
496
+ content: unfinished,
497
+ toolCallId,
498
+ outcome: TOOL_OUTCOME.INTERRUPTED,
499
+ }) as Message,
500
+ new Set(checkpoint === null ? [] : [checkpoint.toolCallId]),
501
+ );
502
+ this.#restored = restored;
503
+ for (const message of restored) {
504
+ this.replay(message);
505
+ }
506
+ }
507
+ if (checkpoint !== null) {
508
+ await this.#resumeFrom(checkpoint);
509
+ return;
510
+ }
511
+ this.#noticeIfRunUnfinished(messages);
512
+ }
513
+
514
+ /**
515
+ * Notice a previous run that never produced a response.
516
+ *
517
+ * {@link AgUiClient.send} persists the user's message before starting the
518
+ * run, so a transcript ending on that user message means nothing came back.
519
+ * The transcript's shape alone detects it, needing no store method and no
520
+ * `pagehide` listener — neither of which fires on a crash or force-quit.
521
+ *
522
+ * An agent-initiated reload is not this case: a navigating tool leaves a
523
+ * checkpoint and resumes, so the caller returns early on one.
524
+ *
525
+ * Deliberately a notice, never a resume. AG-UI has no resume-an-aborted-run
526
+ * primitive, and re-sending the accumulated messages is semantically a new
527
+ * run, so any server-side tool already executed would run a second time.
528
+ */
529
+ #noticeIfRunUnfinished(messages: readonly Message[] | null): void {
530
+ const last = messages?.at(-1);
531
+ if (last === undefined || last.role !== MESSAGE_ROLE.USER) {
532
+ return;
533
+ }
534
+ this.#host.transcript.appendNotice("⚠", this.#host.strings().runInterrupted, "interrupted");
535
+ }
536
+
537
+ /**
538
+ * Replay a restored message: text bubbles *and* tool activity. An assistant
539
+ * turn may carry `toolCalls` (rendered as cards) and/or text; a `tool` turn
540
+ * carries a result that settles the matching card. So a refreshed page shows
541
+ * the full transcript — tool calls and their results — not just the prose.
542
+ */
543
+ replay(message: Message): void {
544
+ const text = typeof message.content === "string" ? message.content : "";
545
+ if (message.role === MESSAGE_ROLE.USER) {
546
+ const attachments = messageAttachments(message);
547
+ if (text !== "" || attachments.length > 0) {
548
+ const bubble = this.#host.appendMessage(MESSAGE_ROLE.USER, text);
549
+ if (attachments.length > 0) {
550
+ bubble.appendChild(renderAttachmentChips(attachments));
551
+ }
552
+ }
553
+ return;
554
+ }
555
+ if (message.role === MESSAGE_ROLE.ASSISTANT) {
556
+ if (text !== "") {
557
+ // Restored history must appear statically — entrance animations
558
+ // (fade / word) are for freshly-arriving messages. On reload the whole
559
+ // transcript mounts at once, so animating every bubble's text in
560
+ // parallel looks wrong. Mark it so the fade CSS skips it, and don't
561
+ // wrap words.
562
+ const restoredBubble = this.#host.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
563
+ restoredBubble.classList.add("message--restored");
564
+ this.#host.actions.attach(restoredBubble);
565
+ }
566
+ // Narrowed rather than trusted, for the same reason `messageAttachments`
567
+ // narrows the neighbouring field: anything that throws in this loop aborts
568
+ // the replay at this message, and every later turn silently disappears from
569
+ // the transcript. See `restoredToolCalls`.
570
+ for (const call of restoredToolCalls(message.toolCalls)) {
571
+ const restored = {
572
+ id: call.id,
573
+ name: call.function.name,
574
+ args: this.#parseArgs(call.function.arguments),
575
+ };
576
+ // Restored history goes through the same interception as the live
577
+ // stream — otherwise a reload resurrects the raw `load_capability`
578
+ // card the live path deliberately replaced.
579
+ if (this.#host.transcript.noticeIfSkillLoad(restored)) {
580
+ continue;
581
+ }
582
+ this.#host.transcript.setCardElement(
583
+ restored.id,
584
+ this.#host.transcript.cardFor(restored).element,
585
+ );
586
+ // Only `render` is replayed, never `handler`. A restored transcript
587
+ // redraws what the call drew; it must not re-run what the call *did*.
588
+ // Only the renderer is handed over, never the tool. The guarantee that
589
+ // a reload cannot re-run a tool's *effect* is worth more than a comment
590
+ // saying so: this signature cannot reach `handler`, so a later
591
+ // maintainer adding a "no render? fall back to the handler" convenience
592
+ // here has to change the type first, which is exactly the moment the
593
+ // question should be asked.
594
+ const render = this.#host.tools.resolve(restored.name)?.render;
595
+ if (render !== undefined) {
596
+ this.#host.transcript.renderToolOutput(render, restored);
597
+ }
598
+ }
599
+ return;
600
+ }
601
+ if (message.role === "activity") {
602
+ // The client materialises a pushed activity as a message of its own, so a
603
+ // chart's data is in the transcript already and survives a reload. Only
604
+ // the drawing had to be put back.
605
+ const activity = message as unknown as { activityType?: unknown; content?: unknown };
606
+ if (typeof activity.activityType === "string") {
607
+ this.#host.activities.draw(message.id, activity.activityType, activity.content);
608
+ }
609
+ return;
610
+ }
611
+ if (message.role === "tool") {
612
+ const card = this.#host.transcript.card(message.toolCallId);
613
+ if (card !== undefined) {
614
+ // The outcome `AgUiClient` annotated onto the persisted message, read
615
+ // back through the same mapping the live path uses -- so a card that
616
+ // said "declined" before the reload still says it after. Narrowed off
617
+ // `unknown` rather than trusted, like every other field read out of the
618
+ // store: `Message` does not declare it, a host store may not round-trip
619
+ // it, and history written before this shipped has none. All three land
620
+ // on DONE, which is what this line did unconditionally.
621
+ card.settle(
622
+ toolStatusFromOutcome((message as { outcome?: unknown }).outcome),
623
+ message.content,
624
+ );
625
+ }
626
+ }
627
+ }
628
+
629
+ /** Parse a tool call's JSON `arguments` from history into an object. */
630
+ #parseArgs(raw: unknown): Record<string, unknown> {
631
+ if (typeof raw !== "string") {
632
+ // A restored call whose `arguments` are missing or not a string still has
633
+ // a name worth showing, so this renders an empty-args card rather than
634
+ // dropping the card.
635
+ return {};
636
+ }
637
+ try {
638
+ const parsed: unknown = JSON.parse(raw);
639
+ if (typeof parsed === "object" && parsed !== null) {
640
+ return parsed as Record<string, unknown>;
641
+ }
642
+ } catch {
643
+ // Malformed history — fall back to empty args rather than failing replay.
644
+ }
645
+ return {};
646
+ }
647
+
648
+ /** Complete the checkpointed navigating tool call and continue the run. */
649
+ async #resumeFrom(checkpoint: NavigationCheckpoint): Promise<void> {
650
+ this.#host.conversationStore().saveCheckpoint(this.#threadId, null);
651
+ const client = this.#host.ensureClient();
652
+ client.addToolResult(
653
+ checkpoint.toolCallId,
654
+ // Called on the element, as `this.navigationResult(...)` always was.
655
+ JSON.stringify(this.#host.navigationResult().call(this.#host.element, checkpoint)),
656
+ );
657
+ await client.resume();
658
+ }
659
+ }
660
+
661
+ /** One tool call as a restored assistant message carries it. */
662
+ interface RestoredToolCall {
663
+ readonly id: string;
664
+ readonly function: { readonly name: string; readonly arguments?: unknown };
665
+ }
666
+
667
+ /**
668
+ * The tool calls a restored assistant turn carries, with anything shapeless dropped.
669
+ *
670
+ * Narrowing here rather than trusting the declared type, for three reasons that
671
+ * point the same way.
672
+ *
673
+ * **`null` is a value this field really takes.** `@ag-ui/core` types `toolCalls`
674
+ * as optional (`z.ZodOptional`), so TypeScript offers only `undefined` — but the
675
+ * protocol's Python models declare `tool_calls: list[ToolCall] | None`, and a
676
+ * server dumping them without `exclude_none` sends `null`. The two SDKs disagree
677
+ * about the wire, and a client cannot afford to take either one's word for it.
678
+ *
679
+ * **A throw here costs the rest of the transcript.** This runs inside the replay
680
+ * of stored history, one message at a time; an exception aborts the whole replay,
681
+ * so a single bad entry silently truncates the conversation from that point on —
682
+ * with no error state and nothing on screen to explain the gap.
683
+ *
684
+ * **Storage is untrusted anyway** — hand-edited, truncated, written by an older
685
+ * version, or supplied by a host's own store. `messageAttachments` already takes
686
+ * exactly this stance for the neighbouring field on the same message.
687
+ */
688
+ function restoredToolCalls(value: unknown): readonly RestoredToolCall[] {
689
+ return Array.isArray(value) ? value.filter(isRestoredToolCall) : [];
690
+ }
691
+
692
+ /** Whether an unknown history entry has enough shape to render a tool card. */
693
+ function isRestoredToolCall(value: unknown): value is RestoredToolCall {
694
+ if (typeof value !== "object" || value === null) {
695
+ return false;
696
+ }
697
+ const call = value as { id?: unknown; function?: { name?: unknown } };
698
+ return typeof call.id === "string" && typeof call.function?.name === "string";
699
+ }