@artooi/ag-ui-web-component 0.39.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 (46) hide show
  1. package/CHANGELOG.md +155 -1
  2. package/README.md +70 -12
  3. package/dist/ag-ui-web-component.bundle.js +168 -45
  4. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  5. package/dist/core/ag_ui_chat.d.ts +7 -3
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +12 -0
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/client_seed.d.ts +14 -2
  10. package/dist/core/client_seed.d.ts.map +1 -1
  11. package/dist/index.js +636 -231
  12. package/dist/index.js.map +2 -2
  13. package/dist/tools/tool_catalog.d.ts.map +1 -1
  14. package/dist/ui/composer/composer_attachments.d.ts +6 -1
  15. package/dist/ui/composer/composer_attachments.d.ts.map +1 -1
  16. package/dist/ui/composer/composer_voice.d.ts +3 -0
  17. package/dist/ui/composer/composer_voice.d.ts.map +1 -1
  18. package/dist/ui/excerpts/transcript_quote_offer.d.ts +7 -2
  19. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -1
  20. package/dist/ui/history/conversation_history.d.ts +29 -3
  21. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  22. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  23. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  24. package/dist/ui/placement/panel_placement.d.ts +1 -1
  25. package/dist/ui/placement/panel_placement.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/transcript/transcript.d.ts +3 -2
  29. package/dist/ui/transcript/transcript.d.ts.map +1 -1
  30. package/dist/ui/ui_strings.d.ts +2 -0
  31. package/dist/ui/ui_strings.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/core/ag_ui_chat.ts +217 -64
  34. package/src/core/agui_client.ts +26 -14
  35. package/src/core/client_seed.ts +14 -2
  36. package/src/tools/tool_catalog.ts +63 -12
  37. package/src/ui/composer/composer_attachments.ts +64 -41
  38. package/src/ui/composer/composer_voice.ts +5 -0
  39. package/src/ui/excerpts/transcript_quote_offer.ts +26 -13
  40. package/src/ui/history/conversation_history.ts +125 -17
  41. package/src/ui/placement/launcher_drag.ts +104 -89
  42. package/src/ui/placement/panel_placement.ts +27 -3
  43. package/src/ui/styles.ts +137 -14
  44. package/src/ui/transcript/transcript.ts +10 -5
  45. package/src/ui/ui_strings.ts +3 -0
  46. package/src/version.ts +1 -1
@@ -615,27 +615,39 @@ export class AgUiClient {
615
615
  * unannotated save quietly threw the annotations away.
616
616
  */
617
617
  #persist(): void {
618
+ this.#onPersist(this.annotatedMessages);
619
+ }
620
+
621
+ /**
622
+ * The history in the form a save writes it: {@link messages}, with how each
623
+ * tool call ended annotated onto its result -- exactly what
624
+ * {@link AgUiClientConfig.onPersist} is handed.
625
+ *
626
+ * For writing the conversation somewhere other than a save. The element needs
627
+ * it when a checkpoint continuation adds to a conversation this client holds:
628
+ * the continuation's saves write the conversation ahead of the exchange, and
629
+ * the bare {@link messages} would drop the annotations, so a declined card
630
+ * turned green on the next reload.
631
+ */
632
+ get annotatedMessages(): readonly Message[] {
618
633
  const messages = this.#agent.messages;
619
634
  if (this.#outcomes.size === 0) {
620
- this.#onPersist(messages);
621
- return;
635
+ return messages;
622
636
  }
623
637
  // A copy, and only of the messages that gain something. `agent.messages` is
624
638
  // the list the next `runAgent` sends back to the server, so writing an extra
625
639
  // field into it would put a client-side annotation on the wire; a store is
626
640
  // allowed to hold more than the protocol does.
627
- this.#onPersist(
628
- messages.map((message) => {
629
- if (message.role !== "tool") {
630
- return message;
631
- }
632
- const outcome = this.#outcomes.get(message.toolCallId);
633
- // Cast at the AG-UI boundary, as the `attachments` augmentation on a
634
- // user message already does: `Message` does not declare the field, and
635
- // the default store round-trips it through `JSON.stringify` verbatim.
636
- return outcome === undefined ? message : ({ ...message, outcome } as Message);
637
- }),
638
- );
641
+ return messages.map((message) => {
642
+ if (message.role !== "tool") {
643
+ return message;
644
+ }
645
+ const outcome = this.#outcomes.get(message.toolCallId);
646
+ // Cast at the AG-UI boundary, as the `attachments` augmentation on a
647
+ // user message already does: `Message` does not declare the field, and
648
+ // the default store round-trips it through `JSON.stringify` verbatim.
649
+ return outcome === undefined ? message : ({ ...message, outcome } as Message);
650
+ });
639
651
  }
640
652
 
641
653
  async #runLoop(): Promise<void> {
@@ -10,6 +10,18 @@ export interface ClientSeed {
10
10
  readonly endpoint: string;
11
11
  /** The history the agent starts from, and sends with its first run. */
12
12
  readonly initialMessages: readonly Message[];
13
- /** Whether the client writes its messages to the conversation store. */
14
- readonly persist: boolean;
13
+ /**
14
+ * The conversation this client's own history continues, which every save
15
+ * writes ahead of it.
16
+ *
17
+ * Empty for the conversation's own client, whose history is the whole
18
+ * conversation. A continuation's history is only the turn it adds and its
19
+ * answer -- the endpoint supplies the rest from its snapshot -- and a store
20
+ * keeps one list per thread, so a save of that history alone would replace
21
+ * the conversation with its last exchange, and not saving it lost the
22
+ * exchange on the next reload.
23
+ */
24
+ readonly follows: readonly Message[];
25
+ /** Handed the whole conversation each time the client saves it, as written. */
26
+ readonly onSaved?: (conversation: readonly Message[]) => void;
15
27
  }
@@ -264,13 +264,13 @@ export class ToolCatalog {
264
264
  },
265
265
  required: ["question"],
266
266
  },
267
- handler: (args) => this.#askUser(args),
267
+ handler: (args, callId) => this.#askUser(args, callId),
268
268
  },
269
269
  ];
270
270
  }
271
271
 
272
272
  /** Render the `ask_user` question card and resolve with the user's answer. */
273
- async #askUser(args: Record<string, unknown>): Promise<string> {
273
+ async #askUser(args: Record<string, unknown>, callId: string | undefined): Promise<string> {
274
274
  const question = typeof args["question"] === "string" ? args["question"] : "";
275
275
  const request: QuestionRequest = { question };
276
276
  const rawOptions = args["options"];
@@ -284,20 +284,71 @@ export class ToolCatalog {
284
284
  // it with an empty answer (the run is then cancelled).
285
285
  const signal = this.#host.decision.open();
286
286
  this.#host.hidePending();
287
- // A host-supplied renderer takes full control of the UI; otherwise the
288
- // built-in inline card renders into the current answer group.
287
+ // The built-in inline card renders into the current answer group.
288
+ const builtIn = (): Promise<string> =>
289
+ requestQuestion(this.#host.ensureGroup(), request, {
290
+ signal,
291
+ strings: this.#host.strings(),
292
+ });
293
+ // A host-supplied renderer takes full control of the UI.
289
294
  const renderer = this.#host.askUserRenderer();
290
- const answer =
291
- renderer !== null
292
- ? // Called on the element, as `this.askUserRenderer(...)` always was.
293
- await renderer.call(this.#host.element, request, { signal })
294
- : await requestQuestion(this.#host.ensureGroup(), request, {
295
- signal,
296
- strings: this.#host.strings(),
297
- });
295
+ let answer: string;
296
+ if (renderer === null) {
297
+ answer = await builtIn();
298
+ } else {
299
+ // Awaited here rather than inside a helper, so an answering renderer
300
+ // takes exactly as many turns to be heard as it always did.
301
+ try {
302
+ // Called on the element, as `this.askUserRenderer(...)` always was.
303
+ answer = await renderer.call(this.#host.element, request, { signal });
304
+ } catch (error) {
305
+ answer = await this.#afterRendererFailed(error, signal, callId, builtIn);
306
+ }
307
+ }
298
308
  this.#host.decision.close();
299
309
  this.#host.updateEmptyState();
300
310
  this.#host.follow();
301
311
  return answer;
302
312
  }
313
+
314
+ /**
315
+ * Answer an `ask_user` call whose host renderer threw or rejected instead of
316
+ * answering: put the question to the built-in card.
317
+ *
318
+ * The same answer `approvalRenderer` gets for the same failure, and for the
319
+ * same reason: a renderer is presentation, not a policy. It decides how the
320
+ * question looks, never whether the agent's question is put to the user.
321
+ * Uncaught, the throw escaped the handler, so the call's card settled as an
322
+ * error quoting the host's message, that message went on to the agent as the
323
+ * tool result -- a detail of the host's page, never written for the model --
324
+ * and the pending decision was never closed, so the next Stop, in whatever
325
+ * round, aborted the signal of a wait that had already ended rather than
326
+ * finding nothing open. The built-in card still asks,
327
+ * and the run carries on as if no renderer had been set. Reported the way a
328
+ * failed `render` is, and for the same reason: survived is not the same as
329
+ * findable.
330
+ *
331
+ * Except when the wait was already abandoned. A renderer honouring its signal
332
+ * rejects once a Stop fires it, which is the signal working rather than the
333
+ * renderer failing, and a card drawn then would ask about a run the user just
334
+ * ended. So it resolves with the empty answer the built-in card resolves with
335
+ * on the same abort, and says nothing.
336
+ */
337
+ #afterRendererFailed(
338
+ error: unknown,
339
+ signal: AbortSignal,
340
+ callId: string | undefined,
341
+ builtIn: () => Promise<string>,
342
+ ): Promise<string> {
343
+ if (signal.aborted) {
344
+ return Promise.resolve("");
345
+ }
346
+ // Named by call rather than by question: the model can ask the same thing
347
+ // twice in one turn, and the id is the one thing that tells them apart.
348
+ console.warn(
349
+ `ag-ui-chat: askUserRenderer failed for tool call ${callId}, so the built-in question card asks instead`,
350
+ error,
351
+ );
352
+ return builtIn();
353
+ }
303
354
  }
@@ -57,8 +57,15 @@ export class ComposerAttachments {
57
57
  * built-in multipart endpoint: reveal the 📎 button, wire the hidden file
58
58
  * input + drag-and-drop, and mount the tray. With neither, the affordance
59
59
  * stays hidden and the chat degrades to text-only.
60
+ *
61
+ * Called on every connect, so it starts by taking down the tray the last
62
+ * connection mounted: that one was disposed when the element left, and the
63
+ * attributes that decide whether there is a tray at all may have changed
64
+ * since. The shell outlives a connection, so its listeners go under `signal`.
60
65
  */
61
- wire(): void {
66
+ wire(signal: AbortSignal): void {
67
+ this.#tray?.element.remove();
68
+ this.#tray = null;
62
69
  const url = this.#host.element.getAttribute("data-attachments-url");
63
70
  const upload = this.#host.uploadHandler() ?? this.#defaultUploadHandler(url);
64
71
  if (upload === null) {
@@ -82,8 +89,8 @@ export class ComposerAttachments {
82
89
  this.#host.slot.appendChild(this.#tray.element);
83
90
  this.#host.fileInput.accept = accept;
84
91
  this.#host.button.hidden = false;
85
- this.#enableDragAndDrop();
86
- this.#enablePaste(tray);
92
+ this.#enableDragAndDrop(signal);
93
+ this.#enablePaste(tray, signal);
87
94
  }
88
95
 
89
96
  /** The queueing behind `AgUiChat.attachFile`, whose doc is the contract. */
@@ -136,25 +143,37 @@ export class ComposerAttachments {
136
143
  }
137
144
 
138
145
  /** Accept files dropped anywhere on the chat shell into the tray. */
139
- #enableDragAndDrop(): void {
146
+ #enableDragAndDrop(signal: AbortSignal): void {
140
147
  const chat = this.#host.chat;
141
- chat.addEventListener("dragover", (event) => {
142
- event.preventDefault();
143
- chat.classList.add("chat--dragover");
144
- });
145
- chat.addEventListener("dragleave", () => {
146
- chat.classList.remove("chat--dragover");
147
- });
148
- chat.addEventListener("drop", (event) => {
149
- event.preventDefault();
150
- chat.classList.remove("chat--dragover");
151
- const files = event.dataTransfer?.files;
152
- if (files !== undefined) {
153
- for (const file of Array.from(files)) {
154
- this.#tray?.add(file);
148
+ chat.addEventListener(
149
+ "dragover",
150
+ (event) => {
151
+ event.preventDefault();
152
+ chat.classList.add("chat--dragover");
153
+ },
154
+ { signal },
155
+ );
156
+ chat.addEventListener(
157
+ "dragleave",
158
+ () => {
159
+ chat.classList.remove("chat--dragover");
160
+ },
161
+ { signal },
162
+ );
163
+ chat.addEventListener(
164
+ "drop",
165
+ (event) => {
166
+ event.preventDefault();
167
+ chat.classList.remove("chat--dragover");
168
+ const files = event.dataTransfer?.files;
169
+ if (files !== undefined) {
170
+ for (const file of Array.from(files)) {
171
+ this.#tray?.add(file);
172
+ }
155
173
  }
156
- }
157
- });
174
+ },
175
+ { signal },
176
+ );
158
177
  }
159
178
 
160
179
  /**
@@ -228,27 +247,31 @@ export class ComposerAttachments {
228
247
  * clipboard, and swallowing the words someone meant to paste in order to
229
248
  * attach a picture they did not is the worse of the two failures.
230
249
  */
231
- #enablePaste(tray: AttachmentTray): void {
232
- this.#host.chat.addEventListener("paste", (event: ClipboardEvent) => {
233
- // Nullish rather than a null check: the property is typed as nullable,
234
- // and an engine that fires a plain Event for a paste leaves it absent
235
- // instead, which is not the same value and is the same situation.
236
- const clipboard = event.clipboardData ?? null;
237
- if (clipboard === null) {
238
- return;
239
- }
240
- const files = Array.from(clipboard.files);
241
- if (files.length === 0) {
242
- this.#pasteLongTextAsFile(event, clipboard, tray);
243
- return;
244
- }
245
- if (clipboard.getData("text/plain") === "") {
246
- event.preventDefault();
247
- }
248
- for (const file of files) {
249
- this.#tray?.add(named(file));
250
- }
251
- });
250
+ #enablePaste(tray: AttachmentTray, signal: AbortSignal): void {
251
+ this.#host.chat.addEventListener(
252
+ "paste",
253
+ (event: ClipboardEvent) => {
254
+ // Nullish rather than a null check: the property is typed as nullable,
255
+ // and an engine that fires a plain Event for a paste leaves it absent
256
+ // instead, which is not the same value and is the same situation.
257
+ const clipboard = event.clipboardData ?? null;
258
+ if (clipboard === null) {
259
+ return;
260
+ }
261
+ const files = Array.from(clipboard.files);
262
+ if (files.length === 0) {
263
+ this.#pasteLongTextAsFile(event, clipboard, tray);
264
+ return;
265
+ }
266
+ if (clipboard.getData("text/plain") === "") {
267
+ event.preventDefault();
268
+ }
269
+ for (const file of files) {
270
+ this.#tray?.add(named(file));
271
+ }
272
+ },
273
+ { signal },
274
+ );
252
275
  }
253
276
 
254
277
  /** Tell the host what the tray now holds — see {@link ATTACHMENT_EVENT}. */
@@ -44,8 +44,13 @@ export class ComposerVoice {
44
44
  * built-in POST endpoint. The control records via `MediaRecorder` and drops
45
45
  * the transcript into the composer; with neither configured the mic stays
46
46
  * hidden and the chat is text-only.
47
+ *
48
+ * Called on every connect, so it starts by taking down the mic the last
49
+ * connection mounted, which was released when the element left.
47
50
  */
48
51
  wire(): void {
52
+ this.#voice?.element.remove();
53
+ this.#voice = null;
49
54
  const url = this.#host.element.getAttribute("data-transcribe-url");
50
55
  const transcribe = this.#host.transcribeHandler() ?? this.#defaultTranscribeHandler(url);
51
56
  if (transcribe === null) {
@@ -99,8 +99,13 @@ export class TranscriptQuoteOffer {
99
99
  this.#pageQuote = null;
100
100
  }
101
101
 
102
- /** Build the offer and listen for settled selections in the transcript. */
103
- mount(): void {
102
+ /**
103
+ * Build the offer and listen for settled selections in the transcript.
104
+ *
105
+ * The button and the transcript outlive a connection, so the listeners go
106
+ * under `signal`, which the element aborts when it leaves the document.
107
+ */
108
+ mount(signal: AbortSignal): void {
104
109
  const button = this.button;
105
110
  button.className = "quote-selection";
106
111
  button.type = "button";
@@ -111,23 +116,31 @@ export class TranscriptQuoteOffer {
111
116
  // selection first, and by the time a click lands there is nothing left to
112
117
  // quote. Preventing the default keeps the selection alive long enough to
113
118
  // read it.
114
- button.addEventListener("mousedown", (event) => {
115
- event.preventDefault();
116
- });
117
- button.addEventListener("click", () => {
118
- this.#host.quote(this.#quoting);
119
- window.getSelection()?.removeAllRanges();
120
- this.#hide();
121
- });
119
+ button.addEventListener(
120
+ "mousedown",
121
+ (event) => {
122
+ event.preventDefault();
123
+ },
124
+ { signal },
125
+ );
126
+ button.addEventListener(
127
+ "click",
128
+ () => {
129
+ this.#host.quote(this.#quoting);
130
+ window.getSelection()?.removeAllRanges();
131
+ this.#hide();
132
+ },
133
+ { signal },
134
+ );
122
135
 
123
136
  // A settled selection, by either input. `mouseup` rather than
124
137
  // `selectionchange` so the offer does not chase the pointer mid-drag; the
125
138
  // second half of the same gesture, `mousedown`, retires the previous offer
126
139
  // before the new selection exists.
127
140
  const messages = this.#host.messages;
128
- messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
129
- messages.addEventListener("keyup", () => this.#onSelectionSettled());
130
- messages.addEventListener("mousedown", () => this.#hide());
141
+ messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event), { signal });
142
+ messages.addEventListener("keyup", () => this.#onSelectionSettled(), { signal });
143
+ messages.addEventListener("mousedown", () => this.#hide(), { signal });
131
144
  }
132
145
 
133
146
  /** Whether the transcript offers to quote what the user selects. */
@@ -61,6 +61,17 @@ export interface ConversationHistoryHost {
61
61
  readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
62
62
  /** Resize the composer to its content. */
63
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;
64
75
  /** The conversation's own client, built on first use. */
65
76
  readonly ensureClient: () => AgUiClient;
66
77
  /**
@@ -69,6 +80,14 @@ export interface ConversationHistoryHost {
69
80
  * added to one can be missing from the other.
70
81
  */
71
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;
72
91
  /** Stop the in-flight run. */
73
92
  readonly cancelRun: () => void;
74
93
  /** Drop the in-memory run and transcript, leaving the thread untouched. */
@@ -97,10 +116,16 @@ export class ConversationHistory {
97
116
  /** The active thread's id. Empty until the element connects. */
98
117
  #threadId = "";
99
118
  /**
100
- * The messages the last restore replayed, which seed the next client the
101
- * element builds. Emptied with the rest of the in-memory run.
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.
102
122
  */
103
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;
104
129
  // Bumped on every rehydrate; a replay whose generation is stale (a newer
105
130
  // thread switch started while it awaited a slow store) drops its result.
106
131
  #generation = 0;
@@ -121,11 +146,16 @@ export class ConversationHistory {
121
146
  return this.#threadId;
122
147
  }
123
148
 
124
- /** The messages the last restore replayed, for seeding the next client. */
149
+ /** The conversation as last written, for seeding the next client. */
125
150
  get restored(): readonly Message[] {
126
151
  return this.#restored;
127
152
  }
128
153
 
154
+ /** The checkpoint continuation in flight, or `null` when none is running. */
155
+ get continuation(): AgUiClient | null {
156
+ return this.#continuation;
157
+ }
158
+
129
159
  /** Point at the thread the store says is active. */
130
160
  adoptActiveThread(): void {
131
161
  this.#threadId = this.#host.conversationStore().threadId();
@@ -153,6 +183,9 @@ export class ConversationHistory {
153
183
  /** Forget the messages the last restore seeded, with the rest of the run. */
154
184
  forgetRestored(): void {
155
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;
156
189
  }
157
190
 
158
191
  /** Delete the active thread if nothing was ever sent in it. */
@@ -258,13 +291,18 @@ export class ConversationHistory {
258
291
  * Uses a short-lived agent pointed at the resume / fork endpoint and seeded
259
292
  * with no history, because those endpoints supply the prior turns from the
260
293
  * snapshot and re-sending them would duplicate. A separate agent makes that
261
- * structural — the main agent keeps its own history — and mints the fresh
262
- * `run_id` the endpoints also require.
294
+ * structural and mints the fresh `run_id` the endpoints also require.
263
295
  *
264
296
  * Built by the same construction as the conversation's own client, so the
265
297
  * continuation streams into the same transcript the user is looking at and
266
298
  * runs under the same state, tools and bounds. It is the run in flight while
267
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.
268
306
  */
269
307
  async continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
270
308
  const index = this.runs();
@@ -275,6 +313,26 @@ export class ConversationHistory {
275
313
  // documented empty panel, which has no rows either. Typed, not silent.
276
314
  return;
277
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
+ }
278
336
  const content = this.#host.input.value.trim();
279
337
  if (content === "") {
280
338
  // A continuation sends *only* the next turn -- the snapshot supplies
@@ -289,31 +347,81 @@ export class ConversationHistory {
289
347
  // keystroke -- a transcript notice for a recoverable slip would outlive
290
348
  // the slip. Focus follows for the same reason applying a skill moves it when
291
349
  // a template is short of a field.
292
- this.#host.hint.textContent = this.#host.strings().continueNeedsTurn;
293
- this.#host.hint.hidden = false;
294
- this.#host.input.focus();
350
+ this.#refuse(this.#host.strings().continueNeedsTurn);
295
351
  return;
296
352
  }
297
353
  this.#host.input.value = "";
298
354
  this.#host.autoGrow();
355
+ const cleared = this.#cleared;
299
356
  const client = this.#host.buildClient({
300
357
  endpoint: verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId),
301
358
  // The seed the endpoints assume: nothing. The snapshot is the history.
302
359
  initialMessages: [],
303
- // Nor does it write the store. Its agent holds only the new turn and its
304
- // answer, and a store keeps one list per thread, so saving what this
305
- // client has would replace the conversation with its last exchange.
306
- persist: false,
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
+ },
307
392
  });
308
393
  this.#continuation = client;
309
- await client.send(content);
310
- // Only if it is still the one in flight: stopping forgets it at once, and a
311
- // continuation started after that one is not this one to forget.
312
- if (this.#continuation === client) {
313
- this.#continuation = null;
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
+ }
314
412
  }
315
413
  }
316
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
+
317
425
  /**
318
426
  * Restore the conversation from the store on mount, then — if a navigating
319
427
  * tool reloaded the page mid-run — resume the loop by supplying that tool's