@artooi/ag-ui-web-component 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +155 -1
  2. package/README.md +70 -12
  3. package/dist/ag-ui-web-component.bundle.js +168 -45
  4. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  5. package/dist/core/ag_ui_chat.d.ts +7 -3
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +12 -0
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/client_seed.d.ts +14 -2
  10. package/dist/core/client_seed.d.ts.map +1 -1
  11. package/dist/index.js +636 -231
  12. package/dist/index.js.map +2 -2
  13. package/dist/tools/tool_catalog.d.ts.map +1 -1
  14. package/dist/ui/composer/composer_attachments.d.ts +6 -1
  15. package/dist/ui/composer/composer_attachments.d.ts.map +1 -1
  16. package/dist/ui/composer/composer_voice.d.ts +3 -0
  17. package/dist/ui/composer/composer_voice.d.ts.map +1 -1
  18. package/dist/ui/excerpts/transcript_quote_offer.d.ts +7 -2
  19. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -1
  20. package/dist/ui/history/conversation_history.d.ts +29 -3
  21. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  22. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  23. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  24. package/dist/ui/placement/panel_placement.d.ts +1 -1
  25. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  26. package/dist/ui/styles.d.ts +1 -1
  27. package/dist/ui/styles.d.ts.map +1 -1
  28. package/dist/ui/transcript/transcript.d.ts +3 -2
  29. package/dist/ui/transcript/transcript.d.ts.map +1 -1
  30. package/dist/ui/ui_strings.d.ts +2 -0
  31. package/dist/ui/ui_strings.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/core/ag_ui_chat.ts +217 -64
  34. package/src/core/agui_client.ts +26 -14
  35. package/src/core/client_seed.ts +14 -2
  36. package/src/tools/tool_catalog.ts +63 -12
  37. package/src/ui/composer/composer_attachments.ts +64 -41
  38. package/src/ui/composer/composer_voice.ts +5 -0
  39. package/src/ui/excerpts/transcript_quote_offer.ts +26 -13
  40. package/src/ui/history/conversation_history.ts +125 -17
  41. package/src/ui/placement/launcher_drag.ts +104 -89
  42. package/src/ui/placement/panel_placement.ts +27 -3
  43. package/src/ui/styles.ts +137 -14
  44. package/src/ui/transcript/transcript.ts +10 -5
  45. package/src/ui/ui_strings.ts +3 -0
  46. package/src/version.ts +1 -1
package/dist/index.js CHANGED
@@ -982,6 +982,7 @@ var DEFAULT_UI_STRINGS = {
982
982
  attachmentsStillUploading: "{n} file still uploading \u2014 it was not sent with this message and is still attached.",
983
983
  notConnected: "This chat isn\u2019t connected to an agent, so the message wasn\u2019t sent.",
984
984
  continueNeedsTurn: "Type the next turn in the composer first, then pick a run to continue.",
985
+ continueWhileRunning: "Wait for the current answer or stop it, then pick a run to continue.",
985
986
  skillNeeds: "\u201C{title}\u201D needs {fields} \u2014 fill it in below, then send.",
986
987
  message: "Message",
987
988
  inputPlaceholder: "Ask anything\u2026",
@@ -1418,12 +1419,12 @@ var ToolCatalog = class {
1418
1419
  },
1419
1420
  required: ["question"]
1420
1421
  },
1421
- handler: (args) => this.#askUser(args)
1422
+ handler: (args, callId) => this.#askUser(args, callId)
1422
1423
  }
1423
1424
  ];
1424
1425
  }
1425
1426
  /** Render the `ask_user` question card and resolve with the user's answer. */
1426
- async #askUser(args) {
1427
+ async #askUser(args, callId) {
1427
1428
  const question = typeof args["question"] === "string" ? args["question"] : "";
1428
1429
  const request = { question };
1429
1430
  const rawOptions = args["options"];
@@ -1435,19 +1436,59 @@ var ToolCatalog = class {
1435
1436
  }
1436
1437
  const signal = this.#host.decision.open();
1437
1438
  this.#host.hidePending();
1438
- const renderer = this.#host.askUserRenderer();
1439
- const answer = renderer !== null ? (
1440
- // Called on the element, as `this.askUserRenderer(...)` always was.
1441
- await renderer.call(this.#host.element, request, { signal })
1442
- ) : await requestQuestion(this.#host.ensureGroup(), request, {
1439
+ const builtIn = () => requestQuestion(this.#host.ensureGroup(), request, {
1443
1440
  signal,
1444
1441
  strings: this.#host.strings()
1445
1442
  });
1443
+ const renderer = this.#host.askUserRenderer();
1444
+ let answer;
1445
+ if (renderer === null) {
1446
+ answer = await builtIn();
1447
+ } else {
1448
+ try {
1449
+ answer = await renderer.call(this.#host.element, request, { signal });
1450
+ } catch (error) {
1451
+ answer = await this.#afterRendererFailed(error, signal, callId, builtIn);
1452
+ }
1453
+ }
1446
1454
  this.#host.decision.close();
1447
1455
  this.#host.updateEmptyState();
1448
1456
  this.#host.follow();
1449
1457
  return answer;
1450
1458
  }
1459
+ /**
1460
+ * Answer an `ask_user` call whose host renderer threw or rejected instead of
1461
+ * answering: put the question to the built-in card.
1462
+ *
1463
+ * The same answer `approvalRenderer` gets for the same failure, and for the
1464
+ * same reason: a renderer is presentation, not a policy. It decides how the
1465
+ * question looks, never whether the agent's question is put to the user.
1466
+ * Uncaught, the throw escaped the handler, so the call's card settled as an
1467
+ * error quoting the host's message, that message went on to the agent as the
1468
+ * tool result -- a detail of the host's page, never written for the model --
1469
+ * and the pending decision was never closed, so the next Stop, in whatever
1470
+ * round, aborted the signal of a wait that had already ended rather than
1471
+ * finding nothing open. The built-in card still asks,
1472
+ * and the run carries on as if no renderer had been set. Reported the way a
1473
+ * failed `render` is, and for the same reason: survived is not the same as
1474
+ * findable.
1475
+ *
1476
+ * Except when the wait was already abandoned. A renderer honouring its signal
1477
+ * rejects once a Stop fires it, which is the signal working rather than the
1478
+ * renderer failing, and a card drawn then would ask about a run the user just
1479
+ * ended. So it resolves with the empty answer the built-in card resolves with
1480
+ * on the same abort, and says nothing.
1481
+ */
1482
+ #afterRendererFailed(error, signal, callId, builtIn) {
1483
+ if (signal.aborted) {
1484
+ return Promise.resolve("");
1485
+ }
1486
+ console.warn(
1487
+ `ag-ui-chat: askUserRenderer failed for tool call ${callId}, so the built-in question card asks instead`,
1488
+ error
1489
+ );
1490
+ return builtIn();
1491
+ }
1451
1492
  };
1452
1493
 
1453
1494
  // src/skills/skill_name_from.ts
@@ -2711,8 +2752,15 @@ var ComposerAttachments = class {
2711
2752
  * built-in multipart endpoint: reveal the 📎 button, wire the hidden file
2712
2753
  * input + drag-and-drop, and mount the tray. With neither, the affordance
2713
2754
  * stays hidden and the chat degrades to text-only.
2755
+ *
2756
+ * Called on every connect, so it starts by taking down the tray the last
2757
+ * connection mounted: that one was disposed when the element left, and the
2758
+ * attributes that decide whether there is a tray at all may have changed
2759
+ * since. The shell outlives a connection, so its listeners go under `signal`.
2714
2760
  */
2715
- wire() {
2761
+ wire(signal) {
2762
+ this.#tray?.element.remove();
2763
+ this.#tray = null;
2716
2764
  const url = this.#host.element.getAttribute("data-attachments-url");
2717
2765
  const upload = this.#host.uploadHandler() ?? this.#defaultUploadHandler(url);
2718
2766
  if (upload === null) {
@@ -2734,8 +2782,8 @@ var ComposerAttachments = class {
2734
2782
  this.#host.slot.appendChild(this.#tray.element);
2735
2783
  this.#host.fileInput.accept = accept;
2736
2784
  this.#host.button.hidden = false;
2737
- this.#enableDragAndDrop();
2738
- this.#enablePaste(tray);
2785
+ this.#enableDragAndDrop(signal);
2786
+ this.#enablePaste(tray, signal);
2739
2787
  }
2740
2788
  /** The queueing behind `AgUiChat.attachFile`, whose doc is the contract. */
2741
2789
  attach(file) {
@@ -2779,25 +2827,37 @@ var ComposerAttachments = class {
2779
2827
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_ATTACHMENT_MAX_BYTES;
2780
2828
  }
2781
2829
  /** Accept files dropped anywhere on the chat shell into the tray. */
2782
- #enableDragAndDrop() {
2830
+ #enableDragAndDrop(signal) {
2783
2831
  const chat = this.#host.chat;
2784
- chat.addEventListener("dragover", (event) => {
2785
- event.preventDefault();
2786
- chat.classList.add("chat--dragover");
2787
- });
2788
- chat.addEventListener("dragleave", () => {
2789
- chat.classList.remove("chat--dragover");
2790
- });
2791
- chat.addEventListener("drop", (event) => {
2792
- event.preventDefault();
2793
- chat.classList.remove("chat--dragover");
2794
- const files = event.dataTransfer?.files;
2795
- if (files !== void 0) {
2796
- for (const file of Array.from(files)) {
2797
- this.#tray?.add(file);
2832
+ chat.addEventListener(
2833
+ "dragover",
2834
+ (event) => {
2835
+ event.preventDefault();
2836
+ chat.classList.add("chat--dragover");
2837
+ },
2838
+ { signal }
2839
+ );
2840
+ chat.addEventListener(
2841
+ "dragleave",
2842
+ () => {
2843
+ chat.classList.remove("chat--dragover");
2844
+ },
2845
+ { signal }
2846
+ );
2847
+ chat.addEventListener(
2848
+ "drop",
2849
+ (event) => {
2850
+ event.preventDefault();
2851
+ chat.classList.remove("chat--dragover");
2852
+ const files = event.dataTransfer?.files;
2853
+ if (files !== void 0) {
2854
+ for (const file of Array.from(files)) {
2855
+ this.#tray?.add(file);
2856
+ }
2798
2857
  }
2799
- }
2800
- });
2858
+ },
2859
+ { signal }
2860
+ );
2801
2861
  }
2802
2862
  /**
2803
2863
  * Turn a very long text paste into an attachment instead of a wall of text.
@@ -2867,24 +2927,28 @@ var ComposerAttachments = class {
2867
2927
  * clipboard, and swallowing the words someone meant to paste in order to
2868
2928
  * attach a picture they did not is the worse of the two failures.
2869
2929
  */
2870
- #enablePaste(tray) {
2871
- this.#host.chat.addEventListener("paste", (event) => {
2872
- const clipboard = event.clipboardData ?? null;
2873
- if (clipboard === null) {
2874
- return;
2875
- }
2876
- const files = Array.from(clipboard.files);
2877
- if (files.length === 0) {
2878
- this.#pasteLongTextAsFile(event, clipboard, tray);
2879
- return;
2880
- }
2881
- if (clipboard.getData("text/plain") === "") {
2882
- event.preventDefault();
2883
- }
2884
- for (const file of files) {
2885
- this.#tray?.add(named(file));
2886
- }
2887
- });
2930
+ #enablePaste(tray, signal) {
2931
+ this.#host.chat.addEventListener(
2932
+ "paste",
2933
+ (event) => {
2934
+ const clipboard = event.clipboardData ?? null;
2935
+ if (clipboard === null) {
2936
+ return;
2937
+ }
2938
+ const files = Array.from(clipboard.files);
2939
+ if (files.length === 0) {
2940
+ this.#pasteLongTextAsFile(event, clipboard, tray);
2941
+ return;
2942
+ }
2943
+ if (clipboard.getData("text/plain") === "") {
2944
+ event.preventDefault();
2945
+ }
2946
+ for (const file of files) {
2947
+ this.#tray?.add(named(file));
2948
+ }
2949
+ },
2950
+ { signal }
2951
+ );
2888
2952
  }
2889
2953
  /** Tell the host what the tray now holds — see {@link ATTACHMENT_EVENT}. */
2890
2954
  #dispatch(tray) {
@@ -3112,8 +3176,13 @@ var ComposerVoice = class {
3112
3176
  * built-in POST endpoint. The control records via `MediaRecorder` and drops
3113
3177
  * the transcript into the composer; with neither configured the mic stays
3114
3178
  * hidden and the chat is text-only.
3179
+ *
3180
+ * Called on every connect, so it starts by taking down the mic the last
3181
+ * connection mounted, which was released when the element left.
3115
3182
  */
3116
3183
  wire() {
3184
+ this.#voice?.element.remove();
3185
+ this.#voice = null;
3117
3186
  const url = this.#host.element.getAttribute("data-transcribe-url");
3118
3187
  const transcribe = this.#host.transcribeHandler() ?? this.#defaultTranscribeHandler(url);
3119
3188
  if (transcribe === null) {
@@ -3622,26 +3691,39 @@ ${quoted}`;
3622
3691
  this.#pageQuote?.detach();
3623
3692
  this.#pageQuote = null;
3624
3693
  }
3625
- /** Build the offer and listen for settled selections in the transcript. */
3626
- mount() {
3694
+ /**
3695
+ * Build the offer and listen for settled selections in the transcript.
3696
+ *
3697
+ * The button and the transcript outlive a connection, so the listeners go
3698
+ * under `signal`, which the element aborts when it leaves the document.
3699
+ */
3700
+ mount(signal) {
3627
3701
  const button2 = this.button;
3628
3702
  button2.className = "quote-selection";
3629
3703
  button2.type = "button";
3630
3704
  button2.setAttribute("part", "quote-selection");
3631
3705
  button2.textContent = this.#host.strings().quoteSelection;
3632
3706
  button2.hidden = true;
3633
- button2.addEventListener("mousedown", (event) => {
3634
- event.preventDefault();
3635
- });
3636
- button2.addEventListener("click", () => {
3637
- this.#host.quote(this.#quoting);
3638
- window.getSelection()?.removeAllRanges();
3639
- this.#hide();
3640
- });
3707
+ button2.addEventListener(
3708
+ "mousedown",
3709
+ (event) => {
3710
+ event.preventDefault();
3711
+ },
3712
+ { signal }
3713
+ );
3714
+ button2.addEventListener(
3715
+ "click",
3716
+ () => {
3717
+ this.#host.quote(this.#quoting);
3718
+ window.getSelection()?.removeAllRanges();
3719
+ this.#hide();
3720
+ },
3721
+ { signal }
3722
+ );
3641
3723
  const messages = this.#host.messages;
3642
- messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
3643
- messages.addEventListener("keyup", () => this.#onSelectionSettled());
3644
- messages.addEventListener("mousedown", () => this.#hide());
3724
+ messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event), { signal });
3725
+ messages.addEventListener("keyup", () => this.#onSelectionSettled(), { signal });
3726
+ messages.addEventListener("mousedown", () => this.#hide(), { signal });
3645
3727
  }
3646
3728
  /** Whether the transcript offers to quote what the user selects. */
3647
3729
  #enabled() {
@@ -4022,10 +4104,16 @@ var ConversationHistory = class {
4022
4104
  /** The active thread's id. Empty until the element connects. */
4023
4105
  #threadId = "";
4024
4106
  /**
4025
- * The messages the last restore replayed, which seed the next client the
4026
- * element builds. Emptied with the rest of the in-memory run.
4107
+ * The conversation as last written for the next client the element builds to
4108
+ * start from: what the last restore replayed, or what a checkpoint
4109
+ * continuation last saved after it. Emptied with the rest of the in-memory run.
4027
4110
  */
4028
4111
  #restored = [];
4112
+ /**
4113
+ * Counts the conversations cleared away, so a continuation can tell whether
4114
+ * the one it continued is still on screen when it saves.
4115
+ */
4116
+ #cleared = 0;
4029
4117
  // Bumped on every rehydrate; a replay whose generation is stale (a newer
4030
4118
  // thread switch started while it awaited a slow store) drops its result.
4031
4119
  #generation = 0;
@@ -4043,10 +4131,14 @@ var ConversationHistory = class {
4043
4131
  get threadId() {
4044
4132
  return this.#threadId;
4045
4133
  }
4046
- /** The messages the last restore replayed, for seeding the next client. */
4134
+ /** The conversation as last written, for seeding the next client. */
4047
4135
  get restored() {
4048
4136
  return this.#restored;
4049
4137
  }
4138
+ /** The checkpoint continuation in flight, or `null` when none is running. */
4139
+ get continuation() {
4140
+ return this.#continuation;
4141
+ }
4050
4142
  /** Point at the thread the store says is active. */
4051
4143
  adoptActiveThread() {
4052
4144
  this.#threadId = this.#host.conversationStore().threadId();
@@ -4071,6 +4163,7 @@ var ConversationHistory = class {
4071
4163
  /** Forget the messages the last restore seeded, with the rest of the run. */
4072
4164
  forgetRestored() {
4073
4165
  this.#restored = [];
4166
+ this.#cleared += 1;
4074
4167
  }
4075
4168
  /** Delete the active thread if nothing was ever sent in it. */
4076
4169
  reapUnsent() {
@@ -4156,43 +4249,76 @@ var ConversationHistory = class {
4156
4249
  * Uses a short-lived agent pointed at the resume / fork endpoint and seeded
4157
4250
  * with no history, because those endpoints supply the prior turns from the
4158
4251
  * snapshot and re-sending them would duplicate. A separate agent makes that
4159
- * structural — the main agent keeps its own history — and mints the fresh
4160
- * `run_id` the endpoints also require.
4252
+ * structural and mints the fresh `run_id` the endpoints also require.
4161
4253
  *
4162
4254
  * Built by the same construction as the conversation's own client, so the
4163
4255
  * continuation streams into the same transcript the user is looking at and
4164
4256
  * runs under the same state, tools and bounds. It is the run in flight while
4165
4257
  * it lasts: {@link stopContinuation} is how the element's Stop reaches it.
4258
+ *
4259
+ * What it adds joins the conversation. Its saves write the conversation on
4260
+ * screen ahead of its exchange, and each one hands that whole list to the
4261
+ * next client the element builds, so the next ordinary message is sent with
4262
+ * the exchange it follows. The conversation's own client never held it, and
4263
+ * was sending without it.
4166
4264
  */
4167
4265
  async continueRun(runId, verb) {
4168
4266
  const index = this.runs();
4169
4267
  if (index === null) {
4170
4268
  return;
4171
4269
  }
4270
+ if (this.#host.running() || this.#continuation !== null) {
4271
+ this.#refuse(this.#host.strings().continueWhileRunning);
4272
+ return;
4273
+ }
4172
4274
  const content = this.#host.input.value.trim();
4173
4275
  if (content === "") {
4174
- this.#host.hint.textContent = this.#host.strings().continueNeedsTurn;
4175
- this.#host.hint.hidden = false;
4176
- this.#host.input.focus();
4276
+ this.#refuse(this.#host.strings().continueNeedsTurn);
4177
4277
  return;
4178
4278
  }
4179
4279
  this.#host.input.value = "";
4180
4280
  this.#host.autoGrow();
4281
+ const cleared = this.#cleared;
4181
4282
  const client = this.#host.buildClient({
4182
4283
  endpoint: verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId),
4183
4284
  // The seed the endpoints assume: nothing. The snapshot is the history.
4184
4285
  initialMessages: [],
4185
- // Nor does it write the store. Its agent holds only the new turn and its
4186
- // answer, and a store keeps one list per thread, so saving what this
4187
- // client has would replace the conversation with its last exchange.
4188
- persist: false
4286
+ // What its saves write ahead of the exchange: the conversation on screen,
4287
+ // in the form the store holds it. From the conversation's own client when
4288
+ // there is one, because that client keeps how each call ended beside its
4289
+ // messages rather than on them; otherwise what the last restore or
4290
+ // continuation wrote, which is already in that form.
4291
+ //
4292
+ // All of it, even where this forks an earlier run and the server's
4293
+ // snapshot stops there: what is saved is what the screen shows.
4294
+ follows: this.#host.client()?.annotatedMessages ?? this.#restored,
4295
+ onSaved: (conversation) => {
4296
+ if (cleared !== this.#cleared) {
4297
+ return;
4298
+ }
4299
+ this.#restored = conversation;
4300
+ this.#host.releaseClient();
4301
+ }
4189
4302
  });
4190
4303
  this.#continuation = client;
4191
- await client.send(content);
4192
- if (this.#continuation === client) {
4193
- this.#continuation = null;
4304
+ try {
4305
+ await client.send(content);
4306
+ } finally {
4307
+ if (this.#continuation === client) {
4308
+ this.#continuation = null;
4309
+ this.#host.continuationEnded();
4310
+ }
4194
4311
  }
4195
4312
  }
4313
+ /**
4314
+ * Say at the composer why a picked run did not continue, and put the caret
4315
+ * there. The hint clears itself on the next keystroke.
4316
+ */
4317
+ #refuse(reason) {
4318
+ this.#host.hint.textContent = reason;
4319
+ this.#host.hint.hidden = false;
4320
+ this.#host.input.focus();
4321
+ }
4196
4322
  /**
4197
4323
  * Restore the conversation from the store on mount, then — if a navigating
4198
4324
  * tool reloaded the page mid-run — resume the loop by supplying that tool's
@@ -4784,6 +4910,7 @@ var STEP = 16;
4784
4910
  var COARSE_STEP = 64;
4785
4911
  function enableLauncherDrag(launcher, options) {
4786
4912
  let suppressClick = false;
4913
+ const { signal } = options;
4787
4914
  launcher.addEventListener(
4788
4915
  "click",
4789
4916
  (event) => {
@@ -4794,54 +4921,58 @@ function enableLauncherDrag(launcher, options) {
4794
4921
  event.stopPropagation();
4795
4922
  event.preventDefault();
4796
4923
  },
4797
- true
4924
+ { capture: true, signal }
4798
4925
  );
4799
- launcher.addEventListener("pointerdown", (event) => {
4800
- suppressClick = false;
4801
- if (!options.enabled()) {
4802
- return;
4803
- }
4804
- const start = options.rect();
4805
- const originX = event.clientX;
4806
- const originY = event.clientY;
4807
- let dragging = false;
4808
- const onMove = (move) => {
4809
- const dx = move.clientX - originX;
4810
- const dy = move.clientY - originY;
4811
- if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
4812
- return;
4813
- }
4814
- dragging = true;
4815
- launcher.setAttribute("data-dragging", "true");
4816
- const next = clampLauncher(
4817
- { ...start, left: start.left + dx, top: start.top + dy },
4818
- options.viewport()
4819
- );
4820
- options.apply(next.left, next.top);
4821
- };
4822
- const onUp = (up) => {
4823
- window.removeEventListener("pointermove", onMove);
4824
- window.removeEventListener("pointerup", onUp);
4825
- window.removeEventListener("pointercancel", onUp);
4826
- if (!dragging) {
4926
+ launcher.addEventListener(
4927
+ "pointerdown",
4928
+ (event) => {
4929
+ suppressClick = false;
4930
+ if (!options.enabled()) {
4827
4931
  return;
4828
4932
  }
4829
- launcher.removeAttribute("data-dragging");
4830
- suppressClick = true;
4831
- const next = clampLauncher(
4832
- {
4833
- ...start,
4834
- left: start.left + (up.clientX - originX),
4835
- top: start.top + (up.clientY - originY)
4836
- },
4837
- options.viewport()
4838
- );
4839
- options.commit(next.left, next.top);
4840
- };
4841
- window.addEventListener("pointermove", onMove);
4842
- window.addEventListener("pointerup", onUp);
4843
- window.addEventListener("pointercancel", onUp);
4844
- });
4933
+ const start = options.rect();
4934
+ const originX = event.clientX;
4935
+ const originY = event.clientY;
4936
+ let dragging = false;
4937
+ const onMove = (move) => {
4938
+ const dx = move.clientX - originX;
4939
+ const dy = move.clientY - originY;
4940
+ if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
4941
+ return;
4942
+ }
4943
+ dragging = true;
4944
+ launcher.setAttribute("data-dragging", "true");
4945
+ const next = clampLauncher(
4946
+ { ...start, left: start.left + dx, top: start.top + dy },
4947
+ options.viewport()
4948
+ );
4949
+ options.apply(next.left, next.top);
4950
+ };
4951
+ const onUp = (up) => {
4952
+ window.removeEventListener("pointermove", onMove);
4953
+ window.removeEventListener("pointerup", onUp);
4954
+ window.removeEventListener("pointercancel", onUp);
4955
+ if (!dragging) {
4956
+ return;
4957
+ }
4958
+ launcher.removeAttribute("data-dragging");
4959
+ suppressClick = true;
4960
+ const next = clampLauncher(
4961
+ {
4962
+ ...start,
4963
+ left: start.left + (up.clientX - originX),
4964
+ top: start.top + (up.clientY - originY)
4965
+ },
4966
+ options.viewport()
4967
+ );
4968
+ options.commit(next.left, next.top);
4969
+ };
4970
+ window.addEventListener("pointermove", onMove);
4971
+ window.addEventListener("pointerup", onUp);
4972
+ window.addEventListener("pointercancel", onUp);
4973
+ },
4974
+ { signal }
4975
+ );
4845
4976
  let pending = null;
4846
4977
  const settle = () => {
4847
4978
  if (pending === null) {
@@ -4851,32 +4982,36 @@ function enableLauncherDrag(launcher, options) {
4851
4982
  pending = null;
4852
4983
  options.commit(left, top);
4853
4984
  };
4854
- launcher.addEventListener("keydown", (event) => {
4855
- if (!options.enabled()) {
4856
- return;
4857
- }
4858
- const step = event.shiftKey ? COARSE_STEP : STEP;
4859
- const rect = options.rect();
4860
- let moved = null;
4861
- if (event.key === "ArrowLeft") {
4862
- moved = { left: rect.left - step, top: rect.top };
4863
- } else if (event.key === "ArrowRight") {
4864
- moved = { left: rect.left + step, top: rect.top };
4865
- } else if (event.key === "ArrowUp") {
4866
- moved = { left: rect.left, top: rect.top - step };
4867
- } else if (event.key === "ArrowDown") {
4868
- moved = { left: rect.left, top: rect.top + step };
4869
- }
4870
- if (moved === null) {
4871
- return;
4872
- }
4873
- event.preventDefault();
4874
- const next = clampLauncher({ ...rect, ...moved }, options.viewport());
4875
- options.apply(next.left, next.top);
4876
- pending = next;
4877
- });
4878
- launcher.addEventListener("keyup", settle);
4879
- launcher.addEventListener("blur", settle);
4985
+ launcher.addEventListener(
4986
+ "keydown",
4987
+ (event) => {
4988
+ if (!options.enabled()) {
4989
+ return;
4990
+ }
4991
+ const step = event.shiftKey ? COARSE_STEP : STEP;
4992
+ const rect = options.rect();
4993
+ let moved = null;
4994
+ if (event.key === "ArrowLeft") {
4995
+ moved = { left: rect.left - step, top: rect.top };
4996
+ } else if (event.key === "ArrowRight") {
4997
+ moved = { left: rect.left + step, top: rect.top };
4998
+ } else if (event.key === "ArrowUp") {
4999
+ moved = { left: rect.left, top: rect.top - step };
5000
+ } else if (event.key === "ArrowDown") {
5001
+ moved = { left: rect.left, top: rect.top + step };
5002
+ }
5003
+ if (moved === null) {
5004
+ return;
5005
+ }
5006
+ event.preventDefault();
5007
+ const next = clampLauncher({ ...rect, ...moved }, options.viewport());
5008
+ options.apply(next.left, next.top);
5009
+ pending = next;
5010
+ },
5011
+ { signal }
5012
+ );
5013
+ launcher.addEventListener("keyup", settle, { signal });
5014
+ launcher.addEventListener("blur", settle, { signal });
4880
5015
  }
4881
5016
 
4882
5017
  // src/ui/placement/place_widget.ts
@@ -5123,8 +5258,9 @@ var PanelPlacement = class {
5123
5258
  * away and unclickable behind the open panel, so a drag there would move
5124
5259
  * something nobody can see.
5125
5260
  */
5126
- enableLauncherDrag() {
5261
+ enableLauncherDrag(signal) {
5127
5262
  enableLauncherDrag(this.#host.launcher, {
5263
+ signal,
5128
5264
  enabled: () => this.#host.collapsed() && this.#launcherDraggable(),
5129
5265
  rect: () => this.#launcherBox(),
5130
5266
  viewport: () => this.#viewport(),
@@ -5509,20 +5645,26 @@ var PanelPlacement = class {
5509
5645
  if (visual === null || visual === void 0) {
5510
5646
  return;
5511
5647
  }
5512
- if (Math.abs(visual.height - window.innerHeight) < 1) {
5648
+ const layout = this.#screen().height;
5649
+ if (Math.abs(visual.height - layout) < 1) {
5513
5650
  this.#host.element.style.removeProperty("--ag-ui-visual-viewport-height");
5514
5651
  this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-bottom");
5652
+ this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-top");
5515
5653
  return;
5516
5654
  }
5517
5655
  this.#host.element.style.setProperty(
5518
5656
  "--ag-ui-visual-viewport-height",
5519
5657
  `${Math.round(visual.height)}px`
5520
5658
  );
5521
- const hidden = window.innerHeight - visual.height - visual.offsetTop;
5659
+ const hidden = layout - visual.height - visual.offsetTop;
5522
5660
  this.#host.element.style.setProperty(
5523
5661
  "--ag-ui-visual-viewport-inset-bottom",
5524
5662
  `${Math.max(0, Math.round(hidden))}px`
5525
5663
  );
5664
+ this.#host.element.style.setProperty(
5665
+ "--ag-ui-visual-viewport-inset-top",
5666
+ `${Math.max(0, Math.round(visual.offsetTop))}px`
5667
+ );
5526
5668
  }
5527
5669
  /**
5528
5670
  * The launcher's box in viewport coordinates, with its transform divided out.
@@ -6665,7 +6807,24 @@ var STYLES = `
6665
6807
  keyboard changes no viewport-percentage length -- not vh, not dvh, not svh
6666
6808
  -- so a full-bleed panel on a phone has to be told the height rather than
6667
6809
  deriving it. The value to publish there is the visual viewport's. */
6668
- --_viewport-height: var(--ag-ui-viewport-height, var(--_visual-viewport-height));
6810
+ --_viewport-height: var(
6811
+ --ag-ui-viewport-height,
6812
+ calc(
6813
+ min(var(--_visual-viewport-inset-top) + var(--_visual-viewport-height), 100dvh - var(--_viewport-inset-bottom)) -
6814
+ var(--_viewport-inset-top) - var(--_keyboard-inset-top)
6815
+ )
6816
+ );
6817
+ /* That default is the host's box cut to the part of the screen the user can
6818
+ see: from the top the panel starts at, the host's reserved top moved down
6819
+ by the keyboard inset below, to whichever is further up, the bottom of the
6820
+ visible area or the host's reserved bottom. Where nothing has measured, the
6821
+ visible area is the layout viewport with no pan, and this is the host's
6822
+ box exactly.
6823
+
6824
+ Cut rather than replaced. A measured height used as the whole answer kept
6825
+ a bar the host reserved at the top in the position and out of the height,
6826
+ so the panel ran that far past the bottom of the visible area, with its
6827
+ composer behind the keyboard. */
6669
6828
  /* The measured height of the part of the screen the user can actually see,
6670
6829
  written by the element from the visual viewport and falling back to the
6671
6830
  layout viewport where nothing has measured yet.
@@ -6678,11 +6837,15 @@ var STYLES = `
6678
6837
 
6679
6838
  Separate from the token above so a host that states the usable height
6680
6839
  outright still wins: the element writes this one inline, and an inline
6681
- value would otherwise outrank the host's own rule. */
6682
- --_visual-viewport-height: var(
6683
- --ag-ui-visual-viewport-height,
6684
- calc(100vh - var(--_viewport-inset-top) - var(--_viewport-inset-bottom))
6685
- );
6840
+ value would otherwise outrank the host's own rule.
6841
+
6842
+ The fallback is dvh rather than vh because the browser's own bars do move
6843
+ it, where a keyboard does not. iOS Safari resolves vh to the viewport with
6844
+ its bars collapsed, so on a page that does not scroll them away a panel
6845
+ sized from it ran under the address bar by the bars' height -- 40px on
6846
+ the phone measured -- with a docked composer underneath. dvh is the
6847
+ viewport with the bars as they are. */
6848
+ --_visual-viewport-height: var(--ag-ui-visual-viewport-height, 100dvh);
6686
6849
  /* How much of the layout viewport is hidden below the visible one, measured
6687
6850
  and written by the element alongside the height above.
6688
6851
 
@@ -6697,6 +6860,24 @@ var STYLES = `
6697
6860
  A host wanting no keyboard lift at all sets --ag-ui-keyboard-inset: 0px. */
6698
6861
  --_keyboard-inset: var(--ag-ui-keyboard-inset, var(--_visual-viewport-inset-bottom));
6699
6862
  --_visual-viewport-inset-bottom: var(--ag-ui-visual-viewport-inset-bottom, 0px);
6863
+ /* How far a panel anchored at the top moves down for a keyboard, below the
6864
+ top the host reserved. With the same two-token shape as the lift above;
6865
+ 0px keeps the panel at the host's top.
6866
+
6867
+ To show the field being typed into, a browser pans the visible area down
6868
+ the layout viewport, and a fixed element stays against the layout top, so
6869
+ a panel that did not move showed only its lower part, from the pan down,
6870
+ with its header off the screen and an empty band under it. It moves by
6871
+ what the pan goes past the host's reserved top, not by the whole pan: the
6872
+ pan scrolls a bar reserved there away with the rest of the page rather
6873
+ than pushing it down, and adding the two put the panel a whole bar below
6874
+ the visible area. A corner panel anchored at the bottom does not use this,
6875
+ because the band below already accounts for the pan. */
6876
+ --_keyboard-inset-top: var(--ag-ui-keyboard-inset-top, max(0px, var(--_visual-viewport-inset-top) - var(--_viewport-inset-top)));
6877
+ /* How much of the layout viewport is hidden above the visible one: how far
6878
+ the browser panned. Measured and written by the element with the two
6879
+ above. */
6880
+ --_visual-viewport-inset-top: var(--ag-ui-visual-viewport-inset-top, 0px);
6700
6881
  --_viewport-width: var(
6701
6882
  --ag-ui-viewport-width,
6702
6883
  calc(100vw - var(--_viewport-inset-left) - var(--_viewport-inset-right))
@@ -6833,7 +7014,7 @@ var STYLES = `
6833
7014
  }
6834
7015
 
6835
7016
  :host([placement="side"]) {
6836
- --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
7017
+ --_inset: var(--ag-ui-inset, calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
6837
7018
  --_width: var(--ag-ui-width, 420px);
6838
7019
  --_height: var(--ag-ui-height, var(--_viewport-height));
6839
7020
  --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
@@ -6843,7 +7024,8 @@ var STYLES = `
6843
7024
  :host([placement="full"]) {
6844
7025
  --_inset: var(
6845
7026
  --ag-ui-inset,
6846
- var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
7027
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
7028
+ var(--_viewport-inset-bottom) var(--_viewport-inset-left)
6847
7029
  );
6848
7030
  --_width: var(--ag-ui-width, var(--_viewport-width));
6849
7031
  --_height: var(--ag-ui-height, var(--_viewport-height));
@@ -6860,7 +7042,8 @@ var STYLES = `
6860
7042
  :host([placement="page"]) {
6861
7043
  --_inset: var(
6862
7044
  --ag-ui-inset,
6863
- var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
7045
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
7046
+ var(--_viewport-inset-bottom) var(--_viewport-inset-left)
6864
7047
  );
6865
7048
  --_width: var(--ag-ui-width, var(--_viewport-width));
6866
7049
  --_height: var(--ag-ui-height, var(--_viewport-height));
@@ -6926,7 +7109,7 @@ var STYLES = `
6926
7109
  :host([placement=""]:not([data-small-viewport="off"])) {
6927
7110
  --_inset: var(
6928
7111
  --ag-ui-inset,
6929
- var(--_viewport-inset-top) var(--_viewport-inset-right)
7112
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
6930
7113
  calc(var(--_viewport-inset-bottom) + var(--_keyboard-inset))
6931
7114
  var(--_viewport-inset-left)
6932
7115
  );
@@ -6956,7 +7139,7 @@ var STYLES = `
6956
7139
  --ag-ui-position: static (and place this element in your own layout) for a
6957
7140
  host-managed push instead. */
6958
7141
  :host([placement="sidebar"]) {
6959
- --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
7142
+ --_inset: var(--ag-ui-inset, calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
6960
7143
  --_width: var(--ag-ui-width, 420px);
6961
7144
  --_height: var(--ag-ui-height, var(--_viewport-height));
6962
7145
  --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
@@ -6965,7 +7148,11 @@ var STYLES = `
6965
7148
  }
6966
7149
 
6967
7150
  :host([placement="sidebar"][data-side="left"]) {
6968
- --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) auto var(--_viewport-inset-bottom) var(--_viewport-inset-left));
7151
+ --_inset: var(
7152
+ --ag-ui-inset,
7153
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) auto var(--_viewport-inset-bottom)
7154
+ var(--_viewport-inset-left)
7155
+ );
6969
7156
  }
6970
7157
 
6971
7158
  /* The docked panel is pinned to the edge it docks against rather than filling
@@ -7722,6 +7909,64 @@ var STYLES = `
7722
7909
  border-block-start-color: transparent;
7723
7910
  }
7724
7911
 
7912
+ /* On a phone the composer stays at the foot and the greeting takes the room
7913
+ above it, which is the other way round from everything above.
7914
+
7915
+ Centring is a shape for a screen with room to spare: the composer reads as
7916
+ the one thing on the page, and whichever way the rows grow there is space
7917
+ left on both sides of them. A phone has no space to spare, and the keyboard
7918
+ is what takes it -- opening one leaves a 426px-tall visible area with the
7919
+ composer halfway up it and an empty band underneath, which is the half of
7920
+ the screen a thumb is already resting on. Docked, the field sits directly
7921
+ over the keyboard, where every chat on the platform puts it, and the
7922
+ greeting keeps the rest.
7923
+
7924
+ Both rules restate the selector they override, with the breakpoint's own
7925
+ opt-out added: this is part of the shape a host keeps its desktop layout
7926
+ instead of, so it goes the same way as the rest of that shape. Restating
7927
+ rather than adding conditions to the originals keeps each of those rules
7928
+ readable as one decision, and source order settles the pair. */
7929
+ @media (max-width: 600px) {
7930
+ :host([data-empty]:not([data-restoring]):not([data-small-viewport="off"])) .chat::after {
7931
+ flex-grow: 0;
7932
+ }
7933
+
7934
+ /* The empty region fills the transcript instead of being centred in it as a
7935
+ block, so the two things in it can go to different places: the prompts the
7936
+ host offers to the foot, against the composer, and the greeting to the
7937
+ middle of what they leave.
7938
+
7939
+ A prompt chip is a way into the conversation. Against the field it starts,
7940
+ it reads as one; under the greeting halfway up the panel, with the field
7941
+ at the foot, it reads as decoration next to something else. Nothing here
7942
+ moves them on a screen where the composer is still centred: there the
7943
+ whole region hangs at the foot of the upper half, directly over the
7944
+ composer, and the prompts are already against it.
7945
+
7946
+ Auto block margins on the greeting are what centre it, and they take the
7947
+ free space whether or not there are prompts below to leave any, so a host
7948
+ that offers none gets a greeting in the middle of the transcript rather
7949
+ than one clinging to the composer.
7950
+ The region declares a display here, which outranks the rule collapsing a
7951
+ hidden one, so it says it is not hidden itself. Nothing sets the two
7952
+ apart today -- the transcript writes the hidden property and the host's
7953
+ data-empty from one expression -- and that is exactly why: a rule holding
7954
+ because of how a method in another file happens to be written is a rule
7955
+ holding by luck. */
7956
+ :host([placement="page"][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .empty:not([hidden]),
7957
+ :host([placement="embedded"][data-greeting][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .empty:not([hidden]) {
7958
+ margin: 0;
7959
+ flex: 1;
7960
+ display: flex;
7961
+ flex-direction: column;
7962
+ }
7963
+
7964
+ :host([placement="page"][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .greeting,
7965
+ :host([placement="embedded"][data-greeting][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .greeting {
7966
+ margin-block: auto;
7967
+ }
7968
+ }
7969
+
7725
7970
  /* Nor does the greeting paint while a restore is in flight. Hidden rather than
7726
7971
  removed, so it keeps its place and nothing around it moves when it shows. */
7727
7972
  :host([data-restoring]) .greeting {
@@ -8205,6 +8450,17 @@ var STYLES = `
8205
8450
  gap: 3px;
8206
8451
  }
8207
8452
 
8453
+ /* A call with no arguments drops the region rather than framing an empty
8454
+ object, and a settled card drops the result region until it has one -- both
8455
+ by setting the hidden property, and an author display beats the user-agent
8456
+ rule for that attribute. Without this the region kept laying out: 42px of
8457
+ card holding the ARGUMENTS heading over nothing, on every call the agent
8458
+ made with no arguments, in the display mode that shows arguments by
8459
+ default. */
8460
+ .tool-call-section[hidden] {
8461
+ display: none;
8462
+ }
8463
+
8208
8464
  /* The heading that tells the two payloads apart. Without it the arguments and
8209
8465
  the result were one run of text and a reader had to guess the boundary. */
8210
8466
  .tool-call-section-label {
@@ -8875,8 +9131,8 @@ var STYLES = `
8875
9131
  display: none;
8876
9132
  }
8877
9133
 
8878
- /* The mic button's mount point; filled only once #wireVoice mounts the
8879
- control. */
9134
+ /* The mic button's mount point; filled only once ComposerVoice.wire mounts
9135
+ the control, which it skips unless transcription is configured. */
8880
9136
  .voice-slot {
8881
9137
  display: contents;
8882
9138
  }
@@ -9553,6 +9809,15 @@ var STYLES = `
9553
9809
  padding: 10px 12px;
9554
9810
  }
9555
9811
 
9812
+ /* The row is hidden whenever the host offers no skills, which is most elements
9813
+ most of the time, and an author display beats the user-agent rule for the
9814
+ hidden attribute. Without this it kept its padding: 20px of panel between
9815
+ the transcript and the composer, under every placement, reading as the gap
9816
+ under whatever the transcript ends with. */
9817
+ .skill-chips[hidden] {
9818
+ display: none;
9819
+ }
9820
+
9556
9821
  .skill-chip {
9557
9822
  border: 1px solid var(--_border);
9558
9823
  border-radius: 999px;
@@ -13846,12 +14111,17 @@ var Transcript = class {
13846
14111
  /**
13847
14112
  * Start following the foot of the list, and wire the jump button to it.
13848
14113
  * Called while rendering rather than at construction: the viewport has to
13849
- * exist and the observer has to have something to observe.
14114
+ * exist and the observer has to have something to observe. The button
14115
+ * outlives a connection, so its listener goes under `signal`.
13850
14116
  */
13851
- mountScroller(jumpButton) {
13852
- jumpButton.addEventListener("click", () => {
13853
- this.jump();
13854
- });
14117
+ mountScroller(jumpButton, signal) {
14118
+ jumpButton.addEventListener(
14119
+ "click",
14120
+ () => {
14121
+ this.jump();
14122
+ },
14123
+ { signal }
14124
+ );
13855
14125
  this.#scroller = createStickToBottom({
13856
14126
  viewport: this.#host.messages,
13857
14127
  onMissedContent: (missed) => {
@@ -14533,20 +14803,31 @@ var AgUiClient = class {
14533
14803
  * unannotated save quietly threw the annotations away.
14534
14804
  */
14535
14805
  #persist() {
14806
+ this.#onPersist(this.annotatedMessages);
14807
+ }
14808
+ /**
14809
+ * The history in the form a save writes it: {@link messages}, with how each
14810
+ * tool call ended annotated onto its result -- exactly what
14811
+ * {@link AgUiClientConfig.onPersist} is handed.
14812
+ *
14813
+ * For writing the conversation somewhere other than a save. The element needs
14814
+ * it when a checkpoint continuation adds to a conversation this client holds:
14815
+ * the continuation's saves write the conversation ahead of the exchange, and
14816
+ * the bare {@link messages} would drop the annotations, so a declined card
14817
+ * turned green on the next reload.
14818
+ */
14819
+ get annotatedMessages() {
14536
14820
  const messages = this.#agent.messages;
14537
14821
  if (this.#outcomes.size === 0) {
14538
- this.#onPersist(messages);
14539
- return;
14822
+ return messages;
14540
14823
  }
14541
- this.#onPersist(
14542
- messages.map((message) => {
14543
- if (message.role !== "tool") {
14544
- return message;
14545
- }
14546
- const outcome = this.#outcomes.get(message.toolCallId);
14547
- return outcome === void 0 ? message : { ...message, outcome };
14548
- })
14549
- );
14824
+ return messages.map((message) => {
14825
+ if (message.role !== "tool") {
14826
+ return message;
14827
+ }
14828
+ const outcome = this.#outcomes.get(message.toolCallId);
14829
+ return outcome === void 0 ? message : { ...message, outcome };
14830
+ });
14550
14831
  }
14551
14832
  async #runLoop() {
14552
14833
  let resume;
@@ -15771,6 +16052,8 @@ var AgUiChat = class extends HTMLElement {
15771
16052
  * Optional full replacement for the `ask_user` question UI, resolving with
15772
16053
  * the answer; the same seam as {@link approvalRenderer}, styled via `strings`
15773
16054
  * and the `question*` `::part()`s when left unset. Requires {@link askUser}.
16055
+ * A renderer that throws or rejects hands the question to the built-in card,
16056
+ * as a failing {@link approvalRenderer} does, unless Stop already fired.
15774
16057
  */
15775
16058
  askUserRenderer = null;
15776
16059
  /**
@@ -16075,10 +16358,37 @@ var AgUiChat = class extends HTMLElement {
16075
16358
  #voice;
16076
16359
  /** Whether the element is currently in the DOM; gates the connect-time warning. */
16077
16360
  #connected = false;
16361
+ /**
16362
+ * Aborted when the element leaves the document, and replaced when it comes
16363
+ * back.
16364
+ *
16365
+ * Every listener connecting adds to an element that outlives the connection
16366
+ * -- the composer, Send, the launcher, the shell -- is added under this
16367
+ * signal. Connecting runs again on every insertion, so a listener added
16368
+ * without it is a second listener the next time: one click on Stop stopped
16369
+ * the run three times, and one press of the built-in theme toggle flipped it
16370
+ * twice, back to where it started.
16371
+ */
16372
+ #connection = new AbortController();
16373
+ /**
16374
+ * Whether the element has connected before, which makes this connection a
16375
+ * re-insertion: there is a transcript on screen from last time, and the
16376
+ * history replay would draw the conversation a second time beneath it.
16377
+ */
16378
+ #connectedBefore = false;
16379
+ /**
16380
+ * The remote store `data-threads-url` wrapped around the conversation store,
16381
+ * and the store inside it, so connecting again can wrap that store rather
16382
+ * than the wrapper.
16383
+ */
16384
+ #threadStore = null;
16078
16385
  #client = null;
16079
16386
  // Seed for the next client. Once one exists it owns the live value (the
16080
16387
  // agent applies STATE_SNAPSHOT / STATE_DELTA into it), so this is only the
16081
- // starting point — `sharedState` reads through to the client when present.
16388
+ // starting point — `sharedState` reads through to the client when present,
16389
+ // and to a running checkpoint continuation before that. Every client mirrors
16390
+ // its changes here, which is what the client built after a continuation is
16391
+ // seeded from.
16082
16392
  #sharedState = {};
16083
16393
  // Whether an interaction is in flight (first onRunStart → onSettled). Drives
16084
16394
  // the Send⇄Stop button: `agent.isRunning` is false between frontend-tool
@@ -16285,7 +16595,9 @@ var AgUiChat = class extends HTMLElement {
16285
16595
  }
16286
16596
  });
16287
16597
  this.#checkpoints = new CheckpointMenu((runId, verb) => {
16288
- void this.#history.continueRun(runId, verb);
16598
+ void this.#history.continueRun(runId, verb).catch((error) => {
16599
+ console.warn("<ag-ui-chat>: continuing a run failed", error);
16600
+ });
16289
16601
  });
16290
16602
  this.#history = new ConversationHistory({
16291
16603
  element: this,
@@ -16305,8 +16617,14 @@ var AgUiChat = class extends HTMLElement {
16305
16617
  requestCredentials: () => this.#requestCredentials(),
16306
16618
  appendMessage: (role, content) => this.appendMessage(role, content),
16307
16619
  autoGrow: () => autoGrow(this.#input),
16620
+ continuationEnded: () => this.#flushQueued(),
16621
+ client: () => this.#client,
16308
16622
  ensureClient: () => this.#ensureClient(),
16309
16623
  buildClient: (seed) => this.#buildClient(seed),
16624
+ releaseClient: () => {
16625
+ this.#client = null;
16626
+ },
16627
+ running: () => this.#running,
16310
16628
  cancelRun: () => this.#cancelRun(),
16311
16629
  resetState: () => this.#resetState(),
16312
16630
  setRunning: (running) => this.#setRunning(running)
@@ -16388,11 +16706,32 @@ var AgUiChat = class extends HTMLElement {
16388
16706
  * from {@link registerPageState}, which exposes host state as ordinary tools.
16389
16707
  */
16390
16708
  get sharedState() {
16391
- return this.#client?.state ?? this.#sharedState;
16709
+ return this.#liveClient()?.state ?? this.#sharedState;
16392
16710
  }
16393
16711
  set sharedState(state) {
16394
16712
  this.#sharedState = { ...state };
16395
- this.#client?.setState(this.#sharedState);
16713
+ this.#liveClient()?.setState(this.#sharedState);
16714
+ }
16715
+ /**
16716
+ * The client holding the conversation's live shared state: a checkpoint
16717
+ * continuation while one runs, otherwise the conversation's own client, and
16718
+ * `null` before either exists.
16719
+ *
16720
+ * The continuation first, because the state it streams is applied to its own
16721
+ * agent. Reading the conversation's client alone returned what the state was
16722
+ * before the continuation began, for as long as that client lived.
16723
+ *
16724
+ * It now lives no longer than the continuation's first save, which hands the
16725
+ * conversation to the next client built, seeded from the mirror every client
16726
+ * writes -- so the next run sends what the continuation left. That release
16727
+ * alone makes the getter's fallback give the same answer, and no test can
16728
+ * tell the two apart; reading the continuation keeps the getter right
16729
+ * without depending on when the other client is let go. The setter has no
16730
+ * such fallback, and is held by "reads the shared state it changed while it
16731
+ * runs, not the conversation's" in `ag_ui_chat_checkpoints.test.ts`.
16732
+ */
16733
+ #liveClient() {
16734
+ return this.#history.continuation ?? this.#client;
16396
16735
  }
16397
16736
  /** Bind a piece of host page state to `read_<name>` / `set_<name>` tools. */
16398
16737
  registerPageState(binding) {
@@ -16569,6 +16908,11 @@ var AgUiChat = class extends HTMLElement {
16569
16908
  this.setAttribute("data-tool-display", value);
16570
16909
  }
16571
16910
  connectedCallback() {
16911
+ this.#connection = new AbortController();
16912
+ if (this.#connectedBefore) {
16913
+ this.#resetConversation();
16914
+ }
16915
+ this.#connectedBefore = true;
16572
16916
  this.#storage.claim();
16573
16917
  this.#placement.restoreSize();
16574
16918
  requestAnimationFrame(() => {
@@ -16590,13 +16934,13 @@ var AgUiChat = class extends HTMLElement {
16590
16934
  }
16591
16935
  this.#syncLauncher();
16592
16936
  this.#skills.init();
16593
- this.conversationStore = this.#storage.scopeStore(this.conversationStore, this.userKey);
16937
+ this.conversationStore = this.#storage.scopeStore(this.#unwrappedStore(), this.userKey);
16594
16938
  window.addEventListener("resize", this.#onViewportResize);
16595
16939
  window.visualViewport?.addEventListener("resize", this.#onViewportResize);
16596
16940
  window.visualViewport?.addEventListener("scroll", this.#onViewportResize);
16597
16941
  this.#placement.publishVisualViewport();
16598
16942
  this.#wireThreadStore();
16599
- this.#attachments.wire();
16943
+ this.#attachments.wire(this.#connection.signal);
16600
16944
  this.#voice.wire();
16601
16945
  this.#history.adoptActiveThread();
16602
16946
  queueMicrotask(() => this.#startup());
@@ -16661,6 +17005,7 @@ var AgUiChat = class extends HTMLElement {
16661
17005
  */
16662
17006
  disconnectedCallback() {
16663
17007
  this.#connected = false;
17008
+ this.#connection.abort();
16664
17009
  window.removeEventListener("resize", this.#onViewportResize);
16665
17010
  window.visualViewport?.removeEventListener("resize", this.#onViewportResize);
16666
17011
  window.visualViewport?.removeEventListener("scroll", this.#onViewportResize);
@@ -16768,17 +17113,34 @@ var AgUiChat = class extends HTMLElement {
16768
17113
  * thread id, the navigation checkpoint) keep their local store either way.
16769
17114
  */
16770
17115
  #wireThreadStore() {
17116
+ this.#threadStore = null;
16771
17117
  const url = this.getAttribute("data-threads-url");
16772
17118
  if (url !== null) {
16773
- this.conversationStore = new RemoteConversationStore(
17119
+ const inner = this.conversationStore;
17120
+ const remote = new RemoteConversationStore(
16774
17121
  url,
16775
17122
  () => this.#headersFor(url),
16776
- this.conversationStore,
17123
+ inner,
16777
17124
  () => this.#requestCredentials(),
16778
17125
  this.getAttribute("data-threads-cache") !== "false"
16779
17126
  );
17127
+ this.#threadStore = { remote, inner };
17128
+ this.conversationStore = remote;
16780
17129
  }
16781
17130
  }
17131
+ /**
17132
+ * The conversation store without the remote this element wrapped it in.
17133
+ *
17134
+ * An element removed and inserted again connects again, and wrapping the
17135
+ * wrapper stacked a second remote on the first, so every rename and delete
17136
+ * reached the server twice. Unwrapping first also lets a `data-threads-url`
17137
+ * removed while detached take the remote away. A store the host assigned
17138
+ * since is theirs, and comes back as it is.
17139
+ */
17140
+ #unwrappedStore() {
17141
+ const wrapped = this.#threadStore;
17142
+ return wrapped !== null && this.conversationStore === wrapped.remote ? wrapped.inner : this.conversationStore;
17143
+ }
16782
17144
  /**
16783
17145
  * Replace the host-supplied (client) skill catalog. Merged after the embedded
16784
17146
  * and fetched skills (so a client skill overrides a same-named server one).
@@ -17020,12 +17382,22 @@ var AgUiChat = class extends HTMLElement {
17020
17382
  }
17021
17383
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
17022
17384
  #resetState() {
17385
+ this.#resetConversation();
17386
+ this.#sentDrafts.length = 0;
17387
+ this.#recallIndex = null;
17388
+ }
17389
+ /**
17390
+ * Drop the in-memory run and transcript, and nothing the user typed.
17391
+ *
17392
+ * Split from {@link #resetState} for re-insertion, which rebuilds the
17393
+ * conversation from history but is not a new one, so the composer's recall
17394
+ * history stays.
17395
+ */
17396
+ #resetConversation() {
17023
17397
  this.#client = null;
17024
17398
  this.#runHandlers.detach();
17025
17399
  this.#clearTranscript();
17026
17400
  this.#history.forgetRestored();
17027
- this.#sentDrafts.length = 0;
17028
- this.#recallIndex = null;
17029
17401
  }
17030
17402
  /**
17031
17403
  * Wipe the rendered transcript and everything that indexes into it.
@@ -17091,7 +17463,19 @@ var AgUiChat = class extends HTMLElement {
17091
17463
  appendMessage(role, content) {
17092
17464
  return this.#transcript.append(role, content);
17093
17465
  }
17466
+ /**
17467
+ * Build the chrome, from the attributes as they stand.
17468
+ *
17469
+ * Runs on every connect, not once, because removing and re-inserting the
17470
+ * element is the documented way to apply a connect-time attribute written
17471
+ * late. So it has to build the same element the second time as the first:
17472
+ * the elements made here are new each time, the long-lived ones it fills
17473
+ * are filled with `replaceChildren` rather than appended to, and every
17474
+ * listener on a long-lived one goes under the connection's signal. Appending
17475
+ * gave a re-inserted element two headers and two composers.
17476
+ */
17094
17477
  #render() {
17478
+ const { signal } = this.#connection;
17095
17479
  this.#chat.className = "chat";
17096
17480
  this.#chat.setAttribute("part", "panel");
17097
17481
  const header = document.createElement("div");
@@ -17128,7 +17512,7 @@ var AgUiChat = class extends HTMLElement {
17128
17512
  this.#themeToggle.setAttribute("part", "header-button theme-toggle");
17129
17513
  this.#themeToggle.title = this.#strings.toggleTheme;
17130
17514
  this.#themeToggle.setAttribute("aria-label", this.#strings.toggleTheme);
17131
- this.#themeToggle.addEventListener("click", () => this.toggleTheme());
17515
+ this.#themeToggle.addEventListener("click", () => this.toggleTheme(), { signal });
17132
17516
  this.#syncThemeGlyph();
17133
17517
  controls.append(this.#themeToggle);
17134
17518
  }
@@ -17144,8 +17528,8 @@ var AgUiChat = class extends HTMLElement {
17144
17528
  this.#jumpButton.type = "button";
17145
17529
  this.#jumpButton.setAttribute("part", "jump-latest");
17146
17530
  this.#jumpButton.textContent = this.#strings.jumpToLatest;
17147
- this.#excerpts.mount();
17148
- this.#transcript.mountScroller(this.#jumpButton);
17531
+ this.#excerpts.mount(signal);
17532
+ this.#transcript.mountScroller(this.#jumpButton, signal);
17149
17533
  this.#announcer.mount();
17150
17534
  this.#emptyWrap.className = "empty";
17151
17535
  this.#emptyWrap.setAttribute("part", "empty");
@@ -17157,7 +17541,6 @@ var AgUiChat = class extends HTMLElement {
17157
17541
  greetingSlot.append(this.#greetingText);
17158
17542
  greeting.append(greetingSlot);
17159
17543
  this.#syncGreeting();
17160
- this.#emptyWrap.append(greeting);
17161
17544
  const emptySlot = document.createElement("slot");
17162
17545
  emptySlot.name = "empty";
17163
17546
  const starters = renderStarterChips(this, this.#strings, (prompt) => {
@@ -17166,7 +17549,7 @@ var AgUiChat = class extends HTMLElement {
17166
17549
  if (starters !== null) {
17167
17550
  emptySlot.append(starters);
17168
17551
  }
17169
- this.#emptyWrap.append(emptySlot);
17552
+ this.#emptyWrap.replaceChildren(greeting, emptySlot);
17170
17553
  this.#queuedRow.className = "queued";
17171
17554
  this.#queuedRow.setAttribute("part", "queued");
17172
17555
  this.#queuedRow.setAttribute("role", "group");
@@ -17188,41 +17571,47 @@ var AgUiChat = class extends HTMLElement {
17188
17571
  this.#input.setAttribute("aria-label", this.#strings.message);
17189
17572
  this.#input.rows = 1;
17190
17573
  this.#input.placeholder = this.#strings.inputPlaceholder;
17191
- this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
17192
- this.#input.addEventListener("input", () => this.#onInput());
17574
+ this.#input.addEventListener("keydown", (event) => this.#onKeydown(event), { signal });
17575
+ this.#input.addEventListener("input", () => this.#onInput(), { signal });
17193
17576
  this.#send.className = "send";
17194
17577
  this.#send.type = "button";
17195
17578
  this.#send.setAttribute("part", "send");
17196
- this.#send.append(
17579
+ this.#send.replaceChildren(
17197
17580
  glyphSlot("icon-send", "send-send", ICON_SEND),
17198
17581
  glyphSlot("icon-stop", "send-stop", ICON_STOP)
17199
17582
  );
17200
17583
  this.#send.title = this.#strings.send;
17201
17584
  this.#send.setAttribute("aria-label", this.#strings.send);
17202
17585
  this.#send.dataset["state"] = "idle";
17203
- this.#send.addEventListener("click", () => {
17204
- if (this.#running) {
17205
- this.#cancelRun();
17206
- return;
17207
- }
17208
- void this.#submit();
17209
- });
17586
+ this.#send.addEventListener(
17587
+ "click",
17588
+ () => {
17589
+ if (this.#running) {
17590
+ this.#cancelRun();
17591
+ return;
17592
+ }
17593
+ void this.#submit();
17594
+ },
17595
+ { signal }
17596
+ );
17210
17597
  this.#composerHint.className = "skill-hint";
17211
17598
  this.#composerHint.setAttribute("part", "skill-hint");
17212
17599
  this.#composerHint.hidden = true;
17213
17600
  this.#attachButton.className = "attach-btn";
17214
17601
  this.#attachButton.type = "button";
17215
17602
  this.#attachButton.setAttribute("part", "attach-button");
17216
- this.#attachButton.append(glyphSlot("icon-attach", "attach-glyph", ICON_ATTACH));
17603
+ this.#attachButton.replaceChildren(glyphSlot("icon-attach", "attach-glyph", ICON_ATTACH));
17217
17604
  this.#attachButton.title = this.#strings.attachFiles;
17218
17605
  this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
17219
17606
  this.#attachButton.hidden = true;
17220
- this.#attachButton.addEventListener("click", () => this.#fileInput.click());
17607
+ this.#attachButton.addEventListener("click", () => this.#fileInput.click(), { signal });
17221
17608
  this.#fileInput.className = "attach-input";
17222
17609
  this.#fileInput.type = "file";
17223
17610
  this.#fileInput.multiple = true;
17224
17611
  this.#fileInput.hidden = true;
17225
- this.#fileInput.addEventListener("change", () => this.#attachments.onFilesPicked());
17612
+ this.#fileInput.addEventListener("change", () => this.#attachments.onFilesPicked(), {
17613
+ signal
17614
+ });
17226
17615
  this.#attachSlot.className = "attachment-slot";
17227
17616
  this.#voiceSlot.className = "voice-slot";
17228
17617
  const footer = document.createElement("slot");
@@ -17231,8 +17620,8 @@ var AgUiChat = class extends HTMLElement {
17231
17620
  composer.append(this.#input, tools);
17232
17621
  inputRow.append(composer, this.#fileInput);
17233
17622
  this.#messagesWrap.className = "messages-wrap";
17234
- this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#excerpts.button);
17235
- this.#chat.append(
17623
+ this.#messagesWrap.replaceChildren(this.#messages, this.#jumpButton, this.#excerpts.button);
17624
+ this.#chat.replaceChildren(
17236
17625
  header,
17237
17626
  this.#messagesWrap,
17238
17627
  this.#skillsMenu.palette,
@@ -17245,16 +17634,20 @@ var AgUiChat = class extends HTMLElement {
17245
17634
  this.#drawer.element,
17246
17635
  this.#checkpoints.element
17247
17636
  );
17248
- this.#chat.addEventListener("pointerdown", (event) => {
17249
- if (!this.#checkpoints.open_) {
17250
- return;
17251
- }
17252
- const path = event.composedPath();
17253
- if (path.includes(this.#checkpoints.element) || path.includes(checkpoints)) {
17254
- return;
17255
- }
17256
- this.#checkpoints.close();
17257
- });
17637
+ this.#chat.addEventListener(
17638
+ "pointerdown",
17639
+ (event) => {
17640
+ if (!this.#checkpoints.open_) {
17641
+ return;
17642
+ }
17643
+ const path = event.composedPath();
17644
+ if (path.includes(this.#checkpoints.element) || path.includes(checkpoints)) {
17645
+ return;
17646
+ }
17647
+ this.#checkpoints.close();
17648
+ },
17649
+ { signal }
17650
+ );
17258
17651
  this.#launcher.className = "launcher";
17259
17652
  this.#launcher.type = "button";
17260
17653
  this.#launcher.setAttribute("part", "launcher");
@@ -17267,19 +17660,19 @@ var AgUiChat = class extends HTMLElement {
17267
17660
  this.#railLabel.setAttribute("part", "rail-label");
17268
17661
  this.#railLabel.setAttribute("aria-hidden", "true");
17269
17662
  this.#railLabel.textContent = this.getAttribute("title-text") ?? this.#strings.title;
17270
- this.#launcher.append(
17663
+ this.#launcher.replaceChildren(
17271
17664
  iconElement("launcher", "launcher-icon", ICON_LAUNCHER, readLauncherIconUrl(this)),
17272
17665
  this.#railLabel,
17273
17666
  this.#badge
17274
17667
  );
17275
- this.#launcher.addEventListener("click", () => this.setCollapsed(false));
17276
- this.#placement.enableLauncherDrag();
17668
+ this.#launcher.addEventListener("click", () => this.setCollapsed(false), { signal });
17669
+ this.#placement.enableLauncherDrag(signal);
17277
17670
  this.#placement.mountResizeGrips(this.#chat);
17278
17671
  adoptStyles(this.#root);
17279
17672
  const probe = this.#placement.probe;
17280
17673
  probe.className = "viewport-probe";
17281
17674
  probe.setAttribute("aria-hidden", "true");
17282
- this.#root.append(probe, this.#announcer.region, this.#chat, this.#launcher);
17675
+ this.#root.replaceChildren(probe, this.#announcer.region, this.#chat, this.#launcher);
17283
17676
  }
17284
17677
  /**
17285
17678
  * Reflect the collapsed state and the unread count on the launcher.
@@ -17434,6 +17827,9 @@ var AgUiChat = class extends HTMLElement {
17434
17827
  * other shape would be a second sender racing the guard above.
17435
17828
  */
17436
17829
  #flushQueued() {
17830
+ if (this.#running || this.#history.continuation !== null) {
17831
+ return;
17832
+ }
17437
17833
  const next = this.#queued.shift();
17438
17834
  this.#renderQueued();
17439
17835
  if (next !== void 0) {
@@ -17471,7 +17867,7 @@ var AgUiChat = class extends HTMLElement {
17471
17867
  if (content === "" && attachments.length === 0) {
17472
17868
  return;
17473
17869
  }
17474
- if (this.#running) {
17870
+ if (this.#running || this.#history.continuation !== null) {
17475
17871
  if (content !== "") {
17476
17872
  this.#queued.push(content);
17477
17873
  this.#renderQueued();
@@ -17509,12 +17905,14 @@ var AgUiChat = class extends HTMLElement {
17509
17905
  * `attachments` are durable {@link AttachmentRef}s — what {@link attachFile}
17510
17906
  * resolves to and what {@link ATTACHMENT_EVENT} reports.
17511
17907
  *
17512
- * No-ops on an empty message, and while a run is in flight, since a second
17513
- * concurrent run would orphan the first. Unlike the built-in Send it does not
17514
- * consult the tray: what you pass is what is sent.
17908
+ * No-ops on an empty message, and while a run or a picked checkpoint's
17909
+ * continuation is in flight, since a second concurrent run would orphan the
17910
+ * first. Unlike the built-in Send it does not queue: it returns, and the
17911
+ * caller keeps what it tried to send. Nor does it consult the tray -- what
17912
+ * you pass is what is sent.
17515
17913
  */
17516
17914
  async sendMessage(content, attachments = []) {
17517
- if (this.#running || content === "" && attachments.length === 0) {
17915
+ if (this.#running || this.#history.continuation !== null || content === "" && attachments.length === 0) {
17518
17916
  return;
17519
17917
  }
17520
17918
  if (this.#transcript.isEmpty()) {
@@ -17587,7 +17985,8 @@ var AgUiChat = class extends HTMLElement {
17587
17985
  this.#client = this.#buildClient({
17588
17986
  endpoint: this.endpoint,
17589
17987
  initialMessages: this.#history.restored,
17590
- persist: true
17988
+ // Its history is the whole conversation, so nothing goes ahead of it.
17989
+ follows: []
17591
17990
  });
17592
17991
  }
17593
17992
  return this.#client;
@@ -17624,9 +18023,15 @@ var AgUiChat = class extends HTMLElement {
17624
18023
  getContext: () => this.#dispatch.buildContext(),
17625
18024
  executeTool: (call) => this.#dispatch.execute(call),
17626
18025
  resolveInterrupts: (interrupts) => this.#dispatch.resolveInterrupts(interrupts),
17627
- ...seed.persist ? {
17628
- onPersist: (messages) => this.conversationStore.saveMessages(threadId, messages)
17629
- } : {},
18026
+ // Every client saves the whole conversation, because a store keeps one
18027
+ // list per thread. For the conversation's own client that is its
18028
+ // history; a continuation holds only what it adds, and writes the
18029
+ // conversation it continues ahead of that.
18030
+ onPersist: (messages) => {
18031
+ const conversation = [...seed.follows, ...messages];
18032
+ this.conversationStore.saveMessages(threadId, conversation);
18033
+ seed.onSaved?.(conversation);
18034
+ },
17630
18035
  onStateChanged: (state) => this.#onSharedStateChanged(state),
17631
18036
  connectionLostMessage: this.#strings.connectionLost,
17632
18037
  unfinishedMessage: this.#strings.callNotFinished,
@@ -17789,7 +18194,7 @@ function setControlValue(el2, value) {
17789
18194
  }
17790
18195
 
17791
18196
  // src/version.ts
17792
- var VERSION = "0.39.0";
18197
+ var VERSION = "0.40.0";
17793
18198
  export {
17794
18199
  ATTACHMENT_EVENT,
17795
18200
  AgUiChat,