@artooi/ag-ui-web-component 0.41.3 → 0.43.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.
@@ -55,8 +55,11 @@ export interface ToolExecution {
55
55
  * Executes a frontend tool call.
56
56
  *
57
57
  * Returns the {@link ToolExecution} to post back to the agent, or `null` when
58
- * the call is not a frontend tool the host owns — a server-side tool the server
59
- * already executed, which the client must not re-run.
58
+ * there is nothing to post: the call is not a frontend tool the host owns (a
59
+ * server-side tool the server already executed, which the client must not
60
+ * re-run), or its run was stopped or handed over while the call waited. A
61
+ * stopped call is left unanswered here, like the calls after it in the round,
62
+ * and the next request answers it as not finished.
60
63
  */
61
64
  export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
62
65
 
@@ -317,8 +320,10 @@ export class AgUiClient {
317
320
  readonly #unfinishedMessage: string;
318
321
  readonly #declinedMessage: string;
319
322
  readonly #maxToolRounds: number;
320
- // Set by cancel(); reset at the top of each #run(). Checked by the loop so
321
- // a cancel between frontend-tool rounds doesn't start another round.
323
+ // Set by cancel(); reset as send() or resume() begins, before either runs
324
+ // any host code. Checked by the loop so a cancel between frontend-tool
325
+ // rounds doesn't start another round, and again just before each request,
326
+ // so a cancel from host code the round itself runs is not sent anyway.
322
327
  #cancelled = false;
323
328
 
324
329
  constructor(config: AgUiClientConfig) {
@@ -388,6 +393,12 @@ export class AgUiClient {
388
393
  * {@link messageAttachments}.
389
394
  */
390
395
  async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
396
+ // A new interaction, so the Stop of the one before it is forgotten here --
397
+ // ahead of the save below, which is the host's store. Forgotten as the run
398
+ // began instead, a Stop that store made (a new chat started from the save)
399
+ // was erased before anything read it, and the turn went out regardless,
400
+ // into the conversation being left and with all of its history.
401
+ this.#cancelled = false;
391
402
  // In `metadata` because it is the one field on a message `@ag-ui/client`
392
403
  // declares open by key. 1.0 strips every undeclared key from the input it
393
404
  // sends, so refs at the top level of the message -- where every release
@@ -410,11 +421,14 @@ export class AgUiClient {
410
421
  * can be asked again.
411
422
  *
412
423
  * Returns the retained history, or `null` when there is nothing to retry (no
413
- * user message has been sent yet). **Truncates only** -- the caller re-renders
414
- * from the returned list and then calls {@link resume}, because the transcript
415
- * belongs to the element and a client that reached into it would own two
416
- * things. Running here instead would stream the new answer in underneath the
417
- * old one.
424
+ * user message has been sent yet). The shortened history is saved before it
425
+ * replaces the current one, so a save that throws changes nothing and the
426
+ * throw propagates.
427
+ *
428
+ * **Truncates only** -- the caller re-renders from the returned list and then
429
+ * calls {@link resume}, because the transcript belongs to the element and a
430
+ * client that reached into it would own two things. Running here instead
431
+ * would stream the new answer in underneath the old one.
418
432
  *
419
433
  * Re-running answers the question the agent was last asked, rather than
420
434
  * telling it its answer was wrong, which is what makes the result a
@@ -439,8 +453,15 @@ export class AgUiClient {
439
453
  return null;
440
454
  }
441
455
  const kept = messages.slice(0, lastUser + 1);
456
+ // Saved before the history is shortened, so a store that throws leaves it
457
+ // as it was and the throw reaches the caller, as a refused save does from
458
+ // send(). Shortened first, the refusal left this client without the answer
459
+ // while the caller's transcript still showed it, and the next request then
460
+ // asked a follow-up about something the agent was never told it had said.
461
+ // Handed over directly rather than through #persist, which saves the list
462
+ // the agent holds, because the agent does not hold this one yet.
463
+ this.#onPersist(kept);
442
464
  this.#agent.setMessages(kept);
443
- this.#persist();
444
465
  return kept;
445
466
  }
446
467
 
@@ -450,6 +471,10 @@ export class AgUiClient {
450
471
  * message; it continues the conversation already in history.
451
472
  */
452
473
  async resume(): Promise<void> {
474
+ // A new interaction, as in send(). No host code runs between here and the
475
+ // loop, so for a resume this is the same moment the run used to forget a
476
+ // Stop at; it moved here so that both ways in forget it in one place.
477
+ this.#cancelled = false;
453
478
  await this.#run();
454
479
  }
455
480
 
@@ -465,6 +490,13 @@ export class AgUiClient {
465
490
  * disconnect). Safe to call with no run in flight. Partial text already
466
491
  * streamed stays in history; {@link AgUiClientHandlers.onCancelled} fires
467
492
  * instead of `onError`, and `onSettled` still follows.
493
+ *
494
+ * Also stops a {@link send} or {@link resume} whose request has not been made
495
+ * yet, when host code it runs on the way calls this: the save of the turn,
496
+ * the tool and context providers, the save answering a call left open. No
497
+ * request is made then, and `onCancelled` and `onSettled` fire as for any
498
+ * other Stop. A cancel with nothing in flight is forgotten when the next
499
+ * send or resume begins.
468
500
  */
469
501
  cancel(): void {
470
502
  this.#cancelled = true;
@@ -472,7 +504,6 @@ export class AgUiClient {
472
504
  }
473
505
 
474
506
  async #run(): Promise<void> {
475
- this.#cancelled = false;
476
507
  try {
477
508
  await this.#runLoop();
478
509
  // `@ag-ui/client` filters abort errors inside `runAgent` (it resolves
@@ -637,7 +668,10 @@ export class AgUiClient {
637
668
  let resuming: ReadonlySet<string> = new Set();
638
669
  for (let round = 0; round < this.#maxToolRounds; round += 1) {
639
670
  // A cancel during the previous round's tool execution lands here: the
640
- // running handler completed, but no further round starts.
671
+ // running handler completed, but no further round starts. The check
672
+ // before the request below would stop the round as well; this one keeps
673
+ // a stopped loop from running the providers and the store for a round
674
+ // it will not send.
641
675
  if (this.#cancelled) {
642
676
  return;
643
677
  }
@@ -651,6 +685,15 @@ export class AgUiClient {
651
685
  params.resume = resume;
652
686
  }
653
687
  this.#answerUnansweredCalls(resuming);
688
+ // Everything above since the check at the top is host code -- the tool
689
+ // and context providers, and the store saving the answers just made -- and
690
+ // any of it can be what stopped the run, by starting a new chat or
691
+ // changing the principal. `@ag-ui/client` makes a fresh abort controller
692
+ // for each run, so the abort `cancel()` made in that time reached
693
+ // nothing, and only this check keeps the request from going out.
694
+ if (this.#cancelled) {
695
+ return;
696
+ }
654
697
  await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
655
698
  resume = undefined;
656
699
  resuming = new Set();
@@ -53,9 +53,12 @@ export class StorageScope {
53
53
  // remounts, but re-resolves if the host answers the warning with an `id`.
54
54
  #generatedNs = "";
55
55
  #generatedFor = "";
56
- // The `sessionStorage`-backed store, which the element may therefore re-scope
57
- // on a principal change. `null` when the host injected a store of its own
58
- // kind, whose keying the element does not know and must not guess at.
56
+ // The `sessionStorage`-backed store this scope handed out, which the element
57
+ // may therefore re-scope on a principal change -- while it is still the store
58
+ // in use, which rescopeStore asks, since a host can assign its own at any
59
+ // time. `null` until connecting has met the built-in store at all. A store of
60
+ // the host's own kind is never held here: the element does not know its
61
+ // keying and must not guess at it.
59
62
  #builtinStore: SessionStorageStore | null = null;
60
63
 
61
64
  constructor(host: StorageScopeHost) {
@@ -115,12 +118,22 @@ export class StorageScope {
115
118
  }
116
119
 
117
120
  /**
118
- * Rebuild the built-in store under `namespace`, or `null` for a store of the
119
- * host's own kind, which holds its data somewhere the element cannot see and
120
- * has to scope itself.
121
+ * Rebuild the built-in store under `namespace`, or `null` when `current` --
122
+ * the store the element is using now, unwrapped from any remote it wrapped
123
+ * it in -- is not the one this scope handed out.
124
+ *
125
+ * Asked of the store in use rather than of what connecting remembered,
126
+ * because the two part company the moment a host assigns a store of its own
127
+ * to a connected element. Remembering was enough to tell a host's store from
128
+ * the built-in one at connect; after it, the built-in one was still
129
+ * remembered, so a key change replaced the host's store with a fresh
130
+ * `sessionStorage` one -- and a host that had chosen to keep message bodies
131
+ * off the client found every one of them cached in the tab again. A store
132
+ * the element did not make holds its data somewhere the element cannot see,
133
+ * and has to scope itself.
121
134
  */
122
- rescopeStore(namespace: string): SessionStorageStore | null {
123
- if (this.#builtinStore === null) {
135
+ rescopeStore(current: ClientConversationStore, namespace: string): SessionStorageStore | null {
136
+ if (current !== this.#builtinStore) {
124
137
  return null;
125
138
  }
126
139
  this.#builtinStore = new SessionStorageStore(namespace);
@@ -14,6 +14,7 @@ import {
14
14
  requestConfirmation,
15
15
  } from "../ui/interrupts/confirmation_card.js";
16
16
  import type { PendingDecision } from "../ui/interrupts/pending_decision.js";
17
+ import type { ToolCallCard } from "../ui/progress/tool_call_card.js";
17
18
  import type { Transcript } from "../ui/transcript/transcript.js";
18
19
  import type { UiStrings } from "../ui/ui_strings.js";
19
20
  import type { ClientTool } from "./client_tool_registry.js";
@@ -56,6 +57,20 @@ export interface ToolDispatchHost {
56
57
  readonly conversationStore: () => ClientConversationStore;
57
58
  /** The active thread's id. */
58
59
  readonly threadId: () => string;
60
+ /**
61
+ * The element's current tenure: an identity it replaces on every handover
62
+ * from one principal to another, and never otherwise. Only ever compared, so
63
+ * a call can tell whether the principal it was dispatched for is still the
64
+ * one the element serves.
65
+ */
66
+ readonly tenure: () => object;
67
+ /**
68
+ * The conversation on screen: an identity the element replaces every time it
69
+ * clears the transcript for another conversation, a handover included. Only
70
+ * ever compared, so a call can tell whether the conversation it was
71
+ * dispatched in is still the one it would draw into.
72
+ */
73
+ readonly conversation: () => object;
59
74
  }
60
75
 
61
76
  /**
@@ -118,6 +133,15 @@ export class ToolDispatch {
118
133
  * Execute one frontend tool call the round produced, against its own card:
119
134
  * refused when the page moved under the round, asked about when a rule gates
120
135
  * it, and settled with what the handler returned or threw.
136
+ *
137
+ * Every await below is a place the run can end, so each is followed by a
138
+ * check that it has not. A Stop while the host's predicate is pending ends
139
+ * the call there, and one while the card is open declines it. A handover
140
+ * during any of the three leaves the call with no transcript to draw in and
141
+ * no store to write to, so it does neither. Any other reset of the
142
+ * conversation -- New chat, a drawer switch, `reload()` -- leaves the call
143
+ * its store and its result but not the transcript, so it still settles and
144
+ * returns, and does not show the run waiting in a conversation it is not in.
121
145
  */
122
146
  async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
123
147
  // A skill load already rendered as a notice on the stream; it is never a
@@ -126,6 +150,16 @@ export class ToolDispatch {
126
150
  if (skillNameFrom(call) !== null) {
127
151
  return null;
128
152
  }
153
+ // Fixed here, as the call is dispatched, rather than read when they are
154
+ // used. The client calls this in the same task as its own check that the
155
+ // run was not stopped, so these are the run's principal and thread; read
156
+ // after the handler instead, they were whoever was current by then. A
157
+ // navigating handler's failure cleared the checkpoint of the thread New
158
+ // chat had just moved to, leaving its own behind, and across a handover
159
+ // it wrote into the next principal's store.
160
+ const tenure = this.#host.tenure();
161
+ const conversation = this.#host.conversation();
162
+ const threadId = this.#host.threadId();
129
163
  const card = this.#host.transcript.cardFor(call);
130
164
  this.#host.transcript.forgetCard(call.id);
131
165
  // Kept after the card leaves the awaiting cards: a tool that renders into the
@@ -180,7 +214,23 @@ export class ToolDispatch {
180
214
  // lives only in the DOM, and the DOM is what a reload throws away.
181
215
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
182
216
  }
217
+ // The run is suspended on the host's predicate as much as on a card, and a
218
+ // predicate is free to take as long as one: it may ask a server. So the
219
+ // wait is opened for it the same way, and a Stop -- or a handover, which
220
+ // stops the run first -- aborts it. Unchecked, a predicate that answered
221
+ // after the Stop opened its card or ran its handler for a run the user had
222
+ // ended, and after a handover it ran the previous principal's handler on
223
+ // the next principal's page.
224
+ //
225
+ // Held by "draws no card when the predicate answers after Stop", and for
226
+ // the handover by "does not run the previous principal's handler after a
227
+ // handover", in tool_dispatch.test.ts.
228
+ const asking = this.#host.decision.open();
183
229
  const rule = await this.#confirmationRule(call, tool);
230
+ this.#host.decision.close();
231
+ if (asking.aborted) {
232
+ return this.#abandoned(card);
233
+ }
184
234
  if (rule === "unanswered") {
185
235
  // Settled as a refusal and returned as one, so the run carries on to its
186
236
  // next round the way it does after a decline and the agent can say what
@@ -216,17 +266,49 @@ export class ToolDispatch {
216
266
  this.#host.transcript.follow();
217
267
  const accepted = await decision;
218
268
  this.#host.decision.close();
269
+ // A handover while the card was open declines it, as any Stop does, but
270
+ // the transcript the decline would be drawn into is the next principal's
271
+ // by now, and the client the refusal goes to writes nothing for a tenure
272
+ // that has ended. Unchecked, the pending indicator the decline shows was
273
+ // left in the next principal's empty transcript with nothing to remove it.
274
+ //
275
+ // Held by "draws nothing for the next principal when a handover declines
276
+ // the card" in tool_dispatch.test.ts.
277
+ if (this.#host.tenure() !== tenure) {
278
+ return null;
279
+ }
219
280
  card.recordDecision(accepted ? "approved" : "declined");
220
281
  if (!accepted) {
221
282
  const message = this.#host.strings().declinedAction;
222
283
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
223
- this.#host.transcript.showPending();
284
+ // New chat or a drawer switch with the card open declines it too, and
285
+ // the indicator would land in the conversation that replaced this one,
286
+ // whose run is not this one and which nothing would take it down from.
287
+ // Only the drawing is skipped: the refusal is still this run's to send.
288
+ //
289
+ // Held by "draws nothing into the new chat when New chat declines the
290
+ // open card" in tool_dispatch.test.ts.
291
+ if (this.#host.conversation() === conversation) {
292
+ this.#host.transcript.showPending();
293
+ }
224
294
  // The one outcome with no error text and no server involvement at all:
225
295
  // a person said no in this browser. Nothing else records that, so
226
296
  // without the annotation the reload showed a green card for an action
227
297
  // the user had explicitly refused.
228
298
  return { content: message, outcome: TOOL_OUTCOME.DENIED };
229
299
  }
300
+ // Answered yes, and then stopped before that answer was heard: a click
301
+ // on Confirm and a Stop in the same task, so the card had already
302
+ // resolved by the time the abort reached it. The person agreed to the
303
+ // call, so it is not declined; the run it belonged to is over, so it does
304
+ // not run either. The server-side gate treats approve-then-Stop the same
305
+ // way.
306
+ //
307
+ // Held by "does not run a call confirmed in the same task as Stop" in
308
+ // tool_dispatch.test.ts.
309
+ if (signal.aborted) {
310
+ return this.#abandoned(card);
311
+ }
230
312
  }
231
313
  // A navigating tool reloads only without a client-side router; with a
232
314
  // host `navigate()` (SPA) it routes in-page and the loop just continues.
@@ -235,12 +317,30 @@ export class ToolDispatch {
235
317
  // Checkpoint before the handler reloads the page; the history (incl.
236
318
  // this tool call) was already persisted when the run that produced it
237
319
  // settled. The result is supplied on the next mount via the resume path.
238
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), { toolCallId: call.id });
320
+ //
321
+ // Nothing has been awaited since the last check that the run is still
322
+ // going, so the live thread is the dispatch one here too. Written under
323
+ // the dispatch thread all the same, so the clear below is certain to
324
+ // remove what this wrote.
325
+ this.#host.conversationStore().saveCheckpoint(threadId, { toolCallId: call.id });
239
326
  }
240
327
  try {
241
328
  // The call id lets a handler that renders into the transcript find its
242
329
  // own card; handlers that only act on the page ignore it.
243
330
  const result = await tool.handler(call.args, call.id);
331
+ // A handler cannot be aborted, and a plain Stop while it ran keeps its
332
+ // result: the action happened on the page, and the agent is told so on
333
+ // the next request. A handover is different. The transcript this would
334
+ // draw into is the next principal's now, and the client the result goes
335
+ // to writes nothing for a tenure that has ended, so the result has no
336
+ // one left to reach. Unchecked, the pending indicator was drawn into the
337
+ // next principal's empty transcript, where nothing would ever remove it.
338
+ //
339
+ // Held by "draws nothing for the next principal when a handler resolves
340
+ // after a handover" in tool_dispatch.test.ts.
341
+ if (this.#host.tenure() !== tenure) {
342
+ return null;
343
+ }
244
344
  // Drawn from the arguments rather than the result, so the live path and
245
345
  // the replay path render the same thing from the same input.
246
346
  if (tool.render !== undefined) {
@@ -252,12 +352,32 @@ export class ToolDispatch {
252
352
  }
253
353
  const content = JSON.stringify(result ?? null);
254
354
  card.settle(TOOL_CALL_STATUS.DONE, content);
255
- this.#host.transcript.showPending();
355
+ // The same check for a reset that is not a handover. The result is kept,
356
+ // as after a Stop, but the conversation on screen is not the one it was
357
+ // dispatched in, so the run is not shown waiting there.
358
+ //
359
+ // Held by "draws nothing into the new chat when a handler resolves after
360
+ // New chat" in tool_dispatch.test.ts, and for the other resets by the
361
+ // tests beside it.
362
+ if (this.#host.conversation() === conversation) {
363
+ this.#host.transcript.showPending();
364
+ }
256
365
  return { content };
257
366
  } catch (error) {
367
+ // The same handover check as on success, and here it also guards a
368
+ // write: the store is read live, and after a handover it is the next
369
+ // principal's, so the clear below removed their checkpoint rather than
370
+ // this call's -- which the handover's purge had already removed.
371
+ //
372
+ // Held by "leaves the next principal's checkpoint alone when a handler
373
+ // fails after a handover" in tool_dispatch.test.ts.
374
+ if (this.#host.tenure() !== tenure) {
375
+ return null;
376
+ }
258
377
  if (navigates) {
259
- // The navigation never happened; drop the dangling checkpoint.
260
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), null);
378
+ // The navigation never happened; drop the dangling checkpoint, from
379
+ // the thread it was written to.
380
+ this.#host.conversationStore().saveCheckpoint(threadId, null);
261
381
  }
262
382
  // The handler's own message, verbatim, in two places at once: the card,
263
383
  // which the user sees, and the tool result, which goes to the endpoint,
@@ -267,7 +387,14 @@ export class ToolDispatch {
267
387
  // invisible from the host's side and is not one it can take back.
268
388
  const message = error instanceof Error ? error.message : String(error);
269
389
  card.settle(TOOL_CALL_STATUS.ERROR, message);
270
- this.#host.transcript.showPending();
390
+ // As on success: the failure is still reported, and not drawn as a run
391
+ // waiting in a conversation the call is no longer in.
392
+ //
393
+ // Held by "draws nothing into the new chat when a handler fails after
394
+ // New chat" in tool_dispatch.test.ts.
395
+ if (this.#host.conversation() === conversation) {
396
+ this.#host.transcript.showPending();
397
+ }
271
398
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
272
399
  }
273
400
  }
@@ -479,6 +606,26 @@ export class ToolDispatch {
479
606
  return isDestructive(tool.parameters) ? "destructive" : null;
480
607
  }
481
608
 
609
+ /**
610
+ * Settle a call whose run ended while it waited, and give the loop nothing.
611
+ *
612
+ * The card is settled here because nothing else will: dispatch took it out
613
+ * of the end-of-run sweep before it waited, so left alone it read "running…"
614
+ * for good. It says the call did not finish, which is true -- nothing ran and
615
+ * nobody refused it.
616
+ *
617
+ * `null` rather than a result, because a stopped run has no next round to
618
+ * send one in. The loop moves on and finds the run stopped, and the call is
619
+ * left unanswered like every call after it in the round, so the client
620
+ * answers it as not finished on the next request -- in the same words as
621
+ * this card. After a handover the card is in a transcript already cleared,
622
+ * and settling it there is seen by no one.
623
+ */
624
+ #abandoned(card: ToolCallCard): null {
625
+ card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
626
+ return null;
627
+ }
628
+
482
629
  /**
483
630
  * Whether the page moved since the current round's context was built.
484
631
  *
@@ -58,6 +58,8 @@ export class VoiceInput {
58
58
  #capTimer: ReturnType<typeof setTimeout> | null = null;
59
59
  #hitCap = false;
60
60
  #disposed = false;
61
+ /** Identifies the newest start; an older one that settles is stale. */
62
+ #startToken: object = {};
61
63
 
62
64
  constructor(options: VoiceInputOptions) {
63
65
  this.#transcribe = options.transcribe;
@@ -93,25 +95,66 @@ export class VoiceInput {
93
95
  }
94
96
 
95
97
  async #start(): Promise<void> {
96
- let stream: MediaStream;
98
+ // The button reads idle for the whole of the permission prompt, so a second
99
+ // click during it is a second start. The latest click wins: this token is
100
+ // replaced by every start, and whichever one settles while it is not the
101
+ // newest is stale. It is a token and not an in-flight flag on purpose:
102
+ // getUserMedia may never settle (a dismissed prompt), and a flag waiting
103
+ // for it would leave the button dead until reload.
104
+ const token = {};
105
+ this.#startToken = token;
106
+ let stream: MediaStream | null;
97
107
  try {
98
108
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
99
109
  } catch {
110
+ stream = null;
111
+ }
112
+ if (token !== this.#startToken || this.#disposed) {
113
+ // Superseded by a later click, or the prompt outlived the control: the
114
+ // element left, or user-key changed, while it was open. Whatever this
115
+ // call was granted is released and nothing shared is touched: #stream,
116
+ // #recorder and the cap timer belong to the newer start, and recording
117
+ // now on a disposed control would light the browser's indicator for a
118
+ // button nobody can see or stop (dispose has already run, so nothing
119
+ // else would ever release these tracks). A refusal is dropped the same
120
+ // way, so a click nobody is waiting on draws no error over the newer one.
121
+ for (const track of stream?.getTracks() ?? []) {
122
+ track.stop();
123
+ }
124
+ return;
125
+ }
126
+ if (stream === null) {
100
127
  this.#fail(this.#strings.transcriptionFailed);
101
128
  return;
102
129
  }
103
130
  this.#stream = stream;
104
131
  this.#chunks = [];
105
132
  this.#hitCap = false;
106
- const recorder = new MediaRecorder(stream);
107
- recorder.addEventListener("dataavailable", (event) => {
108
- this.#chunks.push(event.data);
109
- });
110
- recorder.addEventListener("stop", () => {
111
- void this.#finish(recorder.mimeType);
112
- });
113
- this.#recorder = recorder;
114
- recorder.start();
133
+ let recorder: MediaRecorder;
134
+ try {
135
+ recorder = new MediaRecorder(stream);
136
+ recorder.addEventListener("dataavailable", (event) => {
137
+ this.#chunks.push(event.data);
138
+ });
139
+ recorder.addEventListener("stop", () => {
140
+ void this.#finish(recorder.mimeType);
141
+ });
142
+ this.#recorder = recorder;
143
+ recorder.start();
144
+ } catch {
145
+ // A granted stream the browser will not record: one whose tracks have
146
+ // already ended (Chromium throws NotSupportedError from start), a device
147
+ // that went away between the grant and the start, or no MediaRecorder at
148
+ // all. It is a refused mic by another route, so it ends the same way: the
149
+ // tracks are released (the stream is already stored, and nothing else
150
+ // would stop it), the button shows the failure, and nothing reaches the
151
+ // click handler, which discards the promise. A recorder whose start threw
152
+ // can still report "recording" and still holds its listeners, but #fail
153
+ // clears #recorder, so neither #stop nor dispose can reach it and its stop
154
+ // listener never runs #finish.
155
+ this.#fail(this.#strings.transcriptionFailed);
156
+ return;
157
+ }
115
158
  // Nothing else ends a recording: `MediaRecorder` runs until it is told to
116
159
  // stop, so without this the only exits are a second click and `dispose()`.
117
160
  this.#capTimer = setTimeout(() => {
@@ -136,10 +179,16 @@ export class VoiceInput {
136
179
  }
137
180
 
138
181
  /**
139
- * Tear the control down, for a host element removed mid-recording. Stops any
140
- * live `MediaRecorder`, releases the mic tracks so the browser's recording
141
- * indicator clears, and suppresses the pending transcription — a
142
- * disconnected control must not fire `onText` into a detached element.
182
+ * Tear the control down, for a host element removed mid-recording or handed
183
+ * to the next principal. Stops any live `MediaRecorder`, releases the mic
184
+ * tracks so the browser's recording indicator clears, and suppresses the
185
+ * pending transcription — a disconnected control must not fire `onText` into
186
+ * a detached element.
187
+ *
188
+ * Disposal is final: nothing un-disposes an instance, and the host's next
189
+ * wiring builds a new one. So the disposed flag alone tells a continuation
190
+ * that outlived the control from a live one, and a clip already posted when this runs is
191
+ * dropped whenever its answer arrives.
143
192
  */
144
193
  dispose(): void {
145
194
  this.#disposed = true;
@@ -160,6 +209,15 @@ export class VoiceInput {
160
209
  const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
161
210
  try {
162
211
  const text = await this.#transcribe(audio);
212
+ if (this.#disposed) {
213
+ // Disposed while the clip was in flight. The check above ran before the
214
+ // await and cannot see this: on a change of user-key the host disposes
215
+ // and wires a fresh mic over the same composer, so delivering now
216
+ // would put the previous principal's words in the next one's box.
217
+ // The button is left as it was, too: it is no longer this control's
218
+ // to change.
219
+ return;
220
+ }
163
221
  this.#setState("idle");
164
222
  if (this.#hitCap) {
165
223
  // #setState has just reset the tooltip to the idle label, so this goes
@@ -188,6 +246,13 @@ export class VoiceInput {
188
246
  }
189
247
 
190
248
  #fail(message: string): void {
249
+ // A transcription that fails can settle after dispose. (A refused prompt
250
+ // cannot reach here that way: #start drops a stale or disposed refusal
251
+ // before it calls this.) A disposed control has nothing to release and no
252
+ // tooltip to draw.
253
+ if (this.#disposed) {
254
+ return;
255
+ }
191
256
  this.#releaseStream();
192
257
  this.#recorder = null;
193
258
  this.#setState("idle");