@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.
package/dist/index.js CHANGED
@@ -987,6 +987,7 @@ var DEFAULT_UI_STRINGS = {
987
987
  notConnected: "This chat isn\u2019t connected to an agent, so the message wasn\u2019t sent.",
988
988
  continueNeedsTurn: "Type the next turn in the composer first, then pick a run to continue.",
989
989
  continueWhileRunning: "Wait for the current answer or stop it, then pick a run to continue.",
990
+ continueWhileLoading: "Wait for the conversation to finish loading, then pick a run to continue.",
990
991
  skillNeeds: "\u201C{title}\u201D needs {fields} \u2014 fill it in below, then send.",
991
992
  message: "Message",
992
993
  inputPlaceholder: "Ask anything\u2026",
@@ -1731,11 +1732,23 @@ var ToolDispatch = class {
1731
1732
  * Execute one frontend tool call the round produced, against its own card:
1732
1733
  * refused when the page moved under the round, asked about when a rule gates
1733
1734
  * it, and settled with what the handler returned or threw.
1735
+ *
1736
+ * Every await below is a place the run can end, so each is followed by a
1737
+ * check that it has not. A Stop while the host's predicate is pending ends
1738
+ * the call there, and one while the card is open declines it. A handover
1739
+ * during any of the three leaves the call with no transcript to draw in and
1740
+ * no store to write to, so it does neither. Any other reset of the
1741
+ * conversation -- New chat, a drawer switch, `reload()` -- leaves the call
1742
+ * its store and its result but not the transcript, so it still settles and
1743
+ * returns, and does not show the run waiting in a conversation it is not in.
1734
1744
  */
1735
1745
  async execute(call) {
1736
1746
  if (skillNameFrom(call) !== null) {
1737
1747
  return null;
1738
1748
  }
1749
+ const tenure = this.#host.tenure();
1750
+ const conversation = this.#host.conversation();
1751
+ const threadId = this.#host.threadId();
1739
1752
  const card = this.#host.transcript.cardFor(call);
1740
1753
  this.#host.transcript.forgetCard(call.id);
1741
1754
  this.#host.transcript.setCardElement(call.id, card.element);
@@ -1752,7 +1765,12 @@ var ToolDispatch = class {
1752
1765
  this.#host.transcript.showPending();
1753
1766
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
1754
1767
  }
1768
+ const asking = this.#host.decision.open();
1755
1769
  const rule = await this.#confirmationRule(call, tool);
1770
+ this.#host.decision.close();
1771
+ if (asking.aborted) {
1772
+ return this.#abandoned(card);
1773
+ }
1756
1774
  if (rule === "unanswered") {
1757
1775
  const message = this.#host.strings().confirmCheckFailed;
1758
1776
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
@@ -1776,20 +1794,31 @@ var ToolDispatch = class {
1776
1794
  this.#host.transcript.follow();
1777
1795
  const accepted = await decision;
1778
1796
  this.#host.decision.close();
1797
+ if (this.#host.tenure() !== tenure) {
1798
+ return null;
1799
+ }
1779
1800
  card.recordDecision(accepted ? "approved" : "declined");
1780
1801
  if (!accepted) {
1781
1802
  const message = this.#host.strings().declinedAction;
1782
1803
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
1783
- this.#host.transcript.showPending();
1804
+ if (this.#host.conversation() === conversation) {
1805
+ this.#host.transcript.showPending();
1806
+ }
1784
1807
  return { content: message, outcome: TOOL_OUTCOME.DENIED };
1785
1808
  }
1809
+ if (signal.aborted) {
1810
+ return this.#abandoned(card);
1811
+ }
1786
1812
  }
1787
1813
  const navigates = isNavigates(tool.parameters) && this.#host.navigate() === null;
1788
1814
  if (navigates) {
1789
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), { toolCallId: call.id });
1815
+ this.#host.conversationStore().saveCheckpoint(threadId, { toolCallId: call.id });
1790
1816
  }
1791
1817
  try {
1792
1818
  const result = await tool.handler(call.args, call.id);
1819
+ if (this.#host.tenure() !== tenure) {
1820
+ return null;
1821
+ }
1793
1822
  if (tool.render !== void 0) {
1794
1823
  this.#host.transcript.renderToolOutput(tool.render, call);
1795
1824
  }
@@ -1799,15 +1828,22 @@ var ToolDispatch = class {
1799
1828
  }
1800
1829
  const content = JSON.stringify(result ?? null);
1801
1830
  card.settle(TOOL_CALL_STATUS.DONE, content);
1802
- this.#host.transcript.showPending();
1831
+ if (this.#host.conversation() === conversation) {
1832
+ this.#host.transcript.showPending();
1833
+ }
1803
1834
  return { content };
1804
1835
  } catch (error) {
1836
+ if (this.#host.tenure() !== tenure) {
1837
+ return null;
1838
+ }
1805
1839
  if (navigates) {
1806
- this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), null);
1840
+ this.#host.conversationStore().saveCheckpoint(threadId, null);
1807
1841
  }
1808
1842
  const message = error instanceof Error ? error.message : String(error);
1809
1843
  card.settle(TOOL_CALL_STATUS.ERROR, message);
1810
- this.#host.transcript.showPending();
1844
+ if (this.#host.conversation() === conversation) {
1845
+ this.#host.transcript.showPending();
1846
+ }
1811
1847
  return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
1812
1848
  }
1813
1849
  }
@@ -1960,6 +1996,25 @@ var ToolDispatch = class {
1960
1996
  }
1961
1997
  return isDestructive(tool.parameters) ? "destructive" : null;
1962
1998
  }
1999
+ /**
2000
+ * Settle a call whose run ended while it waited, and give the loop nothing.
2001
+ *
2002
+ * The card is settled here because nothing else will: dispatch took it out
2003
+ * of the end-of-run sweep before it waited, so left alone it read "running…"
2004
+ * for good. It says the call did not finish, which is true -- nothing ran and
2005
+ * nobody refused it.
2006
+ *
2007
+ * `null` rather than a result, because a stopped run has no next round to
2008
+ * send one in. The loop moves on and finds the run stopped, and the call is
2009
+ * left unanswered like every call after it in the round, so the client
2010
+ * answers it as not finished on the next request -- in the same words as
2011
+ * this card. After a handover the card is in a transcript already cleared,
2012
+ * and settling it there is seen by no one.
2013
+ */
2014
+ #abandoned(card) {
2015
+ card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
2016
+ return null;
2017
+ }
1963
2018
  /**
1964
2019
  * Whether the page moved since the current round's context was built.
1965
2020
  *
@@ -3029,6 +3084,8 @@ var VoiceInput = class {
3029
3084
  #capTimer = null;
3030
3085
  #hitCap = false;
3031
3086
  #disposed = false;
3087
+ /** Identifies the newest start; an older one that settles is stale. */
3088
+ #startToken = {};
3032
3089
  constructor(options) {
3033
3090
  this.#transcribe = options.transcribe;
3034
3091
  this.#onText = options.onText;
@@ -3058,25 +3115,42 @@ var VoiceInput = class {
3058
3115
  await this.#start();
3059
3116
  }
3060
3117
  async #start() {
3118
+ const token = {};
3119
+ this.#startToken = token;
3061
3120
  let stream;
3062
3121
  try {
3063
3122
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
3064
3123
  } catch {
3124
+ stream = null;
3125
+ }
3126
+ if (token !== this.#startToken || this.#disposed) {
3127
+ for (const track of stream?.getTracks() ?? []) {
3128
+ track.stop();
3129
+ }
3130
+ return;
3131
+ }
3132
+ if (stream === null) {
3065
3133
  this.#fail(this.#strings.transcriptionFailed);
3066
3134
  return;
3067
3135
  }
3068
3136
  this.#stream = stream;
3069
3137
  this.#chunks = [];
3070
3138
  this.#hitCap = false;
3071
- const recorder = new MediaRecorder(stream);
3072
- recorder.addEventListener("dataavailable", (event) => {
3073
- this.#chunks.push(event.data);
3074
- });
3075
- recorder.addEventListener("stop", () => {
3076
- void this.#finish(recorder.mimeType);
3077
- });
3078
- this.#recorder = recorder;
3079
- recorder.start();
3139
+ let recorder;
3140
+ try {
3141
+ recorder = new MediaRecorder(stream);
3142
+ recorder.addEventListener("dataavailable", (event) => {
3143
+ this.#chunks.push(event.data);
3144
+ });
3145
+ recorder.addEventListener("stop", () => {
3146
+ void this.#finish(recorder.mimeType);
3147
+ });
3148
+ this.#recorder = recorder;
3149
+ recorder.start();
3150
+ } catch {
3151
+ this.#fail(this.#strings.transcriptionFailed);
3152
+ return;
3153
+ }
3080
3154
  this.#capTimer = setTimeout(() => {
3081
3155
  this.#hitCap = true;
3082
3156
  this.#stop();
@@ -3095,10 +3169,16 @@ var VoiceInput = class {
3095
3169
  }
3096
3170
  }
3097
3171
  /**
3098
- * Tear the control down, for a host element removed mid-recording. Stops any
3099
- * live `MediaRecorder`, releases the mic tracks so the browser's recording
3100
- * indicator clears, and suppresses the pending transcription — a
3101
- * disconnected control must not fire `onText` into a detached element.
3172
+ * Tear the control down, for a host element removed mid-recording or handed
3173
+ * to the next principal. Stops any live `MediaRecorder`, releases the mic
3174
+ * tracks so the browser's recording indicator clears, and suppresses the
3175
+ * pending transcription — a disconnected control must not fire `onText` into
3176
+ * a detached element.
3177
+ *
3178
+ * Disposal is final: nothing un-disposes an instance, and the host's next
3179
+ * wiring builds a new one. So the disposed flag alone tells a continuation
3180
+ * that outlived the control from a live one, and a clip already posted when this runs is
3181
+ * dropped whenever its answer arrives.
3102
3182
  */
3103
3183
  dispose() {
3104
3184
  this.#disposed = true;
@@ -3118,6 +3198,9 @@ var VoiceInput = class {
3118
3198
  const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
3119
3199
  try {
3120
3200
  const text4 = await this.#transcribe(audio);
3201
+ if (this.#disposed) {
3202
+ return;
3203
+ }
3121
3204
  this.#setState("idle");
3122
3205
  if (this.#hitCap) {
3123
3206
  this.element.title = fillUiString(this.#strings.recordingLimit, {
@@ -3141,6 +3224,9 @@ var VoiceInput = class {
3141
3224
  this.#stream = null;
3142
3225
  }
3143
3226
  #fail(message) {
3227
+ if (this.#disposed) {
3228
+ return;
3229
+ }
3144
3230
  this.#releaseStream();
3145
3231
  this.#recorder = null;
3146
3232
  this.#setState("idle");
@@ -4119,9 +4205,20 @@ var ConversationHistory = class {
4119
4205
  * the one it continued is still on screen when it saves.
4120
4206
  */
4121
4207
  #cleared = 0;
4122
- // Bumped on every rehydrate; a replay whose generation is stale (a newer
4123
- // thread switch started while it awaited a slow store) drops its result.
4208
+ /**
4209
+ * Which restore may draw: bumped by every rehydrate, and by every clear of
4210
+ * the conversation on screen. A restore whose generation is stale draws
4211
+ * nothing more and resumes nothing: a newer one has started, or New chat, a
4212
+ * thread switch or a `user-key` handover has cleared what it was restoring.
4213
+ */
4124
4214
  #generation = 0;
4215
+ /**
4216
+ * The conversation the latest restore claimed, written by every restore as
4217
+ * it starts; `null` before the first. It speaks for the restore now current
4218
+ * only while its generation is the current one, because clearing the
4219
+ * conversation moves that on. See `#forgetStopped` for the one reader.
4220
+ */
4221
+ #claim = null;
4125
4222
  /** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
4126
4223
  #runIndex = null;
4127
4224
  /**
@@ -4129,6 +4226,17 @@ var ConversationHistory = class {
4129
4226
  * reaches it. `null` when none is running.
4130
4227
  */
4131
4228
  #continuation = null;
4229
+ /**
4230
+ * Settles what a host's send waits on while the restore now current, one
4231
+ * with no run to resume, loads and replays; `null` when no such restore is
4232
+ * under way. Called when that restore ends, and as soon as it is stopped by
4233
+ * anything that moves the generation, so a send waiting on a conversation
4234
+ * that is no longer on screen hears so at once rather than when an
4235
+ * abandoned load answers.
4236
+ *
4237
+ * Also what tells a pick refused for a load from one refused for a run.
4238
+ */
4239
+ #loading = null;
4132
4240
  constructor(host) {
4133
4241
  this.#host = host;
4134
4242
  }
@@ -4169,6 +4277,14 @@ var ConversationHistory = class {
4169
4277
  forgetRestored() {
4170
4278
  this.#restored = [];
4171
4279
  this.#cleared += 1;
4280
+ this.#generation += 1;
4281
+ this.#endWait();
4282
+ }
4283
+ /** Settle what waits on the restore now loading, if anything does, and forget it. */
4284
+ #endWait() {
4285
+ const end = this.#loading;
4286
+ this.#loading = null;
4287
+ end?.();
4172
4288
  }
4173
4289
  /** Delete the active thread if nothing was ever sent in it. */
4174
4290
  reapUnsent() {
@@ -4273,7 +4389,10 @@ var ConversationHistory = class {
4273
4389
  return;
4274
4390
  }
4275
4391
  if (this.#host.running() || this.#continuation !== null) {
4276
- this.#refuse(this.#host.strings().continueWhileRunning);
4392
+ const strings = this.#host.strings();
4393
+ this.#refuse(
4394
+ this.#loading === null ? strings.continueWhileRunning : strings.continueWhileLoading
4395
+ );
4277
4396
  return;
4278
4397
  }
4279
4398
  const content = this.#host.input.value.trim();
@@ -4281,6 +4400,7 @@ var ConversationHistory = class {
4281
4400
  this.#refuse(this.#host.strings().continueNeedsTurn);
4282
4401
  return;
4283
4402
  }
4403
+ this.#host.recordTurn(content);
4284
4404
  this.#host.input.value = "";
4285
4405
  this.#host.autoGrow();
4286
4406
  const cleared = this.#cleared;
@@ -4357,48 +4477,214 @@ var ConversationHistory = class {
4357
4477
  * because the resume path answers it from the page the reload landed on; that
4358
4478
  * exclusion is held by "resumes with the landed page's result, and its card says
4359
4479
  * so" in `ag_ui_chat_reload_mid_run.test.ts`.
4480
+ *
4481
+ * **Every restore holds the composer from before the load.** Until the
4482
+ * store answers, the conversation's client can only be seeded from nothing.
4483
+ * With a remote store that is a real request, and a send, a Retry or a pick
4484
+ * in it was refused by nothing: each built the conversation's client before
4485
+ * the conversation had loaded, so a send went out carrying none of it, its
4486
+ * first save replaced the stored one, and the replay then drew the stored
4487
+ * conversation under the new turn. The client stayed cached without it, so
4488
+ * every later turn went out without it too. So the composer is held as a
4489
+ * send holds it: what was typed parks and goes out after the replay, and a
4490
+ * Retry and a pick are refused -- a Retry has nothing on screen to retry, and
4491
+ * after the load it would re-ask the previous visit's last question.
4492
+ *
4493
+ * **A host's send waits for a restore with no run to resume.** It is
4494
+ * usually a prompt fired as the element mounts, which no host could time
4495
+ * against a load nothing announces, and refusing it would lose the turn.
4496
+ * So it waits for this restore to end, taking the hold over as it does,
4497
+ * and then sends with the conversation drawn above it; what the built-in
4498
+ * Send parked goes out after its run. Stopped while it waits, it is dropped,
4499
+ * as a send stopped by its own submit listener is.
4500
+ *
4501
+ * **A restore that resumes refuses a host's send instead.** The resume is a
4502
+ * run, and the restore ends by starting it, so a send that waited would only
4503
+ * reach that run and be refused there. The resume gives way to nothing: what
4504
+ * was typed parks behind the resumed run and goes out after its answer.
4505
+ *
4506
+ * The other way round, the resume standing down for the send, was rejected.
4507
+ * The send that got in first had already gone out without the conversation,
4508
+ * so standing down kept that loss, and it also dropped the run the navigation
4509
+ * was part of, which the user asked for before the reload: the agent stopped
4510
+ * halfway through the task, with the navigating call never answered.
4511
+ *
4512
+ * **A restore stops where it was stopped, held or not.** New chat, a thread
4513
+ * switch, a `user-key` handover, a newer restore or the element leaving the
4514
+ * page can each come while the store answers, and from host code the restore
4515
+ * runs itself: the activity and tool renderers each replayed message draws
4516
+ * through, and `navigationResult`. From then on it draws nothing more and
4517
+ * resumes nothing. The hold cannot say so, because a host's send that
4518
+ * waited may hold it by then. So the restore asks its generation, which clearing
4519
+ * the conversation moves on as a newer restore does, and asks it after the
4520
+ * load, after each replayed message -- as a Retry asks after each one -- and
4521
+ * once more before the resume. Stopped by anything but the element leaving
4522
+ * the page, it also forgets the checkpoint it loaded, since that was a Stop
4523
+ * for the run it would have resumed -- unless what replaced it is a restore
4524
+ * of the same conversation, still current, which answers for that run
4525
+ * instead; see `#forgetStopped`.
4360
4526
  */
4361
4527
  async rehydrate() {
4362
4528
  this.#generation += 1;
4363
4529
  const generation = this.#generation;
4364
- this.#host.element.setAttribute("data-restoring", "");
4365
- let messages;
4530
+ const threadId = this.#threadId;
4531
+ const store = this.#host.conversationStore();
4532
+ this.#claim = { generation, store, threadId };
4533
+ const checkpoint = store.loadCheckpoint(threadId);
4534
+ const restored = checkpoint === null ? new Promise((resolve) => {
4535
+ this.#loading = resolve;
4536
+ }) : null;
4537
+ const waiting = this.#loading;
4538
+ const hold = this.#host.holdRun(restored);
4366
4539
  try {
4367
- messages = await this.#host.conversationStore().loadMessages(this.#threadId);
4368
- } finally {
4369
- if (generation === this.#generation) {
4370
- this.#host.element.removeAttribute("data-restoring");
4540
+ this.#host.element.setAttribute("data-restoring", "");
4541
+ let messages;
4542
+ try {
4543
+ messages = await store.loadMessages(threadId);
4544
+ } finally {
4545
+ if (generation === this.#generation) {
4546
+ this.#host.element.removeAttribute("data-restoring");
4547
+ }
4371
4548
  }
4372
- }
4373
- if (generation !== this.#generation) {
4374
- return;
4375
- }
4376
- const checkpoint = this.#host.conversationStore().loadCheckpoint(this.#threadId);
4377
- if (messages !== null) {
4378
- const unfinished = this.#host.strings().callNotFinished;
4379
- const restored = answerUnansweredCalls(
4380
- messages,
4381
- // The shape the store holds for a call the client answered the same way:
4382
- // its tool message, with the outcome in its metadata.
4383
- (toolCallId) => ({
4384
- id: randomUUID3(),
4385
- role: "tool",
4386
- content: unfinished,
4387
- toolCallId,
4388
- metadata: { outcome: TOOL_OUTCOME.INTERRUPTED }
4389
- }),
4390
- new Set(checkpoint === null ? [] : [checkpoint.toolCallId])
4391
- );
4392
- this.#restored = restored;
4393
- for (const message of restored) {
4394
- this.replay(message);
4549
+ if (!this.#restoring(generation)) {
4550
+ this.#forgetStopped(store, threadId, checkpoint);
4551
+ return;
4552
+ }
4553
+ if (messages !== null) {
4554
+ const unfinished = this.#host.strings().callNotFinished;
4555
+ const restored2 = answerUnansweredCalls(
4556
+ messages,
4557
+ // The shape the store holds for a call the client answered the same
4558
+ // way: its tool message, with the outcome in its metadata.
4559
+ (toolCallId) => ({
4560
+ id: randomUUID3(),
4561
+ role: "tool",
4562
+ content: unfinished,
4563
+ toolCallId,
4564
+ metadata: { outcome: TOOL_OUTCOME.INTERRUPTED }
4565
+ }),
4566
+ new Set(checkpoint === null ? [] : [checkpoint.toolCallId])
4567
+ );
4568
+ this.#restored = restored2;
4569
+ for (const message of restored2) {
4570
+ this.replay(message);
4571
+ if (!this.#restoring(generation)) {
4572
+ this.#forgetStopped(store, threadId, checkpoint);
4573
+ return;
4574
+ }
4575
+ }
4576
+ }
4577
+ if (checkpoint !== null) {
4578
+ await this.#resumeFrom(checkpoint, generation);
4579
+ return;
4580
+ }
4581
+ this.#noticeIfRunUnfinished(messages);
4582
+ } finally {
4583
+ hold.release();
4584
+ if (this.#loading === waiting) {
4585
+ this.#endWait();
4395
4586
  }
4396
4587
  }
4397
- if (checkpoint !== null) {
4398
- await this.#resumeFrom(checkpoint);
4399
- return;
4588
+ }
4589
+ /**
4590
+ * Whether the restore that claimed `generation` may go on: nothing has
4591
+ * replaced or cleared the conversation it is restoring, and the element is
4592
+ * still on the page.
4593
+ *
4594
+ * On the page, because leaving it is the one stop that clears nothing: the
4595
+ * element is not reset until it connects again, and connecting restores the
4596
+ * conversation afresh. A restore with nothing to resume went on regardless,
4597
+ * drawing into a node that had gone and running the host's renderers for it.
4598
+ * One that resumes learnt it from the Stop letting go of its hold; nothing
4599
+ * asks the hold that now, so this is what keeps the resume's request in.
4600
+ *
4601
+ * One `&&`, so coverage cannot tell whether either half is held. Removing the
4602
+ * generation fails "draws nothing into a new chat started while it loads" in
4603
+ * `conversation_history.test.ts`, and "drops a stale thread replay when a
4604
+ * newer switch started" in `ag_ui_chat.test.ts`; removing the connection
4605
+ * fails "draws nothing into an element that left the page while it loads".
4606
+ */
4607
+ #restoring(generation) {
4608
+ return generation === this.#generation && this.#host.element.isConnected;
4609
+ }
4610
+ /**
4611
+ * Forget the checkpoint a stopped restore loaded, when what stopped it also
4612
+ * stopped the run it was about to resume.
4613
+ *
4614
+ * New chat, a thread switch, a `user-key` handover and a newer restore all
4615
+ * move the generation, and each is a Stop for that run. Left in the store,
4616
+ * the checkpoint outlived the Stop: coming back to the conversation later
4617
+ * resumed the navigating call with whatever page was current by then.
4618
+ * Forgotten, the call is answered as not finished there, as any stopped call
4619
+ * is.
4620
+ *
4621
+ * Not while the restore now current claimed the same conversation, which it
4622
+ * has in two cases. It is this restore, when only the element left the page,
4623
+ * which moves no generation: a move or a framework re-render is not a
4624
+ * farewell, and the restore connecting starts has to find the checkpoint to
4625
+ * resume from. Or it is a newer restore of the same thread in the same store
4626
+ * -- `reload()`, switching away and back, or the element connecting again --
4627
+ * which read the checkpoint as it started, or found it gone, and answers for
4628
+ * it from then on: its resume clears it, and when it is stopped in turn this
4629
+ * same rule decides.
4630
+ *
4631
+ * The newer restore's case used to forget it too, on the grounds that the
4632
+ * newer one had read it and would resume once from that copy. It did until
4633
+ * the element left the page while the newer one loaded: that one stood down
4634
+ * keeping the checkpoint for the reconnect, as a departure should, but the
4635
+ * store no longer held it, so the element came back with no run to resume.
4636
+ *
4637
+ * Written to the store and the thread it was read from. After a handover
4638
+ * that is the previous principal's: the built-in store's clear is a removal
4639
+ * from a namespace the handover has already purged, so it changes nothing,
4640
+ * and nothing reaches the next principal's. A host's store is the same object
4641
+ * either side of a handover; one the host replaced is held by "still refuses
4642
+ * a send for the newer restore once the older has stood down".
4643
+ *
4644
+ * Two conditions. Clearing with no checkpoint fails "draws nothing into a new
4645
+ * chat started while it loads", which runs no store code for one. Clearing
4646
+ * what the current restore claimed fails "resumes nothing once the element
4647
+ * leaves the page while the store answers", where the claim is this
4648
+ * restore's own and the re-inserted element resumes, and "resumes once the
4649
+ * element is back when the host reloads and it then leaves the page", where
4650
+ * it is the reload's. The claim's own three are named on `#claimedByCurrent`.
4651
+ */
4652
+ #forgetStopped(store, threadId, checkpoint) {
4653
+ if (checkpoint !== null && !this.#claimedByCurrent(store, threadId)) {
4654
+ store.saveCheckpoint(threadId, null);
4400
4655
  }
4401
- this.#noticeIfRunUnfinished(messages);
4656
+ }
4657
+ /**
4658
+ * Whether the restore now current claimed the conversation a stopped restore
4659
+ * read its checkpoint from: the same thread, in the same store.
4660
+ *
4661
+ * A claim from an earlier generation speaks for nobody: the conversation was
4662
+ * cleared after it was made, so whatever it would have resumed was stopped,
4663
+ * and that holds of a stopped restore's own claim as much as of any other.
4664
+ * The store is compared as an object, because a store the host assigned
4665
+ * since is a different record of the conversation, and the one the
4666
+ * checkpoint was read from still has to forget it.
4667
+ *
4668
+ * As an object, a store the element builds for itself -- the built-in one
4669
+ * under a namespace, and the remote one `data-threads-url` wraps around it
4670
+ * -- is a different one after a reconnect, which builds it afresh over the
4671
+ * same storage, so a restore from before the reconnect forgets the
4672
+ * checkpoint there once it answers. The reconnect's own restore read it as
4673
+ * it started and resumes from that copy, as a newer restore always did here;
4674
+ * what that copy cannot survive is the element leaving again before the
4675
+ * load answers.
4676
+ *
4677
+ * One `&&` of three, so coverage cannot tell whether any is held. Dropping
4678
+ * the generation fails "forgets the checkpoint when New chat stops the
4679
+ * resume, so the call ends as not finished", where the claim is the stopped
4680
+ * restore's own; dropping the store fails "still refuses a send for the newer
4681
+ * restore once the older has stood down", whose host replaces the store
4682
+ * between the two; dropping the thread fails "forgets the checkpoint of a
4683
+ * thread switched away from, though the next has one of its own".
4684
+ */
4685
+ #claimedByCurrent(store, threadId) {
4686
+ const claim = this.#claim;
4687
+ return claim?.generation === this.#generation && claim.store === store && claim.threadId === threadId;
4402
4688
  }
4403
4689
  /**
4404
4690
  * Notice a previous run that never produced a response.
@@ -4518,12 +4804,15 @@ var ConversationHistory = class {
4518
4804
  * guard is held by "resumes a checkpointed navigating tool call on mount" in
4519
4805
  * `ag_ui_chat.test.ts`, whose transcript never made the call.
4520
4806
  */
4521
- async #resumeFrom(checkpoint) {
4807
+ async #resumeFrom(checkpoint, generation) {
4522
4808
  this.#host.conversationStore().saveCheckpoint(this.#threadId, null);
4523
4809
  const client = this.#host.ensureClient();
4524
4810
  const landed = JSON.stringify(
4525
4811
  this.#host.navigationResult().call(this.#host.element, checkpoint)
4526
4812
  );
4813
+ if (!this.#restoring(generation)) {
4814
+ return;
4815
+ }
4527
4816
  client.addToolResult(checkpoint.toolCallId, landed);
4528
4817
  this.#host.transcript.card(checkpoint.toolCallId)?.settle(TOOL_CALL_STATUS.DONE, landed);
4529
4818
  await client.resume();
@@ -14999,8 +15288,10 @@ var AgUiClient = class {
14999
15288
  #unfinishedMessage;
15000
15289
  #declinedMessage;
15001
15290
  #maxToolRounds;
15002
- // Set by cancel(); reset at the top of each #run(). Checked by the loop so
15003
- // a cancel between frontend-tool rounds doesn't start another round.
15291
+ // Set by cancel(); reset as send() or resume() begins, before either runs
15292
+ // any host code. Checked by the loop so a cancel between frontend-tool
15293
+ // rounds doesn't start another round, and again just before each request,
15294
+ // so a cancel from host code the round itself runs is not sent anyway.
15004
15295
  #cancelled = false;
15005
15296
  constructor(config) {
15006
15297
  this.#agent = config.agent;
@@ -15056,6 +15347,7 @@ var AgUiClient = class {
15056
15347
  * {@link messageAttachments}.
15057
15348
  */
15058
15349
  async send(content, attachments = []) {
15350
+ this.#cancelled = false;
15059
15351
  const message = {
15060
15352
  id: randomUUID4(),
15061
15353
  role: "user",
@@ -15071,11 +15363,14 @@ var AgUiClient = class {
15071
15363
  * can be asked again.
15072
15364
  *
15073
15365
  * Returns the retained history, or `null` when there is nothing to retry (no
15074
- * user message has been sent yet). **Truncates only** -- the caller re-renders
15075
- * from the returned list and then calls {@link resume}, because the transcript
15076
- * belongs to the element and a client that reached into it would own two
15077
- * things. Running here instead would stream the new answer in underneath the
15078
- * old one.
15366
+ * user message has been sent yet). The shortened history is saved before it
15367
+ * replaces the current one, so a save that throws changes nothing and the
15368
+ * throw propagates.
15369
+ *
15370
+ * **Truncates only** -- the caller re-renders from the returned list and then
15371
+ * calls {@link resume}, because the transcript belongs to the element and a
15372
+ * client that reached into it would own two things. Running here instead
15373
+ * would stream the new answer in underneath the old one.
15079
15374
  *
15080
15375
  * Re-running answers the question the agent was last asked, rather than
15081
15376
  * telling it its answer was wrong, which is what makes the result a
@@ -15097,8 +15392,8 @@ var AgUiClient = class {
15097
15392
  return null;
15098
15393
  }
15099
15394
  const kept = messages.slice(0, lastUser + 1);
15395
+ this.#onPersist(kept);
15100
15396
  this.#agent.setMessages(kept);
15101
- this.#persist();
15102
15397
  return kept;
15103
15398
  }
15104
15399
  /**
@@ -15107,6 +15402,7 @@ var AgUiClient = class {
15107
15402
  * message; it continues the conversation already in history.
15108
15403
  */
15109
15404
  async resume() {
15405
+ this.#cancelled = false;
15110
15406
  await this.#run();
15111
15407
  }
15112
15408
  /** Append a frontend tool result to history (used by the resume path). */
@@ -15120,13 +15416,19 @@ var AgUiClient = class {
15120
15416
  * disconnect). Safe to call with no run in flight. Partial text already
15121
15417
  * streamed stays in history; {@link AgUiClientHandlers.onCancelled} fires
15122
15418
  * instead of `onError`, and `onSettled` still follows.
15419
+ *
15420
+ * Also stops a {@link send} or {@link resume} whose request has not been made
15421
+ * yet, when host code it runs on the way calls this: the save of the turn,
15422
+ * the tool and context providers, the save answering a call left open. No
15423
+ * request is made then, and `onCancelled` and `onSettled` fire as for any
15424
+ * other Stop. A cancel with nothing in flight is forgotten when the next
15425
+ * send or resume begins.
15123
15426
  */
15124
15427
  cancel() {
15125
15428
  this.#cancelled = true;
15126
15429
  this.#agent.abortRun();
15127
15430
  }
15128
15431
  async #run() {
15129
- this.#cancelled = false;
15130
15432
  try {
15131
15433
  await this.#runLoop();
15132
15434
  if (this.#cancelled) {
@@ -15284,6 +15586,9 @@ var AgUiClient = class {
15284
15586
  params.resume = resume;
15285
15587
  }
15286
15588
  this.#answerUnansweredCalls(resuming);
15589
+ if (this.#cancelled) {
15590
+ return;
15591
+ }
15287
15592
  await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
15288
15593
  resume = void 0;
15289
15594
  resuming = /* @__PURE__ */ new Set();
@@ -16206,9 +16511,12 @@ var StorageScope = class {
16206
16511
  // remounts, but re-resolves if the host answers the warning with an `id`.
16207
16512
  #generatedNs = "";
16208
16513
  #generatedFor = "";
16209
- // The `sessionStorage`-backed store, which the element may therefore re-scope
16210
- // on a principal change. `null` when the host injected a store of its own
16211
- // kind, whose keying the element does not know and must not guess at.
16514
+ // The `sessionStorage`-backed store this scope handed out, which the element
16515
+ // may therefore re-scope on a principal change -- while it is still the store
16516
+ // in use, which rescopeStore asks, since a host can assign its own at any
16517
+ // time. `null` until connecting has met the built-in store at all. A store of
16518
+ // the host's own kind is never held here: the element does not know its
16519
+ // keying and must not guess at it.
16212
16520
  #builtinStore = null;
16213
16521
  constructor(host) {
16214
16522
  this.#host = host;
@@ -16262,12 +16570,22 @@ var StorageScope = class {
16262
16570
  return this.#builtinStore;
16263
16571
  }
16264
16572
  /**
16265
- * Rebuild the built-in store under `namespace`, or `null` for a store of the
16266
- * host's own kind, which holds its data somewhere the element cannot see and
16267
- * has to scope itself.
16573
+ * Rebuild the built-in store under `namespace`, or `null` when `current` --
16574
+ * the store the element is using now, unwrapped from any remote it wrapped
16575
+ * it in -- is not the one this scope handed out.
16576
+ *
16577
+ * Asked of the store in use rather than of what connecting remembered,
16578
+ * because the two part company the moment a host assigns a store of its own
16579
+ * to a connected element. Remembering was enough to tell a host's store from
16580
+ * the built-in one at connect; after it, the built-in one was still
16581
+ * remembered, so a key change replaced the host's store with a fresh
16582
+ * `sessionStorage` one -- and a host that had chosen to keep message bodies
16583
+ * off the client found every one of them cached in the tab again. A store
16584
+ * the element did not make holds its data somewhere the element cannot see,
16585
+ * and has to scope itself.
16268
16586
  */
16269
- rescopeStore(namespace) {
16270
- if (this.#builtinStore === null) {
16587
+ rescopeStore(current, namespace) {
16588
+ if (current !== this.#builtinStore) {
16271
16589
  return null;
16272
16590
  }
16273
16591
  this.#builtinStore = new SessionStorageStore(namespace);
@@ -16791,6 +17109,13 @@ var AgUiChat = class extends HTMLElement {
16791
17109
  #queuedRow = document.createElement("div");
16792
17110
  /** How far back the composer has been walked; null while the user is typing. */
16793
17111
  #recallIndex = null;
17112
+ /**
17113
+ * What the walk last put in the composer, read back from it. A walk is live
17114
+ * only while the composer still holds exactly this, so a write from anywhere
17115
+ * else ends it. Stale while no walk is in progress, which is harmless: the
17116
+ * comparison can only ever end a walk, never start one.
17117
+ */
17118
+ #recalled = "";
16794
17119
  /**
16795
17120
  * Re-clamp the dragged launcher when the window changes size. Bound once as
16796
17121
  * a field so `removeEventListener` on disconnect gets the same reference.
@@ -16826,6 +17151,59 @@ var AgUiChat = class extends HTMLElement {
16826
17151
  * history replay would draw the conversation a second time beneath it.
16827
17152
  */
16828
17153
  #connectedBefore = false;
17154
+ /**
17155
+ * The `user-key` whose state the element holds -- the transcript, the recall
17156
+ * history, the Always allow waivers and the shared state -- or `null` before
17157
+ * the first connect, when it holds nobody's.
17158
+ *
17159
+ * Not the same thing as the attribute. The attribute names the principal the
17160
+ * host says is here now, and it can change while the element is out of the
17161
+ * document, where nothing acts on it; this is who the state still belongs
17162
+ * to. Connecting compares the two, which is how a key changed while detached
17163
+ * still gets the handover a live change gets.
17164
+ */
17165
+ #principalKey = null;
17166
+ /**
17167
+ * The current principal's tenure: replaced on every handover from one
17168
+ * principal to another, and captured by each client as it is built, so that
17169
+ * a client can tell whether the principal it was built for is still the one
17170
+ * this element serves.
17171
+ *
17172
+ * A client outlives the handover that stops it. A stopped run saves what it
17173
+ * had once its request closes, a tool handler cannot be aborted and its
17174
+ * result is kept, and an event already read off the wire is still applied --
17175
+ * all of it after the store was purged and scoped to whoever arrived, so
17176
+ * each one wrote the previous principal's words into the next one's storage
17177
+ * or state. Writing into the previous principal's store instead would only
17178
+ * re-create what the purge just removed, so such a client writes nothing.
17179
+ *
17180
+ * An identity rather than the key itself, for two reasons. The key's first
17181
+ * arrival is an adoption, not a handover, and a run in flight across it is
17182
+ * the same person's and keeps saving. And a key that leaves and comes back
17183
+ * is a second tenure: the first one's stopped run must not re-create a
17184
+ * conversation the handover purged.
17185
+ */
17186
+ #tenure = {};
17187
+ /**
17188
+ * The conversation on screen: replaced every time the transcript is cleared
17189
+ * for another one -- New chat, a drawer switch, deleting the active thread,
17190
+ * `reload()`, re-insertion and a handover all pass through
17191
+ * {@link #resetConversation} -- and captured by each frontend tool call as it
17192
+ * is dispatched, so the call can tell whether the conversation it was drawn
17193
+ * in is still the one showing.
17194
+ *
17195
+ * A call outlives the reset that stops its run. A handler cannot be aborted,
17196
+ * and a card open at the time is declined, so the call resumes once the new
17197
+ * conversation is showing; drawing the run's pending indicator then put it
17198
+ * in a conversation with no run to take it down.
17199
+ *
17200
+ * Not {@link #tenure}, which only a handover replaces. A reset is not a
17201
+ * change of principal: the call still belongs to someone this element
17202
+ * serves, so it still settles its card, clears its checkpoint and returns
17203
+ * its result, and only the drawing is skipped. The tenure checks end the
17204
+ * call before any of that.
17205
+ */
17206
+ #conversation = {};
16829
17207
  /**
16830
17208
  * The remote store `data-threads-url` wrapped around the conversation store,
16831
17209
  * and the store inside it, so connecting again can wrap that store rather
@@ -16844,6 +17222,42 @@ var AgUiChat = class extends HTMLElement {
16844
17222
  // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
16845
17223
  // rounds, but the user must still be able to stop there.
16846
17224
  #running = false;
17225
+ /**
17226
+ * The send {@link sendMessage} has taken, or the retry {@link retryLastTurn}
17227
+ * has, whose run has not started yet; null otherwise.
17228
+ *
17229
+ * `running` turns on when the client starts the run, a microtask after it is
17230
+ * asked for one, and a send does two things before it asks: it draws the
17231
+ * turn, and it dispatches the submit event, whose listeners run in the middle
17232
+ * of the send. A send from one of them, or a second call in the same task,
17233
+ * passed every guard and started a second run on the same client -- two
17234
+ * requests, and the listener's turn drawn between the user's turn and its
17235
+ * answer. It is the gap a picked checkpoint closes by counting from the pick,
17236
+ * closed the same way for a send. A retry asks for its run the same way, so
17237
+ * it takes the same hold, and so does every restore, from before it asks the
17238
+ * store for the conversation, through the history host's `holdRun`: until
17239
+ * the store answers, the client can only be built from nothing.
17240
+ *
17241
+ * Let go of once the run starts, because `running` holds from then until the
17242
+ * run settles, and a send held past that would refuse a host's follow-up from
17243
+ * the run-finished event, which fires as the run settles. Let go of by the
17244
+ * send's, the retry's or the restore's end when its run never starts.
17245
+ *
17246
+ * An object rather than a flag, so a send or a retry ending releases only
17247
+ * what it took. By the time either ends, the turn queued behind it has
17248
+ * usually taken the field for itself, and a Stop lets go of it for the next
17249
+ * send to take; the earlier one ending must release neither. It is also how
17250
+ * {@link sendMessage} tells the one hold it waits on from all the others it
17251
+ * refuses behind: see {@link #restoreHold}.
17252
+ */
17253
+ #sending = null;
17254
+ /**
17255
+ * The hold the latest restore with no run to resume took, and what settles
17256
+ * once that restore has ended or been stopped; `null` when the latest
17257
+ * restore will resume a run. Only meaningful while `#sending` is that hold:
17258
+ * then a host's {@link sendMessage} waits for it rather than refusing.
17259
+ */
17260
+ #restoreHold = null;
16847
17261
  /** The decision a run is suspended on, which a Stop abandons. */
16848
17262
  #decision = new PendingDecision();
16849
17263
  /**
@@ -16933,7 +17347,12 @@ var AgUiChat = class extends HTMLElement {
16933
17347
  approvalRenderer: () => this.approvalRenderer,
16934
17348
  getContext: () => this.getContext(),
16935
17349
  conversationStore: () => this.conversationStore,
16936
- threadId: () => this.#history.threadId
17350
+ threadId: () => this.#history.threadId,
17351
+ // Read live and compared, the way each client compares the one it was
17352
+ // built with: see #tenure.
17353
+ tenure: () => this.#tenure,
17354
+ // The same, for the conversation on screen: see #conversation.
17355
+ conversation: () => this.#conversation
16937
17356
  });
16938
17357
  this.#runHandlers = new RunHandlers({
16939
17358
  element: this,
@@ -17069,6 +17488,7 @@ var AgUiChat = class extends HTMLElement {
17069
17488
  // A continuation sends only what the composer holds. The tray is not
17070
17489
  // read, so nothing rides along to be drawn or announced.
17071
17490
  announceTurn: (content) => this.#announceTurn(content, []),
17491
+ recordTurn: (content) => this.#recordTurn(content),
17072
17492
  autoGrow: () => autoGrow(this.#input),
17073
17493
  continuationEnded: () => this.#flushQueued(),
17074
17494
  client: () => this.#client,
@@ -17077,7 +17497,41 @@ var AgUiChat = class extends HTMLElement {
17077
17497
  releaseClient: () => {
17078
17498
  this.#client = null;
17079
17499
  },
17080
- running: () => this.#running,
17500
+ // A held send or retry is as much in flight to a pick as a run is: it has
17501
+ // asked this element's client for a run that has not started, and a
17502
+ // continuation started beside it was a second run in one conversation.
17503
+ // So is a restore, which holds through the same field from before the
17504
+ // store answers.
17505
+ running: () => this.#running || this.#sending !== null,
17506
+ // The hold a send takes, taken for every restore, so what refuses a send
17507
+ // held short of its run holds one made while the store is still
17508
+ // answering: Send parks the turn, a Retry and a pick refuse, and
17509
+ // `sendMessage` refuses behind a restore that will resume a run and waits
17510
+ // for one that will not -- `restored` is what it waits on, given only for
17511
+ // that kind. The same field rather than a flag beside it, because what
17512
+ // lets go of a send's hold is right for this one too. The resumed run
17513
+ // starting lets go, in `#setRunning`, so there is no gap after the resume
17514
+ // is asked for, and a Stop does, in `#cancelRun`. A flag the restore
17515
+ // cleared itself would still be set when the resumed run settles and
17516
+ // sends the parked turn, so that send would be refused after the turn had
17517
+ // left the queue, and lost.
17518
+ //
17519
+ // Whether the restore was stopped is not read off this hold, because a
17520
+ // host's send that waited takes it over; the restore asks its own
17521
+ // generation.
17522
+ holdRun: (restored) => {
17523
+ const hold = {};
17524
+ this.#sending = hold;
17525
+ this.#restoreHold = restored === null ? null : { hold, restored };
17526
+ return {
17527
+ release: () => {
17528
+ if (this.#sending === hold) {
17529
+ this.#sending = null;
17530
+ this.#flushQueued();
17531
+ }
17532
+ }
17533
+ };
17534
+ },
17081
17535
  cancelRun: () => this.#cancelRun(),
17082
17536
  resetState: () => this.#resetState(),
17083
17537
  setRunning: (running) => this.#setRunning(running)
@@ -17387,6 +17841,12 @@ var AgUiChat = class extends HTMLElement {
17387
17841
  }
17388
17842
  this.#syncLauncher();
17389
17843
  this.#skills.init();
17844
+ const held = this.#principalKey;
17845
+ const changed = held !== null && held !== this.userKey;
17846
+ if (changed) {
17847
+ this.#changePrincipal(held, this.userKey);
17848
+ }
17849
+ this.#principalKey = this.userKey;
17390
17850
  this.conversationStore = this.#storage.scopeStore(this.#unwrappedStore(), this.userKey);
17391
17851
  window.addEventListener("resize", this.#onViewportResize);
17392
17852
  window.visualViewport?.addEventListener("resize", this.#onViewportResize);
@@ -17398,6 +17858,9 @@ var AgUiChat = class extends HTMLElement {
17398
17858
  this.#history.adoptActiveThread();
17399
17859
  queueMicrotask(() => this.#startup());
17400
17860
  void this.#history.rehydrate();
17861
+ if (changed) {
17862
+ void this.#history.refreshDrawer();
17863
+ }
17401
17864
  this.#connected = true;
17402
17865
  }
17403
17866
  /**
@@ -17722,23 +18185,49 @@ var AgUiChat = class extends HTMLElement {
17722
18185
  * logout, because a logout is a navigation (or, in a single-page app, not
17723
18186
  * even that) rather than a tab close. Nothing remounts, so the host naming
17724
18187
  * the new principal is the only signal the element will ever get.
18188
+ *
18189
+ * Two callers. A live change runs all of it. connectedCallback runs it for a
18190
+ * key that changed while the element was out of the document, and there it
18191
+ * runs before the store is scoped and before `#connected` is set -- which is
18192
+ * what `live` reads, since `isConnected` is already true by then. That call
18193
+ * does the half about what storage and the element still hold. The half
18194
+ * about the principal arriving -- the store scoped under the new key, its
18195
+ * active thread, the replay -- is what connecting goes on to do anyway, and
18196
+ * run here too it would read the old store before the new one exists,
18197
+ * minting a thread pointer in the namespace just purged and replaying the
18198
+ * history twice. Nor would it leave alone a store the host assigned while
18199
+ * the element was out.
17725
18200
  */
17726
18201
  #changePrincipal(previousKey, nextKey) {
18202
+ this.#principalKey = nextKey;
18203
+ const live = this.#connected;
17727
18204
  const previous = this.#storage.conversationNamespace(previousKey);
17728
18205
  const next = this.#storage.conversationNamespace(nextKey);
17729
18206
  if (previousKey === "") {
17730
18207
  SessionStorageStore.adopt(previous, next);
17731
- this.#rescopeStore(next);
18208
+ if (live) {
18209
+ this.#rescopeStore(next);
18210
+ }
17732
18211
  return;
17733
18212
  }
18213
+ this.#tenure = {};
17734
18214
  SessionStorageStore.purge(previous);
17735
- this.#rescopeStore(next);
18215
+ if (live) {
18216
+ this.#rescopeStore(next);
18217
+ }
17736
18218
  this.#dispatch.forgetWaivers();
17737
18219
  this.#cancelRun();
17738
18220
  this.#resetState();
17739
18221
  this.#sharedState = {};
18222
+ this.#input.value = "";
18223
+ this.#onInput();
17740
18224
  this.#setRunning(false);
17741
18225
  this.#setUnread(0);
18226
+ if (!live) {
18227
+ return;
18228
+ }
18229
+ this.#voice.dispose();
18230
+ this.#voice.wire();
17742
18231
  this.#history.adoptActiveThread();
17743
18232
  void this.#history.rehydrate();
17744
18233
  void this.#history.refreshDrawer();
@@ -17747,13 +18236,17 @@ var AgUiChat = class extends HTMLElement {
17747
18236
  * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
17748
18237
  * `data-threads-url` exactly as connecting did.
17749
18238
  *
17750
- * A store of the host's own kind is left alone: a store that holds its data
17751
- * somewhere the element cannot see has to scope itself. The transcript on
18239
+ * A store the host assigned is left alone, whether before connecting or
18240
+ * since: a store that holds its data somewhere the element cannot see has to
18241
+ * scope itself. The exception is a plain `SessionStorageStore` assigned
18242
+ * before connecting, which connecting cannot tell from the default and so
18243
+ * took for the element's own. Hence the unwrapped store in use is what the scope is asked
18244
+ * about, rather than what it remembered when connecting. The transcript on
17752
18245
  * screen is still cleared either way — the host swapped principals, and that
17753
18246
  * much is the element's to act on.
17754
18247
  */
17755
18248
  #rescopeStore(namespace) {
17756
- const store = this.#storage.rescopeStore(namespace);
18249
+ const store = this.#storage.rescopeStore(this.#unwrappedStore(), namespace);
17757
18250
  if (store === null) {
17758
18251
  return;
17759
18252
  }
@@ -17849,6 +18342,7 @@ var AgUiChat = class extends HTMLElement {
17849
18342
  #resetConversation() {
17850
18343
  this.#client = null;
17851
18344
  this.#runHandlers.detach();
18345
+ this.#conversation = {};
17852
18346
  this.#clearTranscript();
17853
18347
  this.#history.forgetRestored();
17854
18348
  }
@@ -17876,8 +18370,20 @@ var AgUiChat = class extends HTMLElement {
17876
18370
  *
17877
18371
  * History is truncated to the most recent user message inclusive and the run
17878
18372
  * repeats, so the agent answers what it was asked rather than being told its
17879
- * last answer was wrong. Returns `false` when there is nothing to retry or a
17880
- * run is already in flight.
18373
+ * last answer was wrong. Returns `false` when there is nothing to retry, or
18374
+ * while anything that holds a send is in flight: a run, a send or another
18375
+ * retry whose run has not started yet, a checkpoint continuation, or a
18376
+ * restore of the stored conversation that is still loading. It does not wait
18377
+ * for a restore as a send does: nothing is on screen to retry yet, and after
18378
+ * the load it would re-ask the previous visit's last question. Also `false`
18379
+ * when host code it
18380
+ * runs before its run starts -- the store's save, or an activity or tool
18381
+ * renderer the replay draws through -- has stopped it by starting a new chat
18382
+ * or changing `user-key`. A tool or context provider that stops it does so
18383
+ * inside the client's resume, past the last point this can see: nothing is
18384
+ * sent, and this resolves `true`, because both a run starting and a Stop let
18385
+ * go of the hold it checks. Rejects with the store's error when the store
18386
+ * refuses the save, and then the conversation is as it was.
17881
18387
  *
17882
18388
  * Public because a host with its own message UI wants the same button, and
17883
18389
  * because the failed-run notice reaches it from outside the action row.
@@ -17888,20 +18394,35 @@ var AgUiChat = class extends HTMLElement {
17888
18394
  * again -- unless the user waived it for this session.
17889
18395
  */
17890
18396
  async retryLastTurn() {
17891
- if (this.#running) {
17892
- return false;
17893
- }
17894
- const client = this.#ensureClient();
17895
- const kept = client.truncateToLastUser();
17896
- if (kept === null) {
18397
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
17897
18398
  return false;
17898
18399
  }
17899
- this.#clearTranscript();
17900
- for (const message of kept) {
17901
- this.#history.replay(message);
18400
+ const hold = {};
18401
+ this.#sending = hold;
18402
+ try {
18403
+ const client = this.#ensureClient();
18404
+ const kept = client.truncateToLastUser();
18405
+ if (kept === null) {
18406
+ return false;
18407
+ }
18408
+ if (this.#sending !== hold) {
18409
+ return false;
18410
+ }
18411
+ this.#clearTranscript();
18412
+ for (const message of kept) {
18413
+ this.#history.replay(message);
18414
+ if (this.#sending !== hold) {
18415
+ return false;
18416
+ }
18417
+ }
18418
+ await client.resume();
18419
+ return true;
18420
+ } finally {
18421
+ if (this.#sending === hold) {
18422
+ this.#sending = null;
18423
+ this.#flushQueued();
18424
+ }
17902
18425
  }
17903
- await client.resume();
17904
- return true;
17905
18426
  }
17906
18427
  /**
17907
18428
  * Append a message bubble and return it.
@@ -18213,6 +18734,9 @@ var AgUiChat = class extends HTMLElement {
18213
18734
  * which is what every shell and every coding agent means by this. Arrowing
18214
18735
  * forward past the newest empties the composer again rather than sticking on
18215
18736
  * it, so the way out is the same key that got you in.
18737
+ *
18738
+ * A walk ends with any write into the composer that is not its own, so that
18739
+ * whatever was written is never replaced by the next step.
18216
18740
  */
18217
18741
  #recallHistory(event) {
18218
18742
  const back = event.key === "ArrowUp";
@@ -18223,6 +18747,9 @@ var AgUiChat = class extends HTMLElement {
18223
18747
  if (drafts.length === 0) {
18224
18748
  return;
18225
18749
  }
18750
+ if (this.#input.value !== this.#recalled) {
18751
+ this.#recallIndex = null;
18752
+ }
18226
18753
  if (this.#recallIndex === null && (!back || this.#input.value !== "")) {
18227
18754
  return;
18228
18755
  }
@@ -18233,6 +18760,7 @@ var AgUiChat = class extends HTMLElement {
18233
18760
  event.preventDefault();
18234
18761
  this.#recallIndex = next < 0 ? null : next;
18235
18762
  this.#input.value = next < 0 ? "" : drafts[next];
18763
+ this.#recalled = this.#input.value;
18236
18764
  this.#input.setSelectionRange(this.#input.value.length, this.#input.value.length);
18237
18765
  autoGrow(this.#input);
18238
18766
  }
@@ -18243,15 +18771,11 @@ var AgUiChat = class extends HTMLElement {
18243
18771
  * observes the disconnect).
18244
18772
  */
18245
18773
  #cancelRun() {
18246
- for (const text4 of this.#queued) {
18247
- if (this.#sentDrafts[0] !== text4) {
18248
- this.#sentDrafts.unshift(text4);
18249
- }
18250
- }
18251
18774
  this.#queued.length = 0;
18252
18775
  this.#renderQueued();
18253
18776
  this.#decision.abort();
18254
18777
  this.#client?.cancel();
18778
+ this.#sending = null;
18255
18779
  this.#history.stopContinuation();
18256
18780
  }
18257
18781
  /**
@@ -18264,6 +18788,9 @@ var AgUiChat = class extends HTMLElement {
18264
18788
  #setRunning(running) {
18265
18789
  const settled = this.#running && !running;
18266
18790
  this.#running = running;
18791
+ if (running) {
18792
+ this.#sending = null;
18793
+ }
18267
18794
  const label = running ? this.#strings.stop : this.#strings.send;
18268
18795
  this.#send.title = label;
18269
18796
  this.#send.setAttribute("aria-label", label);
@@ -18314,13 +18841,29 @@ var AgUiChat = class extends HTMLElement {
18314
18841
  this.#queuedRow.appendChild(chip);
18315
18842
  }
18316
18843
  }
18844
+ /**
18845
+ * Put a turn that has left the composer at the front of the recall history,
18846
+ * and start the next walk from it.
18847
+ *
18848
+ * Every turn the user typed and pressed a key to send comes through here:
18849
+ * sent at once, queued behind a run, or sent to continue a checkpoint. A
18850
+ * host's own `sendMessage` does not, because its text was never in the
18851
+ * composer, and the arrow keys walk back through what the user wrote there.
18852
+ */
18853
+ #recordTurn(content) {
18854
+ if (content !== "" && this.#sentDrafts[0] !== content) {
18855
+ this.#sentDrafts.unshift(content);
18856
+ }
18857
+ this.#recallIndex = null;
18858
+ }
18317
18859
  async #submit() {
18318
18860
  const content = this.#input.value.trim();
18319
18861
  const attachments = this.#attachments.tray?.readyRefs() ?? [];
18320
18862
  if (content === "" && attachments.length === 0) {
18321
18863
  return;
18322
18864
  }
18323
- if (this.#running || this.#history.continuation !== null) {
18865
+ this.#recordTurn(content);
18866
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
18324
18867
  if (content !== "") {
18325
18868
  this.#queued.push(content);
18326
18869
  this.#renderQueued();
@@ -18329,10 +18872,6 @@ var AgUiChat = class extends HTMLElement {
18329
18872
  }
18330
18873
  return;
18331
18874
  }
18332
- if (content !== "" && this.#sentDrafts[0] !== content) {
18333
- this.#sentDrafts.unshift(content);
18334
- }
18335
- this.#recallIndex = null;
18336
18875
  this.#input.value = "";
18337
18876
  autoGrow(this.#input);
18338
18877
  if (this.#attachments.tray?.hasPending() === true) {
@@ -18358,21 +18897,77 @@ var AgUiChat = class extends HTMLElement {
18358
18897
  * `attachments` are durable {@link AttachmentRef}s — what {@link attachFile}
18359
18898
  * resolves to and what {@link ATTACHMENT_EVENT} reports.
18360
18899
  *
18361
- * No-ops on an empty message, and while a run or a picked checkpoint's
18362
- * continuation is in flight, since a second concurrent run would orphan the
18363
- * first. Unlike the built-in Send it does not queue: it returns, and the
18364
- * caller keeps what it tried to send. Nor does it consult the tray -- what
18365
- * you pass is what is sent.
18900
+ * Resolves `true` once the turn has been handed to the client for its run,
18901
+ * which is when the interaction settles, frontend-tool rounds included; a
18902
+ * run a tool or context provider then stops still counts, as it does for
18903
+ * {@link retryLastTurn}. Resolves `false` when nothing was sent.
18904
+ *
18905
+ * Nothing is sent for an empty message, nor while a run or a picked
18906
+ * checkpoint's continuation is in flight, since a second concurrent run
18907
+ * would orphan the first. A send is in flight from the moment it is taken
18908
+ * rather than from its run's start, so nothing is sent from a
18909
+ * {@link SUBMIT_EVENT} listener or for a second call in the same task
18910
+ * either. Nor with no `endpoint`, which says so in the transcript. Nor
18911
+ * behind a reload's restore that will resume a run a navigating tool
18912
+ * interrupted, since that restore ends by starting the run.
18913
+ *
18914
+ * **While the element restores a stored conversation with no run to resume
18915
+ * -- on connect, {@link reload}, a thread picked from the drawer, a change
18916
+ * from one `user-key` to another -- it waits, then sends.** The turn has to follow the
18917
+ * conversation it is part of: sent at once, it went out without it, the
18918
+ * conversation was then drawn under it, and its save replaced the stored
18919
+ * one. So the bubble, the {@link SUBMIT_EVENT} and the request all come after
18920
+ * the replay, by the length of the store's load. A New chat, a thread
18921
+ * switch, a change from one `user-key` to another, a reload or the element
18922
+ * leaving the page while it waits drops it, resolving `false`: the
18923
+ * conversation it was addressed to is no longer on screen. Leaving the page
18924
+ * resolves it only once the load answers or the element is back, since
18925
+ * that is when the restore learns it was stopped. A first `user-key` on an
18926
+ * anonymous conversation adopts it rather than replacing it, and stops
18927
+ * nothing. A second call while one waits is
18928
+ * refused, as a second call in the same task is.
18929
+ *
18930
+ * Unlike the built-in Send it does not queue: it returns, and the caller
18931
+ * keeps what it tried to send. Nor does it consult the tray -- what you pass
18932
+ * is what is sent.
18366
18933
  */
18367
18934
  async sendMessage(content, attachments = []) {
18368
- if (this.#running || this.#history.continuation !== null || content === "" && attachments.length === 0) {
18369
- return;
18935
+ if (content === "" && attachments.length === 0) {
18936
+ return false;
18937
+ }
18938
+ const restore = this.#restoreHold;
18939
+ if (restore !== null && this.#sending === restore.hold) {
18940
+ const waiting = {};
18941
+ this.#sending = waiting;
18942
+ await restore.restored;
18943
+ if (this.#sending !== waiting) {
18944
+ return false;
18945
+ }
18946
+ this.#sending = null;
18947
+ return this.sendMessage(content, attachments);
18370
18948
  }
18371
- if (this.#transcript.isEmpty()) {
18372
- this.setAttribute("data-composer-settling", "");
18949
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
18950
+ return false;
18951
+ }
18952
+ const send = {};
18953
+ this.#sending = send;
18954
+ try {
18955
+ if (this.#transcript.isEmpty()) {
18956
+ this.setAttribute("data-composer-settling", "");
18957
+ }
18958
+ this.#announceTurn(content, attachments);
18959
+ if (this.#sending !== send) {
18960
+ return false;
18961
+ }
18962
+ const connected = this.endpoint !== "";
18963
+ await this.#client_send(content, attachments);
18964
+ return connected;
18965
+ } finally {
18966
+ if (this.#sending === send) {
18967
+ this.#sending = null;
18968
+ this.#flushQueued();
18969
+ }
18373
18970
  }
18374
- this.#announceTurn(content, attachments);
18375
- await this.#client_send(content, attachments);
18376
18971
  }
18377
18972
  /**
18378
18973
  * Put a turn the user sent on screen, and tell the host it was sent.
@@ -18472,6 +19067,7 @@ var AgUiChat = class extends HTMLElement {
18472
19067
  */
18473
19068
  #buildClient(seed) {
18474
19069
  const threadId = this.#history.threadId;
19070
+ const tenure = this.#tenure;
18475
19071
  const agent = this.agentFactory({
18476
19072
  endpoint: seed.endpoint,
18477
19073
  headers: this.#requestHeaders(),
@@ -18496,12 +19092,30 @@ var AgUiChat = class extends HTMLElement {
18496
19092
  // list per thread. For the conversation's own client that is its
18497
19093
  // history; a continuation holds only what it adds, and writes the
18498
19094
  // conversation it continues ahead of that.
19095
+ //
19096
+ // Every message list a client saves reaches the store through here -- a
19097
+ // send, a round's end, a tool's result, the answers it writes for calls
19098
+ // left open or declined by a Stop, a Stop's last save -- and so does a
19099
+ // `data-threads-url` store's local copy, which is the only thing that
19100
+ // wrapper does on a save. So this is the one gate for them. `onSaved` is
19101
+ // behind it as well: it hands the saved conversation on as the seed of
19102
+ // the next client, which would be the next principal's.
18499
19103
  onPersist: (messages) => {
19104
+ if (tenure !== this.#tenure) {
19105
+ return;
19106
+ }
18500
19107
  const conversation = [...seed.follows, ...messages];
18501
19108
  this.conversationStore.saveMessages(threadId, conversation);
18502
19109
  seed.onSaved?.(conversation);
18503
19110
  },
18504
- onStateChanged: (state) => this.#onSharedStateChanged(state),
19111
+ // Shared state is the next principal's first `RunAgentInput.state`, and
19112
+ // the handover has just emptied it. A snapshot already read off the wire
19113
+ // is applied after the Stop, and would put it back.
19114
+ onStateChanged: (state) => {
19115
+ if (tenure === this.#tenure) {
19116
+ this.#onSharedStateChanged(state);
19117
+ }
19118
+ },
18505
19119
  connectionLostMessage: this.#strings.connectionLost,
18506
19120
  unfinishedMessage: this.#strings.callNotFinished,
18507
19121
  declinedMessage: this.#strings.declinedAction,
@@ -18663,7 +19277,7 @@ function setControlValue(el2, value) {
18663
19277
  }
18664
19278
 
18665
19279
  // src/version.ts
18666
- var VERSION = "0.41.3";
19280
+ var VERSION = "0.43.0";
18667
19281
  export {
18668
19282
  ATTACHMENT_EVENT,
18669
19283
  AgUiChat,