@artooi/ag-ui-web-component 0.41.2 → 0.42.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.
@@ -556,6 +556,13 @@ export class AgUiChat extends HTMLElement {
556
556
  readonly #queuedRow: HTMLDivElement = document.createElement("div");
557
557
  /** How far back the composer has been walked; null while the user is typing. */
558
558
  #recallIndex: number | null = null;
559
+ /**
560
+ * What the walk last put in the composer, read back from it. A walk is live
561
+ * only while the composer still holds exactly this, so a write from anywhere
562
+ * else ends it. Stale while no walk is in progress, which is harmless: the
563
+ * comparison can only ever end a walk, never start one.
564
+ */
565
+ #recalled = "";
559
566
 
560
567
  /**
561
568
  * Re-clamp the dragged launcher when the window changes size. Bound once as
@@ -605,6 +612,39 @@ export class AgUiChat extends HTMLElement {
605
612
  * history replay would draw the conversation a second time beneath it.
606
613
  */
607
614
  #connectedBefore = false;
615
+ /**
616
+ * The `user-key` whose state the element holds -- the transcript, the recall
617
+ * history, the Always allow waivers and the shared state -- or `null` before
618
+ * the first connect, when it holds nobody's.
619
+ *
620
+ * Not the same thing as the attribute. The attribute names the principal the
621
+ * host says is here now, and it can change while the element is out of the
622
+ * document, where nothing acts on it; this is who the state still belongs
623
+ * to. Connecting compares the two, which is how a key changed while detached
624
+ * still gets the handover a live change gets.
625
+ */
626
+ #principalKey: string | null = null;
627
+ /**
628
+ * The current principal's tenure: replaced on every handover from one
629
+ * principal to another, and captured by each client as it is built, so that
630
+ * a client can tell whether the principal it was built for is still the one
631
+ * this element serves.
632
+ *
633
+ * A client outlives the handover that stops it. A stopped run saves what it
634
+ * had once its request closes, a tool handler cannot be aborted and its
635
+ * result is kept, and an event already read off the wire is still applied --
636
+ * all of it after the store was purged and scoped to whoever arrived, so
637
+ * each one wrote the previous principal's words into the next one's storage
638
+ * or state. Writing into the previous principal's store instead would only
639
+ * re-create what the purge just removed, so such a client writes nothing.
640
+ *
641
+ * An identity rather than the key itself, for two reasons. The key's first
642
+ * arrival is an adoption, not a handover, and a run in flight across it is
643
+ * the same person's and keeps saving. And a key that leaves and comes back
644
+ * is a second tenure: the first one's stopped run must not re-create a
645
+ * conversation the handover purged.
646
+ */
647
+ #tenure: object = {};
608
648
  /**
609
649
  * The remote store `data-threads-url` wrapped around the conversation store,
610
650
  * and the store inside it, so connecting again can wrap that store rather
@@ -624,6 +664,33 @@ export class AgUiChat extends HTMLElement {
624
664
  // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
625
665
  // rounds, but the user must still be able to stop there.
626
666
  #running = false;
667
+ /**
668
+ * The send {@link sendMessage} has taken, or the retry {@link retryLastTurn}
669
+ * has, whose run has not started yet; null otherwise.
670
+ *
671
+ * `running` turns on when the client starts the run, a microtask after it is
672
+ * asked for one, and a send does two things before it asks: it draws the
673
+ * turn, and it dispatches the submit event, whose listeners run in the middle
674
+ * of the send. A send from one of them, or a second call in the same task,
675
+ * passed every guard and started a second run on the same client -- two
676
+ * requests, and the listener's turn drawn between the user's turn and its
677
+ * answer. It is the gap a picked checkpoint closes by counting from the pick,
678
+ * closed the same way for a send. A retry asks for its run the same way, so
679
+ * it takes the same hold, and so does a restore that will resume a run, from
680
+ * before it asks the store for the conversation, through the history host's
681
+ * `holdRun`.
682
+ *
683
+ * Let go of once the run starts, because `running` holds from then until the
684
+ * run settles, and a send held past that would refuse a host's follow-up from
685
+ * the run-finished event, which fires as the run settles. Let go of by the
686
+ * send's, the retry's or the restore's end when its run never starts.
687
+ *
688
+ * An object rather than a flag, so a send or a retry ending releases only
689
+ * what it took. By the time either ends, the turn queued behind it has
690
+ * usually taken the field for itself, and a Stop lets go of it for the next
691
+ * send to take; the earlier one ending must release neither.
692
+ */
693
+ #sending: object | null = null;
627
694
  /** The decision a run is suspended on, which a Stop abandons. */
628
695
  readonly #decision = new PendingDecision();
629
696
  /**
@@ -715,6 +782,9 @@ export class AgUiChat extends HTMLElement {
715
782
  getContext: () => this.getContext(),
716
783
  conversationStore: () => this.conversationStore,
717
784
  threadId: () => this.#history.threadId,
785
+ // Read live and compared, the way each client compares the one it was
786
+ // built with: see #tenure.
787
+ tenure: () => this.#tenure,
718
788
  });
719
789
  this.#runHandlers = new RunHandlers({
720
790
  element: this,
@@ -860,6 +930,10 @@ export class AgUiChat extends HTMLElement {
860
930
  headersFor: (url) => this.#headersFor(url),
861
931
  requestCredentials: () => this.#requestCredentials(),
862
932
  appendMessage: (role, content) => this.appendMessage(role, content),
933
+ // A continuation sends only what the composer holds. The tray is not
934
+ // read, so nothing rides along to be drawn or announced.
935
+ announceTurn: (content) => this.#announceTurn(content, []),
936
+ recordTurn: (content) => this.#recordTurn(content),
863
937
  autoGrow: () => autoGrow(this.#input),
864
938
  continuationEnded: () => this.#flushQueued(),
865
939
  client: () => this.#client,
@@ -868,7 +942,37 @@ export class AgUiChat extends HTMLElement {
868
942
  releaseClient: () => {
869
943
  this.#client = null;
870
944
  },
871
- running: () => this.#running,
945
+ // A held send or retry is as much in flight to a pick as a run is: it has
946
+ // asked this element's client for a run that has not started, and a
947
+ // continuation started beside it was a second run in one conversation.
948
+ // So is a restore that will resume a run, which holds through the same
949
+ // field from before the store answers.
950
+ running: () => this.#running || this.#sending !== null,
951
+ // The hold a send takes, taken for a restore that will resume a run, so
952
+ // what refuses a send held short of its run refuses one made while the
953
+ // store is still answering: `sendMessage` returns, Send parks the turn,
954
+ // a Retry and a pick refuse. The same field rather than a flag beside it,
955
+ // because what lets go of a send's hold is right for this one too. The
956
+ // resumed run starting lets go, in `#setRunning`, so there is no gap
957
+ // after the resume is asked for, and a Stop does, in `#cancelRun`. A flag
958
+ // the restore cleared itself would still be set when the resumed run
959
+ // settles and sends the parked turn, so that send would be refused after
960
+ // the turn had left the queue, and lost.
961
+ //
962
+ // Whether the restore was stopped is not read off this hold, because one
963
+ // with nothing to resume takes none; the restore asks its own generation.
964
+ holdRun: () => {
965
+ const hold = {};
966
+ this.#sending = hold;
967
+ return {
968
+ release: () => {
969
+ if (this.#sending === hold) {
970
+ this.#sending = null;
971
+ this.#flushQueued();
972
+ }
973
+ },
974
+ };
975
+ },
872
976
  cancelRun: () => this.#cancelRun(),
873
977
  resetState: () => this.#resetState(),
874
978
  setRunning: (running) => this.#setRunning(running),
@@ -938,8 +1042,11 @@ export class AgUiChat extends HTMLElement {
938
1042
  return;
939
1043
  }
940
1044
  if (name === "user-key") {
941
- // Before connect there is nothing to move: connectedCallback resolves the
942
- // namespace from the attribute as it stands by then. An absent attribute
1045
+ // Acted on here only while connected. Before the first connect there is
1046
+ // nothing to move: connectedCallback resolves the namespace from the
1047
+ // attribute as it stands by then. Between a disconnect and the next
1048
+ // connect there is, and connecting hands it over -- see #principalKey
1049
+ // and connectedCallback for why it waits until then. An absent attribute
943
1050
  // and an empty one name the same (unnamed) principal, so neither is a
944
1051
  // change worth acting on.
945
1052
  if (this.#connected && (previous ?? "") !== (value ?? "")) {
@@ -1235,7 +1342,8 @@ export class AgUiChat extends HTMLElement {
1235
1342
  // run was cancelled on the way out, and the transcript is rebuilt below
1236
1343
  // from persisted history -- which could only be appended to what is still
1237
1344
  // showing. The composer's recall history is kept, because a move is not a
1238
- // farewell and disconnecting put the queued messages there on purpose.
1345
+ // farewell, and the Stop on the way out dropped the queued messages because
1346
+ // recall already held them.
1239
1347
  if (this.#connectedBefore) {
1240
1348
  this.#resetConversation();
1241
1349
  }
@@ -1280,6 +1388,33 @@ export class AgUiChat extends HTMLElement {
1280
1388
  }
1281
1389
  this.#syncLauncher();
1282
1390
  this.#skills.init();
1391
+ // A `user-key` that changed while the element was out of the document gets
1392
+ // the handover a live change gets, here, because this is the first moment
1393
+ // the element can make it. A plain move with the same key skips this and
1394
+ // keeps the recall history, as above; a first connect skips it too, since
1395
+ // an element that has never connected holds nobody's state -- and treating
1396
+ // its first key as an arrival would adopt whatever key-less conversation
1397
+ // an earlier visitor left in the tab.
1398
+ //
1399
+ // Deferred to here rather than made the moment the attribute changes, for
1400
+ // three reasons. Out of the document, the arriving half -- replaying the
1401
+ // next principal's history, listing their threads -- is requests, and
1402
+ // nothing should go out for a node that has left (see #startup). Only where
1403
+ // the key ends up counts: one that goes away and comes back while detached
1404
+ // hands nothing over, because nobody else saw this panel in between. And
1405
+ // the namespace was claimed again just above, so the previous principal's
1406
+ // is resolved against the one this element holds now: a namespace another
1407
+ // element claimed while this one was out is never purged from under it.
1408
+ //
1409
+ // Before the store is scoped, which is the arriving half connecting does
1410
+ // anyway: an adoption has to move the conversation before the store is
1411
+ // asked which thread is active, or it mints a fresh one there first.
1412
+ const held = this.#principalKey;
1413
+ const changed = held !== null && held !== this.userKey;
1414
+ if (changed) {
1415
+ this.#changePrincipal(held, this.userKey);
1416
+ }
1417
+ this.#principalKey = this.userKey;
1283
1418
  // Namespace the built-in default store too (a host-injected store is used
1284
1419
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
1285
1420
  this.conversationStore = this.#storage.scopeStore(this.#unwrappedStore(), this.userKey);
@@ -1298,6 +1433,13 @@ export class AgUiChat extends HTMLElement {
1298
1433
  // through a framework ref still has a chance to be heard — see #startup.
1299
1434
  queueMicrotask(() => this.#startup());
1300
1435
  void this.#history.rehydrate();
1436
+ if (changed) {
1437
+ // The last of what a live change does: a drawer left open across the
1438
+ // move still lists the threads of the principal who left, titles and
1439
+ // all, until something reloads it. A plain move leaves it alone, since
1440
+ // the list it shows is still this principal's.
1441
+ void this.#history.refreshDrawer();
1442
+ }
1301
1443
  // Last: everything above reads (and some of it sets) attributes, and none
1302
1444
  // of that should trip the connect-time warning.
1303
1445
  this.#connected = true;
@@ -1678,8 +1820,22 @@ export class AgUiChat extends HTMLElement {
1678
1820
  * logout, because a logout is a navigation (or, in a single-page app, not
1679
1821
  * even that) rather than a tab close. Nothing remounts, so the host naming
1680
1822
  * the new principal is the only signal the element will ever get.
1823
+ *
1824
+ * Two callers. A live change runs all of it. connectedCallback runs it for a
1825
+ * key that changed while the element was out of the document, and there it
1826
+ * runs before the store is scoped and before `#connected` is set -- which is
1827
+ * what `live` reads, since `isConnected` is already true by then. That call
1828
+ * does the half about what storage and the element still hold. The half
1829
+ * about the principal arriving -- the store scoped under the new key, its
1830
+ * active thread, the replay -- is what connecting goes on to do anyway, and
1831
+ * run here too it would read the old store before the new one exists,
1832
+ * minting a thread pointer in the namespace just purged and replaying the
1833
+ * history twice. Nor would it leave alone a store the host assigned while
1834
+ * the element was out.
1681
1835
  */
1682
1836
  #changePrincipal(previousKey: string, nextKey: string): void {
1837
+ this.#principalKey = nextKey;
1838
+ const live = this.#connected;
1683
1839
  const previous = this.#storage.conversationNamespace(previousKey);
1684
1840
  const next = this.#storage.conversationNamespace(nextKey);
1685
1841
  if (previousKey === "") {
@@ -1690,11 +1846,18 @@ export class AgUiChat extends HTMLElement {
1690
1846
  // copying also matters: a copy left behind under the unscoped namespace
1691
1847
  // is a transcript the next key-less mount would happily adopt.
1692
1848
  SessionStorageStore.adopt(previous, next);
1693
- this.#rescopeStore(next);
1849
+ if (live) {
1850
+ this.#rescopeStore(next);
1851
+ }
1694
1852
  return;
1695
1853
  }
1854
+ // First, ahead of the purge: from here on, nothing a client built for the
1855
+ // previous principal does is written anywhere. See #tenure.
1856
+ this.#tenure = {};
1696
1857
  SessionStorageStore.purge(previous);
1697
- this.#rescopeStore(next);
1858
+ if (live) {
1859
+ this.#rescopeStore(next);
1860
+ }
1698
1861
  // An Always allow is the previous principal's decision too, and it lives in
1699
1862
  // memory rather than in the store just purged, so it has to be forgotten
1700
1863
  // here or it outlives them. The adoption above keeps it, for the same
@@ -1712,8 +1875,29 @@ export class AgUiChat extends HTMLElement {
1712
1875
  // the new principal's first run. A host that seeds state assigns it again
1713
1876
  // for the one who arrived.
1714
1877
  this.#sharedState = {};
1878
+ // And the turn being written, also only here. A turn typed and not sent is
1879
+ // the previous principal's words as much as the transcript is, and so is
1880
+ // everything else that reaches the box: a quotation and a skill's template
1881
+ // are both text in it. The tray went with the transcript above. New chat
1882
+ // keeps the text, because the same person is still typing, and an adoption
1883
+ // returned before any of this for the same reason. Through the input
1884
+ // handler rather than beside it, so the hint and the slash palette that
1885
+ // were answering that text come down with it.
1886
+ this.#input.value = "";
1887
+ this.#onInput();
1715
1888
  this.#setRunning(false);
1716
1889
  this.#setUnread(0);
1890
+ if (!live) {
1891
+ return;
1892
+ }
1893
+ // A recording in progress is theirs too, and once stopped it would be
1894
+ // transcribed into the next principal's composer. Taking the mic down ends
1895
+ // it untranscribed, and putting a fresh one up leaves the next principal
1896
+ // a working control -- the way leaving and connecting do it, which is also
1897
+ // why only a live change does it here: a detached one has been through
1898
+ // both already.
1899
+ this.#voice.dispose();
1900
+ this.#voice.wire();
1717
1901
  this.#history.adoptActiveThread();
1718
1902
  void this.#history.rehydrate();
1719
1903
  void this.#history.refreshDrawer();
@@ -1723,13 +1907,17 @@ export class AgUiChat extends HTMLElement {
1723
1907
  * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
1724
1908
  * `data-threads-url` exactly as connecting did.
1725
1909
  *
1726
- * A store of the host's own kind is left alone: a store that holds its data
1727
- * somewhere the element cannot see has to scope itself. The transcript on
1910
+ * A store the host assigned is left alone, whether before connecting or
1911
+ * since: a store that holds its data somewhere the element cannot see has to
1912
+ * scope itself. The exception is a plain `SessionStorageStore` assigned
1913
+ * before connecting, which connecting cannot tell from the default and so
1914
+ * took for the element's own. Hence the unwrapped store in use is what the scope is asked
1915
+ * about, rather than what it remembered when connecting. The transcript on
1728
1916
  * screen is still cleared either way — the host swapped principals, and that
1729
1917
  * much is the element's to act on.
1730
1918
  */
1731
1919
  #rescopeStore(namespace: string): void {
1732
- const store = this.#storage.rescopeStore(namespace);
1920
+ const store = this.#storage.rescopeStore(this.#unwrappedStore(), namespace);
1733
1921
  if (store === null) {
1734
1922
  return;
1735
1923
  }
@@ -1897,8 +2085,17 @@ export class AgUiChat extends HTMLElement {
1897
2085
  *
1898
2086
  * History is truncated to the most recent user message inclusive and the run
1899
2087
  * repeats, so the agent answers what it was asked rather than being told its
1900
- * last answer was wrong. Returns `false` when there is nothing to retry or a
1901
- * run is already in flight.
2088
+ * last answer was wrong. Returns `false` when there is nothing to retry, or
2089
+ * while anything that refuses a send is in flight: a run, a send or another
2090
+ * retry whose run has not started yet, a checkpoint continuation, or a
2091
+ * reload's restore that will resume a run. Also `false` when host code it
2092
+ * runs before its run starts -- the store's save, or an activity or tool
2093
+ * renderer the replay draws through -- has stopped it by starting a new chat
2094
+ * or changing `user-key`. A tool or context provider that stops it does so
2095
+ * inside the client's resume, past the last point this can see: nothing is
2096
+ * sent, and this resolves `true`, because both a run starting and a Stop let
2097
+ * go of the hold it checks. Rejects with the store's error when the store
2098
+ * refuses the save, and then the conversation is as it was.
1902
2099
  *
1903
2100
  * Public because a host with its own message UI wants the same button, and
1904
2101
  * because the failed-run notice reaches it from outside the action row.
@@ -1909,24 +2106,63 @@ export class AgUiChat extends HTMLElement {
1909
2106
  * again -- unless the user waived it for this session.
1910
2107
  */
1911
2108
  async retryLastTurn(): Promise<boolean> {
1912
- if (this.#running) {
2109
+ // Refused for whatever refuses a send, because it starts a run on the same
2110
+ // client. That includes a send held short of its run, which `running` does
2111
+ // not see yet, and a picked checkpoint, which runs on a client of its own
2112
+ // and leaves this one idle -- so a Retry beside it was a second run in the
2113
+ // one conversation, each saving over the other.
2114
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
1913
2115
  return false;
1914
2116
  }
1915
- const client = this.#ensureClient();
1916
- const kept = client.truncateToLastUser();
1917
- if (kept === null) {
1918
- return false;
1919
- }
1920
- // Re-render between the truncation and the run: the kept turns replay as
1921
- // restored history (static, no entrance animation), and only the new answer
1922
- // arrives live. Streaming into the old transcript would put the new answer
1923
- // underneath the one it replaces.
1924
- this.#clearTranscript();
1925
- for (const message of kept) {
1926
- this.#history.replay(message);
2117
+ // Held from here as a send is, and for the same reason: the client starts
2118
+ // the run a microtask after it is asked, and nothing above sees it until
2119
+ // then. A send in that time went out as a second run on the same client,
2120
+ // the built-in Send sent rather than queued, and a pick started a
2121
+ // continuation beside it. Released once the run starts, by `#setRunning`,
2122
+ // and below when it never does.
2123
+ const hold = {};
2124
+ this.#sending = hold;
2125
+ try {
2126
+ const client = this.#ensureClient();
2127
+ const kept = client.truncateToLastUser();
2128
+ if (kept === null) {
2129
+ return false;
2130
+ }
2131
+ // The truncation saved through the host's store, so host code has run
2132
+ // with the Retry under way, and a store that started a new chat or
2133
+ // changed `user-key` from there has stopped it. Going on drew the
2134
+ // abandoned conversation into the one that replaced it and asked its
2135
+ // answer again on a client nothing holds.
2136
+ if (this.#sending !== hold) {
2137
+ return false;
2138
+ }
2139
+ // Re-render between the truncation and the run: the kept turns replay as
2140
+ // restored history (static, no entrance animation), and only the new
2141
+ // answer arrives live. Streaming into the old transcript would put the
2142
+ // new answer underneath the one it replaces.
2143
+ this.#clearTranscript();
2144
+ for (const message of kept) {
2145
+ this.#history.replay(message);
2146
+ // A replay draws through the host's activity and tool renderers, which
2147
+ // can stop the Retry as the store's save can. Checked after each one,
2148
+ // so the rest of the abandoned conversation is not drawn into the chat
2149
+ // that replaced it, and after the last, so its answer is not asked
2150
+ // again.
2151
+ if (this.#sending !== hold) {
2152
+ return false;
2153
+ }
2154
+ }
2155
+ await client.resume();
2156
+ return true;
2157
+ } finally {
2158
+ // Only its own hold, as a send's end releases only its own. A turn the
2159
+ // composer queued behind it is sent from here when its run never started,
2160
+ // because no run settles to send it.
2161
+ if (this.#sending === hold) {
2162
+ this.#sending = null;
2163
+ this.#flushQueued();
2164
+ }
1927
2165
  }
1928
- await client.resume();
1929
- return true;
1930
2166
  }
1931
2167
 
1932
2168
  /**
@@ -2329,7 +2565,9 @@ export class AgUiChat extends HTMLElement {
2329
2565
  autoGrow(this.#input);
2330
2566
  // Typing puts the composer back in the user's hands: the next ArrowUp
2331
2567
  // starts from the newest turn again rather than continuing a walk through
2332
- // history the user has since edited.
2568
+ // history the user has since edited. Needed beside the walk's own check
2569
+ // below, which compares text: an edit typed and then deleted leaves the
2570
+ // turn there unchanged, and the user has still taken the box back.
2333
2571
  this.#recallIndex = null;
2334
2572
  }
2335
2573
 
@@ -2366,6 +2604,9 @@ export class AgUiChat extends HTMLElement {
2366
2604
  * which is what every shell and every coding agent means by this. Arrowing
2367
2605
  * forward past the newest empties the composer again rather than sticking on
2368
2606
  * it, so the way out is the same key that got you in.
2607
+ *
2608
+ * A walk ends with any write into the composer that is not its own, so that
2609
+ * whatever was written is never replaced by the next step.
2369
2610
  */
2370
2611
  #recallHistory(event: KeyboardEvent): void {
2371
2612
  const back = event.key === "ArrowUp";
@@ -2376,6 +2617,17 @@ export class AgUiChat extends HTMLElement {
2376
2617
  if (drafts.length === 0) {
2377
2618
  return;
2378
2619
  }
2620
+ // The walk checks the composer rather than trusting every writer to end
2621
+ // it. Writers are spread across the element -- a quotation, a skill
2622
+ // prefill, dictation, the clears after a send -- and a walk used to end
2623
+ // only for those that also went through the input handler or
2624
+ // `#recordTurn`. A quotation and a prefill did neither, and the next
2625
+ // ArrowUp replaced them with a past turn.
2626
+ // A host page can write the composer too, which no hook of ours can see.
2627
+ // Comparing here is the one rule a writer added later cannot forget.
2628
+ if (this.#input.value !== this.#recalled) {
2629
+ this.#recallIndex = null;
2630
+ }
2379
2631
  // An empty composer is the only safe entry: anything typed is the user's,
2380
2632
  // and replacing it with a past turn would lose it without asking.
2381
2633
  if (this.#recallIndex === null && (!back || this.#input.value !== "")) {
@@ -2392,6 +2644,10 @@ export class AgUiChat extends HTMLElement {
2392
2644
  // honestly -- and an unreachable default is worse than an assertion,
2393
2645
  // because it looks like a case somebody thought about.
2394
2646
  this.#input.value = next < 0 ? "" : (drafts[next] as string);
2647
+ // Read back rather than taken from the draft, so a value the textarea
2648
+ // normalises on the way in -- its line endings -- still reads as the
2649
+ // walk's own on the next step instead of ending it.
2650
+ this.#recalled = this.#input.value;
2395
2651
  this.#input.setSelectionRange(this.#input.value.length, this.#input.value.length);
2396
2652
  autoGrow(this.#input);
2397
2653
  }
@@ -2407,24 +2663,24 @@ export class AgUiChat extends HTMLElement {
2407
2663
  // the user has just stopped is the opposite of what stopping meant, and it
2408
2664
  // would arrive after they had already turned away.
2409
2665
  //
2410
- // Not sending it is not the same as destroying it, though. A queued
2411
- // message left the composer the moment it was queued, so dropping it here
2412
- // would take a paragraph the user typed and leave it nowhere -- not on
2413
- // screen, not in the composer, not recallable. It goes to the front of the
2414
- // recall history instead, so ArrowUp gets it back. In queue order, which
2415
- // puts the one typed last first.
2666
+ // Not sending it is not the same as destroying it, though, and nothing is
2667
+ // destroyed here: a queued turn entered the recall history the moment it
2668
+ // left the composer, so ArrowUp gets it back. This used to be where it
2669
+ // entered, which left it out of reach for as long as it waited, and once
2670
+ // it was recorded on the way in, moving it again put each one in twice.
2416
2671
  //
2417
2672
  // This path is also reached from `disconnectedCallback`, where a DOM move
2418
2673
  // and a framework re-render both look like a farewell and neither is one.
2419
- for (const text of this.#queued) {
2420
- if (this.#sentDrafts[0] !== text) {
2421
- this.#sentDrafts.unshift(text);
2422
- }
2423
- }
2424
2674
  this.#queued.length = 0;
2425
2675
  this.#renderQueued();
2426
2676
  this.#decision.abort();
2427
2677
  this.#client?.cancel();
2678
+ // A send whose run has not started is ended here too, because nothing else
2679
+ // can reach it: the cancel above lands on a client that has not been asked
2680
+ // for the run yet, and a client forgets a Stop when it is next asked for one.
2681
+ // This is how a submit listener that starts a new chat, switches thread or
2682
+ // moves the element stops the send it is hearing about.
2683
+ this.#sending = null;
2428
2684
  // A checkpoint continuation is as much the run in flight -- the composer
2429
2685
  // offers Stop for it -- but it runs on a client the element does not hold.
2430
2686
  this.#history.stopContinuation();
@@ -2440,6 +2696,13 @@ export class AgUiChat extends HTMLElement {
2440
2696
  #setRunning(running: boolean): void {
2441
2697
  const settled = this.#running && !running;
2442
2698
  this.#running = running;
2699
+ // The send that asked for this run is covered from here until it settles,
2700
+ // so its hold ends now. Held to the send's end instead, it was still held
2701
+ // when the run-finished event fired, and a host's follow-up from it was
2702
+ // refused.
2703
+ if (running) {
2704
+ this.#sending = null;
2705
+ }
2443
2706
  const label = running ? this.#strings.stop : this.#strings.send;
2444
2707
  this.#send.title = label;
2445
2708
  this.#send.setAttribute("aria-label", label);
@@ -2499,6 +2762,28 @@ export class AgUiChat extends HTMLElement {
2499
2762
  }
2500
2763
  }
2501
2764
 
2765
+ /**
2766
+ * Put a turn that has left the composer at the front of the recall history,
2767
+ * and start the next walk from it.
2768
+ *
2769
+ * Every turn the user typed and pressed a key to send comes through here:
2770
+ * sent at once, queued behind a run, or sent to continue a checkpoint. A
2771
+ * host's own `sendMessage` does not, because its text was never in the
2772
+ * composer, and the arrow keys walk back through what the user wrote there.
2773
+ */
2774
+ #recordTurn(content: string): void {
2775
+ // A repeat of the last one is not a second entry: the point is to reach
2776
+ // what was said, not how often. Nothing is recorded for an attachment sent
2777
+ // with no text, which has nothing for the composer to hold.
2778
+ if (content !== "" && this.#sentDrafts[0] !== content) {
2779
+ this.#sentDrafts.unshift(content);
2780
+ }
2781
+ // A press sends without an input event, which is what ends a walk
2782
+ // otherwise. Left where it was, the next ArrowUp stepped past the turn just
2783
+ // taken, or past whichever one now sits where it had walked to.
2784
+ this.#recallIndex = null;
2785
+ }
2786
+
2502
2787
  async #submit(): Promise<void> {
2503
2788
  // Ignore a submit while a run is in flight — the single choke point for
2504
2789
  // both Enter and the Send button. The button already turns into Stop, but
@@ -2525,7 +2810,20 @@ export class AgUiChat extends HTMLElement {
2525
2810
  // an attachment is settled state the tray is holding and the composer has
2526
2811
  // no second copy of, so parking it here would mean deciding what happens
2527
2812
  // when the user then removes the chip.
2528
- if (this.#running || this.#history.continuation !== null) {
2813
+ //
2814
+ // A send counts from the moment it is taken, for the same reason a pick
2815
+ // does. A second Send in the same task as the first -- a host clicking it
2816
+ // from code -- reaches here before the first's run has started, and
2817
+ // `sendMessage` refuses it then, so without this the box would be cleared
2818
+ // of a turn that went nowhere.
2819
+ //
2820
+ // Recorded for recall before it is queued or sent, because it leaves the
2821
+ // box either way. A turn queued here is the newest thing the user typed,
2822
+ // and it is still the newest while it waits; the queue later sends it
2823
+ // through `sendMessage`, which records nothing, so this is the only point
2824
+ // that sees it as the composer's.
2825
+ this.#recordTurn(content);
2826
+ if (this.#running || this.#sending !== null || this.#history.continuation !== null) {
2529
2827
  if (content !== "") {
2530
2828
  this.#queued.push(content);
2531
2829
  this.#renderQueued();
@@ -2534,13 +2832,6 @@ export class AgUiChat extends HTMLElement {
2534
2832
  }
2535
2833
  return;
2536
2834
  }
2537
- // Recorded before the box is cleared, newest first, so the arrow keys walk
2538
- // back through it. A repeat of the last one is not a second entry: the
2539
- // point is to reach what was said, not how often.
2540
- if (content !== "" && this.#sentDrafts[0] !== content) {
2541
- this.#sentDrafts.unshift(content);
2542
- }
2543
- this.#recallIndex = null;
2544
2835
  this.#input.value = "";
2545
2836
  autoGrow(this.#input);
2546
2837
  // A file still uploading does not ride along — `readyRefs()` returns only
@@ -2577,27 +2868,73 @@ export class AgUiChat extends HTMLElement {
2577
2868
  *
2578
2869
  * No-ops on an empty message, and while a run or a picked checkpoint's
2579
2870
  * continuation is in flight, since a second concurrent run would orphan the
2580
- * first. Unlike the built-in Send it does not queue: it returns, and the
2581
- * caller keeps what it tried to send. Nor does it consult the tray -- what
2582
- * you pass is what is sent.
2871
+ * first. A send is in flight from the moment it is taken rather than from
2872
+ * its run's start, so this no-ops from a {@link SUBMIT_EVENT} listener and
2873
+ * for a second call in the same task too.
2874
+ * Unlike the built-in Send it does not queue: it returns, and the caller
2875
+ * keeps what it tried to send. Nor does it consult the tray -- what you pass
2876
+ * is what is sent.
2583
2877
  */
2584
2878
  async sendMessage(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
2585
2879
  if (
2586
2880
  this.#running ||
2881
+ this.#sending !== null ||
2587
2882
  this.#history.continuation !== null ||
2588
2883
  (content === "" && attachments.length === 0)
2589
2884
  ) {
2590
2885
  return;
2591
2886
  }
2592
- // Only a send travels. Every other way out of the empty state -- a restored
2593
- // transcript, a thread picked from the drawer, a resumed checkpoint -- is a
2594
- // change of context rather than a continuation of what the user was doing,
2595
- // and snaps. Armed before the bubble lands so both writes reach the same
2596
- // style recalculation, which is what makes the change a transition rather
2597
- // than a jump.
2598
- if (this.#transcript.isEmpty()) {
2599
- this.setAttribute("data-composer-settling", "");
2887
+ const send = {};
2888
+ this.#sending = send;
2889
+ try {
2890
+ // Only a send travels. Every other way out of the empty state -- a
2891
+ // restored transcript, a thread picked from the drawer, a resumed
2892
+ // checkpoint -- is a change of context rather than a continuation of what
2893
+ // the user was doing, and snaps. Armed before the bubble lands so both
2894
+ // writes reach the same style recalculation, which is what makes the
2895
+ // change a transition rather than a jump.
2896
+ if (this.#transcript.isEmpty()) {
2897
+ this.setAttribute("data-composer-settling", "");
2898
+ }
2899
+ // Taken before the announcement, because its listeners run here: a host's
2900
+ // own send from one is refused as overlapping this one, as it would be
2901
+ // from any later moment of it.
2902
+ this.#announceTurn(content, attachments);
2903
+ // A listener can also have stopped it -- New chat, a thread switch, a
2904
+ // change of principal, moving the element. All of them reach the Stop,
2905
+ // which let go of this send, and the turn would otherwise go out into the
2906
+ // conversation that replaced it, or from an element nothing can stop.
2907
+ if (this.#sending !== send) {
2908
+ return;
2909
+ }
2910
+ await this.#client_send(content, attachments);
2911
+ } finally {
2912
+ // Still held here only if its run never started: no endpoint, or a
2913
+ // client or store that threw before asking for one. No run settles
2914
+ // then, so this is what sends a turn queued behind it. Not released if
2915
+ // it is no longer this send's: the run starting let go of it, or a Stop
2916
+ // did, and the field may hold the next send by now.
2917
+ if (this.#sending === send) {
2918
+ this.#sending = null;
2919
+ this.#flushQueued();
2920
+ }
2600
2921
  }
2922
+ }
2923
+
2924
+ /**
2925
+ * Put a turn the user sent on screen, and tell the host it was sent.
2926
+ *
2927
+ * The half of a send that does not depend on where it goes, so both senders
2928
+ * take it from here: {@link sendMessage}, and a checkpoint continuation, which
2929
+ * posts to the resume or fork endpoint on a client of its own. The
2930
+ * continuation used to call that client directly, so the turn went out and
2931
+ * its answer streamed in under no question -- while the save it made held the
2932
+ * turn, and a reload showed what the live transcript never had.
2933
+ *
2934
+ * Arming the composer's travel stays with {@link sendMessage}: continuing a
2935
+ * run is a change of context, and docks at once.
2936
+ */
2937
+ #announceTurn(content: string, attachments: readonly AttachmentRef[]): void {
2601
2938
  const bubble = this.appendMessage(MESSAGE_ROLE.USER, content);
2602
2939
  if (attachments.length > 0) {
2603
2940
  bubble.appendChild(renderAttachmentChips(attachments));
@@ -2609,7 +2946,6 @@ export class AgUiChat extends HTMLElement {
2609
2946
  composed: true,
2610
2947
  }),
2611
2948
  );
2612
- await this.#client_send(content, attachments);
2613
2949
  }
2614
2950
 
2615
2951
  /**
@@ -2694,6 +3030,10 @@ export class AgUiChat extends HTMLElement {
2694
3030
  // closes -- and read live, that save landed after New chat had moved the
2695
3031
  // active thread on, filing the conversation being left under the new one.
2696
3032
  const threadId = this.#history.threadId;
3033
+ // Fixed for the same reason, and checked by every write below: see #tenure.
3034
+ // The thread id alone cannot do it, because the store is still read live --
3035
+ // it has to be, since an adoption rescopes it under a run that keeps going.
3036
+ const tenure = this.#tenure;
2697
3037
  const agent = this.agentFactory({
2698
3038
  endpoint: seed.endpoint,
2699
3039
  headers: this.#requestHeaders(),
@@ -2718,12 +3058,30 @@ export class AgUiChat extends HTMLElement {
2718
3058
  // list per thread. For the conversation's own client that is its
2719
3059
  // history; a continuation holds only what it adds, and writes the
2720
3060
  // conversation it continues ahead of that.
3061
+ //
3062
+ // Every message list a client saves reaches the store through here -- a
3063
+ // send, a round's end, a tool's result, the answers it writes for calls
3064
+ // left open or declined by a Stop, a Stop's last save -- and so does a
3065
+ // `data-threads-url` store's local copy, which is the only thing that
3066
+ // wrapper does on a save. So this is the one gate for them. `onSaved` is
3067
+ // behind it as well: it hands the saved conversation on as the seed of
3068
+ // the next client, which would be the next principal's.
2721
3069
  onPersist: (messages) => {
3070
+ if (tenure !== this.#tenure) {
3071
+ return;
3072
+ }
2722
3073
  const conversation = [...seed.follows, ...messages];
2723
3074
  this.conversationStore.saveMessages(threadId, conversation);
2724
3075
  seed.onSaved?.(conversation);
2725
3076
  },
2726
- onStateChanged: (state) => this.#onSharedStateChanged(state),
3077
+ // Shared state is the next principal's first `RunAgentInput.state`, and
3078
+ // the handover has just emptied it. A snapshot already read off the wire
3079
+ // is applied after the Stop, and would put it back.
3080
+ onStateChanged: (state) => {
3081
+ if (tenure === this.#tenure) {
3082
+ this.#onSharedStateChanged(state);
3083
+ }
3084
+ },
2727
3085
  connectionLostMessage: this.#strings.connectionLost,
2728
3086
  unfinishedMessage: this.#strings.callNotFinished,
2729
3087
  declinedMessage: this.#strings.declinedAction,