@artooi/ag-ui-web-component 0.23.1 → 0.25.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 (37) hide show
  1. package/CHANGELOG.md +194 -1
  2. package/README.md +188 -38
  3. package/dist/ag-ui-web-component.bundle.js +189 -49
  4. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  5. package/dist/constants.d.ts +25 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +24 -0
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +18 -0
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/remote_conversation_store.d.ts +2 -0
  12. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  13. package/dist/core/run_index.d.ts +10 -0
  14. package/dist/core/run_index.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +335 -44
  18. package/dist/index.js.map +2 -2
  19. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  20. package/dist/ui/styles.d.ts +1 -1
  21. package/dist/ui/styles.d.ts.map +1 -1
  22. package/dist/ui/tool_call_card.d.ts +26 -2
  23. package/dist/ui/tool_call_card.d.ts.map +1 -1
  24. package/dist/ui/ui_strings.d.ts +2 -0
  25. package/dist/ui/ui_strings.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/constants.ts +26 -0
  28. package/src/core/ag_ui_chat.ts +186 -40
  29. package/src/core/conversation_store.ts +30 -0
  30. package/src/core/remote_conversation_store.ts +14 -0
  31. package/src/core/run_index.ts +10 -0
  32. package/src/index.ts +3 -0
  33. package/src/ui/checkpoint_menu.ts +32 -7
  34. package/src/ui/styles.ts +153 -13
  35. package/src/ui/tool_call_card.ts +41 -3
  36. package/src/ui/ui_strings.ts +3 -0
  37. package/src/version.ts +1 -1
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ var TOGGLE_EVENT = "ag-ui-toggle";
5
5
  var UNREAD_EVENT = "ag-ui-unread";
6
6
  var STATE_EVENT = "ag-ui-state";
7
7
  var ATTACHMENT_EVENT = "ag-ui-attachments";
8
+ var RUN_FINISHED_EVENT = "ag-ui-run-finished";
8
9
  var MESSAGE_ROLE = {
9
10
  USER: "user",
10
11
  ASSISTANT: "assistant"
@@ -17,6 +18,7 @@ var READ_PAGE_TOOL = "read_page";
17
18
  var MAX_TOOL_ROUNDS = 10;
18
19
  var TOOL_CALL_STATUS = {
19
20
  PENDING: "pending",
21
+ DEFERRED: "deferred",
20
22
  DONE: "done",
21
23
  ERROR: "error",
22
24
  DECLINED: "declined"
@@ -553,6 +555,7 @@ var DEFAULT_UI_STRINGS = {
553
555
  transcribing: "Transcribing\u2026",
554
556
  transcriptionFailed: "Transcription failed",
555
557
  toolRunning: "running\u2026",
558
+ toolDeferred: "waiting for you",
556
559
  toolDone: "\u2713 done",
557
560
  toolError: "\u26A0 error",
558
561
  toolDeclined: "\u2298 declined",
@@ -1121,12 +1124,27 @@ var CheckpointMenu = class {
1121
1124
  const row = document.createElement("div");
1122
1125
  row.className = "checkpoint-row";
1123
1126
  row.setAttribute("part", "checkpoint-row");
1127
+ const preview = run.preview !== void 0 && run.preview !== null && run.preview !== "" ? run.preview : null;
1128
+ const time = run.started_at === null ? null : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
1124
1129
  const label = document.createElement("span");
1125
1130
  label.className = "checkpoint-label";
1126
1131
  label.setAttribute("part", "checkpoint-label");
1127
- label.textContent = run.started_at === null ? run.run_id : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
1128
- label.title = run.run_id;
1132
+ label.textContent = preview ?? time ?? run.run_id;
1129
1133
  row.append(label);
1134
+ if (preview !== null && time !== null) {
1135
+ const when = document.createElement("span");
1136
+ when.className = "checkpoint-time";
1137
+ when.setAttribute("part", "checkpoint-time");
1138
+ when.textContent = time;
1139
+ row.append(when);
1140
+ } else if (run.started_at !== null) {
1141
+ const short = document.createElement("span");
1142
+ short.className = "checkpoint-id";
1143
+ short.setAttribute("part", "checkpoint-id");
1144
+ short.textContent = run.run_id.slice(0, 8);
1145
+ short.title = run.run_id;
1146
+ row.append(short);
1147
+ }
1130
1148
  if (run.parent_run_id !== null) {
1131
1149
  const branch = document.createElement("span");
1132
1150
  branch.className = "checkpoint-branch";
@@ -5392,18 +5410,28 @@ var STYLES = `
5392
5410
  color: var(--_tool-fg);
5393
5411
  }
5394
5412
 
5413
+ /* Wraps, because the name is the only flexible child and every badge the row
5414
+ gains is taken out of it. An approved call adds a third fixed badge, which in
5415
+ a sidebar-width panel left the name 37px and broke it mid-word. Badges drop to
5416
+ their own row instead. */
5395
5417
  .tool-call-head {
5396
5418
  display: flex;
5419
+ flex-wrap: wrap;
5397
5420
  align-items: center;
5398
5421
  justify-content: space-between;
5399
5422
  gap: 8px;
5400
5423
  }
5401
5424
 
5402
5425
  .tool-call-name {
5403
- flex: 1;
5404
- min-width: 0;
5426
+ /* An auto basis, and a min-width floor rather than zero: the name may shrink,
5427
+ but not below something readable, so wrapping moves a badge instead of
5428
+ shredding a word. Breaking anywhere still applies to a name that cannot fit
5429
+ on a line of its own, which is what keeps a long unbroken tool name inside
5430
+ the card. */
5431
+ flex: 1 1 auto;
5432
+ min-width: 6ch;
5405
5433
  font-weight: 600;
5406
- word-break: break-word;
5434
+ overflow-wrap: anywhere;
5407
5435
  }
5408
5436
 
5409
5437
  /* Leading status icon. Empty in the DOM \u2014 the glyph/spinner is drawn
@@ -5432,6 +5460,13 @@ var STYLES = `
5432
5460
  to { transform: rotate(360deg); }
5433
5461
  }
5434
5462
 
5463
+ /* Deferred: no spinner, because nothing is spinning. A steady accent dot, since
5464
+ the state is waiting-on-you rather than an outcome. */
5465
+ .tool-call[data-status="deferred"] .tool-call-icon {
5466
+ border-radius: 50%;
5467
+ background: var(--_accent);
5468
+ }
5469
+
5435
5470
  /* Settled: a themeable glyph coloured by outcome. */
5436
5471
  .tool-call[data-status="done"] .tool-call-icon::before {
5437
5472
  content: var(--_tool-icon-done);
@@ -5474,6 +5509,10 @@ var STYLES = `
5474
5509
  color: var(--_muted);
5475
5510
  }
5476
5511
 
5512
+ .tool-call[data-status="deferred"] .tool-call-status {
5513
+ color: var(--_accent);
5514
+ }
5515
+
5477
5516
  .tool-call[data-status="done"] .tool-call-status {
5478
5517
  color: var(--_success);
5479
5518
  }
@@ -5567,12 +5606,41 @@ var STYLES = `
5567
5606
 
5568
5607
  /* A pending card has no result yet, and in the modes where the arguments are
5569
5608
  hidden too there is nothing behind the toggle. Hide the control rather than
5570
- offer one that expands onto nothing. */
5609
+ offer one that expands onto nothing. A deferred card is the same, and its
5610
+ arguments are shown unconditionally by the rules below. */
5571
5611
  .tool-call[data-status="pending"] .tool-call-toggle,
5612
+ .tool-call[data-status="deferred"] .tool-call-toggle,
5572
5613
  :host([data-tool-display="inline"]) .tool-call[data-status="pending"] .tool-call-toggle {
5573
5614
  display: none;
5574
5615
  }
5575
5616
 
5617
+ /* The approval prompt for a gated call, rendered inside that call's own card.
5618
+ Empty on every card nobody is being asked about, so it collapses instead of
5619
+ adding a gap to each one. */
5620
+ .tool-call-approval:empty {
5621
+ display: none;
5622
+ }
5623
+
5624
+ .tool-call-approval {
5625
+ margin-top: 8px;
5626
+ }
5627
+
5628
+ /* A card that is asking a question shows what it is asking about, in every
5629
+ display mode. Three gated calls of one tool ask the same words, so the
5630
+ arguments are the only thing telling them apart, and a density setting must
5631
+ not be able to hide the answer to "which one is this". */
5632
+ :host([data-tool-display="minimal"]) .tool-call[data-status="deferred"] .tool-call-body {
5633
+ display: flex;
5634
+ }
5635
+
5636
+ /* The arguments region only, never every section: the result region carries the
5637
+ hidden attribute until a result exists, and a display value here overrides it,
5638
+ framing an empty RESULT heading under the question. */
5639
+ :host([data-tool-display="compact"]) .tool-call[data-status="deferred"] .tool-call-section--args,
5640
+ :host([data-tool-display="inline"]) .tool-call[data-status="deferred"] .tool-call-section--args {
5641
+ display: flex;
5642
+ }
5643
+
5576
5644
  .tool-call-toggle {
5577
5645
  align-self: flex-start;
5578
5646
  border: none;
@@ -5792,6 +5860,20 @@ var STYLES = `
5792
5860
  opacity: 0.6;
5793
5861
  }
5794
5862
 
5863
+ /* The same trap the attachment tray carries a note about, two rules along: an
5864
+ author display beats the UA stylesheet's rule for the hidden property, so a
5865
+ button the element has explicitly hidden keeps laying out and painting. The
5866
+ clip is hidden until a host supplies an upload handler or an attachments URL,
5867
+ and without this it is a visible control that cannot do anything.
5868
+
5869
+ The mic needs no such rule, and the asymmetry is worth knowing before adding
5870
+ one: it is not hidden when unconfigured, it is never built. The voice wiring
5871
+ returns before constructing the button, leaving only an empty voice slot that
5872
+ is display: contents. A hidden-state rule for the mic would match nothing. */
5873
+ .attach-btn[hidden] {
5874
+ display: none;
5875
+ }
5876
+
5795
5877
  /* Send closes the row on the right: a circle, the only filled control in the
5796
5878
  composer, so "the thing that acts" reads at a glance. */
5797
5879
  .send {
@@ -6401,47 +6483,123 @@ var STYLES = `
6401
6483
  opacity: 0.7;
6402
6484
  }
6403
6485
 
6486
+ /* A row is a label and two buttons, and nothing about the row itself is
6487
+ pressable. It used to light up on hover, which is the affordance of something
6488
+ clickable and made the buttons look like decoration on a clickable strip. The
6489
+ resting surface groups the row instead, so hover can mean what it says: only
6490
+ the buttons respond to it. */
6491
+ /* A row is a label and two buttons, and nothing about the row itself is
6492
+ pressable. It used to light up on hover, which is the affordance of something
6493
+ clickable and made the buttons look like decoration on a clickable strip. The
6494
+ resting surface groups the row instead, so hover can mean what it says: only
6495
+ the buttons respond to it.
6496
+
6497
+ It wraps for the same reason the tool-call head does. Every child but the label
6498
+ is fixed-width, so in a narrow panel the label is the only thing that can give
6499
+ -- and a flex-basis of zero lets it give everything. Adding the run id was
6500
+ enough to crush "just now" to zero pixels: present, correct, and invisible.
6501
+ Wrapping puts the buttons on their own line instead. */
6404
6502
  .checkpoint-row {
6405
6503
  display: flex;
6504
+ flex-wrap: wrap;
6406
6505
  align-items: center;
6407
6506
  gap: 0.5rem;
6408
- padding: 0.25rem;
6507
+ padding: 0.3125rem 0.4375rem;
6409
6508
  border-radius: 0.375rem;
6410
- }
6411
-
6412
- .checkpoint-row:hover {
6413
6509
  background: var(--_hover);
6414
6510
  }
6415
6511
 
6512
+ /* Grows into spare room, and refuses to shrink past the shortest thing it ever
6513
+ says. A time is short and bounded, so there is no case for eliding it. */
6416
6514
  .checkpoint-label {
6417
- flex: 1;
6515
+ flex: 1 1 auto;
6516
+ min-width: 7ch;
6418
6517
  font-size: 0.8125rem;
6419
6518
  white-space: nowrap;
6420
6519
  overflow: hidden;
6421
6520
  text-overflow: ellipsis;
6422
6521
  }
6423
6522
 
6523
+ /* When the label holds the run's first message, the time moves here: still worth
6524
+ showing, no longer what identifies the row. Muted and unshrinkable, so it does
6525
+ not compete with the words beside it. */
6526
+ .checkpoint-time {
6527
+ flex: 0 0 auto;
6528
+ font-size: 0.6875rem;
6529
+ opacity: 0.7;
6530
+ white-space: nowrap;
6531
+ }
6532
+
6533
+ /* Enough of the run id to tell two runs apart when both say "just now". Muted
6534
+ and monospaced: it is a reference, not a name. */
6535
+ .checkpoint-id {
6536
+ flex: 0 0 auto;
6537
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
6538
+ font-size: 0.6875rem;
6539
+ opacity: 0.55;
6540
+ }
6541
+
6542
+ /* On the panel's own surface, not the row's: the row now paints the hover token
6543
+ itself, and a badge the same colour as what it sits on is not a badge. */
6424
6544
  .checkpoint-branch {
6425
6545
  font-size: 0.6875rem;
6426
6546
  padding: 0 0.375rem;
6427
6547
  border-radius: 999px;
6428
- background: var(--_hover);
6548
+ background: var(--_assistant-bg);
6429
6549
  opacity: 0.8;
6430
6550
  }
6431
6551
 
6552
+ /* The two things in the row that actually do something, so they are the two
6553
+ things that look like it: a filled surface at rest rather than a transparent
6554
+ outline, which on top of the old row highlight was nearly invisible. */
6432
6555
  .checkpoint-action {
6433
6556
  font: inherit;
6434
6557
  font-size: 0.75rem;
6558
+ line-height: 1.4;
6435
6559
  cursor: pointer;
6436
- padding: 0.125rem 0.5rem;
6560
+ padding: 0.1875rem 0.5625rem;
6437
6561
  border: 1px solid var(--_border);
6438
6562
  border-radius: 0.375rem;
6439
- background: transparent;
6563
+ background: var(--_bg);
6440
6564
  color: inherit;
6565
+ transition:
6566
+ background var(--_motion) var(--_ease),
6567
+ border-color var(--_motion) var(--_ease),
6568
+ transform var(--_motion) var(--_ease);
6441
6569
  }
6442
6570
 
6443
- .checkpoint-action:hover {
6571
+ /* Resume is what a reader wants nine times in ten; fork is the deliberate choice
6572
+ beside it. Filled and outlined, the same pair the confirmation and approval
6573
+ cards already use for their primary and secondary action. */
6574
+ .checkpoint-resume {
6575
+ font-weight: 600;
6576
+ border-color: var(--_accent);
6577
+ background: var(--_accent);
6578
+ color: #ffffff;
6579
+ }
6580
+
6581
+ .checkpoint-fork:hover {
6444
6582
  background: var(--_hover);
6583
+ border-color: var(--_accent);
6584
+ }
6585
+
6586
+ /* The filled one cannot go lighter on hover without losing its contrast with the
6587
+ white label, so it dims instead. */
6588
+ .checkpoint-resume:hover {
6589
+ opacity: 0.88;
6590
+ }
6591
+
6592
+ /* Pressed: a pixel down, so the click is felt as well as seen. */
6593
+ .checkpoint-action:active {
6594
+ transform: translateY(1px);
6595
+ }
6596
+
6597
+ /* Keyboard focus was invisible here, in a panel that traps focus and is reached
6598
+ by Tab -- so the one navigation path guaranteed to land on these buttons was
6599
+ the one with nothing to show for it. */
6600
+ .checkpoint-action:focus-visible {
6601
+ outline: 2px solid var(--_accent);
6602
+ outline-offset: 2px;
6445
6603
  }
6446
6604
 
6447
6605
  .drawer-backdrop {
@@ -6938,6 +7096,7 @@ var ThreadDrawer = class {
6938
7096
  function statusLabels(strings) {
6939
7097
  return {
6940
7098
  [TOOL_CALL_STATUS.PENDING]: strings.toolRunning,
7099
+ [TOOL_CALL_STATUS.DEFERRED]: strings.toolDeferred,
6941
7100
  [TOOL_CALL_STATUS.DONE]: strings.toolDone,
6942
7101
  [TOOL_CALL_STATUS.ERROR]: strings.toolError,
6943
7102
  [TOOL_CALL_STATUS.DECLINED]: strings.toolDeclined
@@ -6960,6 +7119,20 @@ function formatPayload(text2) {
6960
7119
  var ToolCallCard = class {
6961
7120
  /** The card's root element; append this into the message list. */
6962
7121
  element;
7122
+ /**
7123
+ * Where a question about *this* call renders — the approval prompt for a
7124
+ * server-side tool the run deferred.
7125
+ *
7126
+ * It belongs to the card rather than to the transcript because a run can defer
7127
+ * several calls at once, and a prompt written per *tool* ("Add this event to
7128
+ * the board?") is identical for every one of them. Rendered into the answer
7129
+ * group they were three anonymous copies of one question, below the three
7130
+ * cards they gated; rendered here, position identifies them and the arguments
7131
+ * are already on screen above the question.
7132
+ *
7133
+ * Empty until used, and hidden while empty by the shadow CSS.
7134
+ */
7135
+ approvalSlot;
6963
7136
  #status;
6964
7137
  #decision;
6965
7138
  #toggle;
@@ -7015,7 +7188,24 @@ var ToolCallCard = class {
7015
7188
  body.className = "tool-call-body";
7016
7189
  body.setAttribute("part", "tool-card-body");
7017
7190
  body.append(argsSection.root, resultSection.root);
7018
- this.element.append(head, this.#toggle, body);
7191
+ this.approvalSlot = document.createElement("div");
7192
+ this.approvalSlot.className = "tool-call-approval";
7193
+ this.approvalSlot.setAttribute("part", "tool-card-approval");
7194
+ this.element.append(head, this.#toggle, body, this.approvalSlot);
7195
+ }
7196
+ /**
7197
+ * Move between the two states that are not an outcome — `pending` (running)
7198
+ * and `deferred` (gated, waiting on a person).
7199
+ *
7200
+ * Ignored once {@link settle} has run: a card that was declined must not be
7201
+ * talked back into looking live by a late event.
7202
+ */
7203
+ mark(status) {
7204
+ if (this.#settled) {
7205
+ return;
7206
+ }
7207
+ this.element.setAttribute("data-status", status);
7208
+ this.#status.textContent = statusLabels(this.#strings)[status];
7019
7209
  }
7020
7210
  /**
7021
7211
  * Record that a human approved or declined this call, as a line in the card.
@@ -7483,6 +7673,7 @@ var THREAD_SUFFIX = "thread";
7483
7673
  var THREADS_SUFFIX = "threads";
7484
7674
  var MESSAGES_SUFFIX = "messages:";
7485
7675
  var CHECKPOINT_SUFFIX = "checkpoint:";
7676
+ var MINTED_SUFFIX = "minted:";
7486
7677
  var TITLE_LIMIT = 60;
7487
7678
  var PREVIEW_LIMIT = 100;
7488
7679
  var DEFAULT_TITLE = "New conversation";
@@ -7502,13 +7693,18 @@ var SessionStorageStore = class {
7502
7693
  }
7503
7694
  const id = randomUUID3();
7504
7695
  sessionStorage.setItem(key, id);
7696
+ sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
7505
7697
  return id;
7506
7698
  }
7699
+ isUnsent(threadId) {
7700
+ return sessionStorage.getItem(this.#key(MINTED_SUFFIX + threadId)) !== null && sessionStorage.getItem(this.#key(MESSAGES_SUFFIX + threadId)) === null;
7701
+ }
7507
7702
  loadMessages(threadId) {
7508
7703
  return Promise.resolve(this.#readJson(this.#key(MESSAGES_SUFFIX + threadId)));
7509
7704
  }
7510
7705
  saveMessages(threadId, messages) {
7511
7706
  sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
7707
+ sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
7512
7708
  this.#touchThread(threadId, messages);
7513
7709
  }
7514
7710
  loadCheckpoint(threadId) {
@@ -7525,6 +7721,7 @@ var SessionStorageStore = class {
7525
7721
  clear(threadId) {
7526
7722
  sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
7527
7723
  sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
7724
+ sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
7528
7725
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
7529
7726
  if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
7530
7727
  sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
@@ -7713,6 +7910,10 @@ var RemoteConversationStore = class {
7713
7910
  setActiveThread(threadId) {
7714
7911
  this.#local.setActiveThread(threadId);
7715
7912
  }
7913
+ /** Delegated, so wrapping a store does not lose what it knows about its own ids. */
7914
+ isUnsent(threadId) {
7915
+ return this.#local.isUnsent?.(threadId) === true;
7916
+ }
7716
7917
  saveMessages(threadId, messages) {
7717
7918
  this.#local.saveMessages(threadId, messages);
7718
7919
  }
@@ -7740,6 +7941,9 @@ var RemoteConversationStore = class {
7740
7941
  return rows.filter((row) => !this.#dropped.has(row.thread_id)).map((row) => this.#toMeta(row));
7741
7942
  }
7742
7943
  async loadMessages(threadId) {
7944
+ if (this.#local.isUnsent?.(threadId) === true) {
7945
+ return null;
7946
+ }
7743
7947
  const response = await this.#get(`${this.#url}${encodeURIComponent(threadId)}/`);
7744
7948
  if (response === null || !response.ok) {
7745
7949
  return this.#local.loadMessages(threadId);
@@ -8166,6 +8370,12 @@ var AgUiChat = class extends HTMLElement {
8166
8370
  * the real output with the generic "executed on the server" fallback.
8167
8371
  */
8168
8372
  #serverSettled = /* @__PURE__ */ new Set();
8373
+ /**
8374
+ * Tool calls made during the current interaction, in the order they started,
8375
+ * so {@link RUN_FINISHED_EVENT} can report them once the whole thing settles.
8376
+ * Spans tool rounds and an approval interrupt; cleared when the event fires.
8377
+ */
8378
+ #runTools = [];
8169
8379
  #root;
8170
8380
  #chat;
8171
8381
  #messages;
@@ -9196,6 +9406,7 @@ var AgUiChat = class extends HTMLElement {
9196
9406
  * {@link toggleCollapsed} and {@link toggleTheme}.
9197
9407
  */
9198
9408
  openThreads() {
9409
+ this.#checkpoints.close();
9199
9410
  void this.#refreshDrawer();
9200
9411
  this.#drawer.open();
9201
9412
  }
@@ -9207,9 +9418,27 @@ var AgUiChat = class extends HTMLElement {
9207
9418
  * an empty panel.
9208
9419
  */
9209
9420
  openCheckpoints() {
9421
+ this.#drawer.close();
9210
9422
  void this.#refreshCheckpoints();
9211
9423
  this.#checkpoints.open();
9212
9424
  }
9425
+ /** Close the checkpoints panel, if it is open. */
9426
+ closeCheckpoints() {
9427
+ this.#checkpoints.close();
9428
+ }
9429
+ /**
9430
+ * Open the checkpoints panel, or close it if it is already open — what the
9431
+ * built-in ⭯ button does, because a control that opens a panel is read as the
9432
+ * control that also dismisses it. {@link openCheckpoints} stays open-only for a
9433
+ * host that means exactly that.
9434
+ */
9435
+ toggleCheckpoints() {
9436
+ if (this.#checkpoints.open_) {
9437
+ this.#checkpoints.close();
9438
+ return;
9439
+ }
9440
+ this.openCheckpoints();
9441
+ }
9213
9442
  /**
9214
9443
  * Start a fresh conversation: forget the persisted history, drop the
9215
9444
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -9474,8 +9703,8 @@ var AgUiChat = class extends HTMLElement {
9474
9703
  controls.setAttribute("part", "header-controls");
9475
9704
  const history = this.#headerButton("history", this.#strings.chatHistory, "\u2630");
9476
9705
  history.addEventListener("click", () => this.openThreads());
9477
- const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "\u2B6F");
9478
- checkpoints.addEventListener("click", () => this.openCheckpoints());
9706
+ const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "\u21BA");
9707
+ checkpoints.addEventListener("click", () => this.toggleCheckpoints());
9479
9708
  const newChat = this.#headerButton("new", this.#strings.newChat, "\u271A");
9480
9709
  newChat.addEventListener("click", () => this.newChat());
9481
9710
  const collapse = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
@@ -9576,6 +9805,16 @@ var AgUiChat = class extends HTMLElement {
9576
9805
  this.#drawer.element,
9577
9806
  this.#checkpoints.element
9578
9807
  );
9808
+ this.#chat.addEventListener("pointerdown", (event) => {
9809
+ if (!this.#checkpoints.open_) {
9810
+ return;
9811
+ }
9812
+ const path = event.composedPath();
9813
+ if (path.includes(this.#checkpoints.element) || path.includes(checkpoints)) {
9814
+ return;
9815
+ }
9816
+ this.#checkpoints.close();
9817
+ });
9579
9818
  this.#launcher.className = "launcher";
9580
9819
  this.#launcher.type = "button";
9581
9820
  this.#launcher.setAttribute("part", "launcher");
@@ -9993,39 +10232,61 @@ var AgUiChat = class extends HTMLElement {
9993
10232
  * Render an approval card per server-side-tool interrupt and collect the
9994
10233
  * user's decisions (approve → run it, deny → decline it).
9995
10234
  *
10235
+ * **One card per gated call, in that call's own tool card, all at once.** A run
10236
+ * can defer several calls, and the wire answers each independently — so the UI
10237
+ * has to let a person answer each independently, which means saying which is
10238
+ * which. The prompt cannot: it comes from the tool's `x-confirm` and is
10239
+ * identical for every call of that tool. The tool card can, by position, and it
10240
+ * is already showing the arguments. Asking them serially was the other half of
10241
+ * the problem: the second question only appeared once the first was answered,
10242
+ * so a person could neither compare them nor tell that more were coming.
10243
+ *
10244
+ * Each gated card is marked `deferred` for the wait. That is not cosmetic — at
10245
+ * `pending` it read "running…" while the stream was over and the server idle.
10246
+ *
9996
10247
  * The run is suspended on these cards. A Stop while any is open aborts the
9997
10248
  * shared {@link #confirmAbort} controller, resolving every still-open card as
9998
10249
  * denied. An approved tool runs on the follow-up resume run and streams its
9999
- * result into the same pending card; a denied one settles here, since no
10000
- * result will ever arrive.
10250
+ * result into the same card (returned to `pending`, since it now really is
10251
+ * running); a denied one settles here, as no result will ever arrive.
10001
10252
  */
10002
10253
  async #resolveInterrupts(interrupts) {
10003
- const responses = {};
10004
10254
  this.#confirmAbort = new AbortController();
10005
10255
  this.#hidePending();
10006
- for (const interrupt of interrupts) {
10007
- const request = {};
10008
- if (interrupt.message !== void 0) {
10009
- request.message = interrupt.message;
10010
- }
10011
- const card = interrupt.toolCallId !== void 0 ? this.#toolCards.get(interrupt.toolCallId) : void 0;
10012
- const toolName = card?.element.getAttribute("data-tool-name");
10013
- if (toolName !== null && toolName !== void 0) {
10014
- request.toolName = toolName;
10015
- }
10016
- const signal = this.#confirmAbort.signal;
10017
- const approved = this.approvalRenderer !== null ? await this.approvalRenderer(request, { signal }) : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
10018
- this.#updateEmptyState();
10019
- this.#messages.scrollTop = this.#messages.scrollHeight;
10020
- card?.recordDecision(approved ? "approved" : "declined");
10021
- if (approved) {
10022
- responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
10023
- } else {
10024
- responses[interrupt.id] = { status: "cancelled" };
10025
- card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
10026
- }
10027
- }
10256
+ const signal = this.#confirmAbort.signal;
10257
+ const answered = await Promise.all(
10258
+ interrupts.map(async (interrupt) => {
10259
+ const card = interrupt.toolCallId !== void 0 ? this.#toolCards.get(interrupt.toolCallId) : void 0;
10260
+ const request = {};
10261
+ const phrase = confirmPhrase(interrupt) ?? interrupt.message;
10262
+ if (phrase !== void 0) {
10263
+ request.message = phrase;
10264
+ }
10265
+ const toolName = card?.element.getAttribute("data-tool-name");
10266
+ if (toolName !== null && toolName !== void 0) {
10267
+ request.toolName = toolName;
10268
+ }
10269
+ card?.mark(TOOL_CALL_STATUS.DEFERRED);
10270
+ const approved = this.approvalRenderer !== null ? await this.approvalRenderer(request, { signal }) : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
10271
+ signal,
10272
+ strings: this.#strings
10273
+ });
10274
+ card?.recordDecision(approved ? "approved" : "declined");
10275
+ if (approved) {
10276
+ card?.mark(TOOL_CALL_STATUS.PENDING);
10277
+ } else {
10278
+ card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
10279
+ }
10280
+ return { id: interrupt.id, approved };
10281
+ })
10282
+ );
10283
+ this.#updateEmptyState();
10284
+ this.#messages.scrollTop = this.#messages.scrollHeight;
10028
10285
  this.#confirmAbort = null;
10286
+ const responses = {};
10287
+ for (const { id, approved } of answered) {
10288
+ responses[id] = approved ? { status: "resolved", payload: { approved: true } } : { status: "cancelled" };
10289
+ }
10029
10290
  return responses;
10030
10291
  }
10031
10292
  #handlers() {
@@ -10064,6 +10325,7 @@ var AgUiChat = class extends HTMLElement {
10064
10325
  if (this.#noticeIfSkillLoad(call)) {
10065
10326
  return;
10066
10327
  }
10328
+ this.#runTools.push({ id: call.id, name: call.name });
10067
10329
  this.#cardFor(call);
10068
10330
  },
10069
10331
  onActivity: (activityType, content) => {
@@ -10117,9 +10379,33 @@ var AgUiChat = class extends HTMLElement {
10117
10379
  }
10118
10380
  this.#currentGroup = null;
10119
10381
  this.#thoughts = null;
10382
+ this.#dispatchRunFinished();
10120
10383
  }
10121
10384
  };
10122
10385
  }
10386
+ /**
10387
+ * Tell the host the interaction is over and what ran in it.
10388
+ *
10389
+ * Last thing in `onSettled`, so a listener that refetches sees a transcript
10390
+ * that has already stopped changing. `side` is read from the streamed-result
10391
+ * bookkeeping rather than from the tool list: whether a call executed on the
10392
+ * server is a fact about the run, and a name can appear on both sides across a
10393
+ * conversation.
10394
+ */
10395
+ #dispatchRunFinished() {
10396
+ const tools = this.#runTools.map(({ id, name }) => ({
10397
+ name,
10398
+ side: this.#serverSettled.has(id) ? "server" : "client"
10399
+ }));
10400
+ this.#runTools = [];
10401
+ this.dispatchEvent(
10402
+ new CustomEvent(RUN_FINISHED_EVENT, {
10403
+ detail: { tools },
10404
+ bubbles: true,
10405
+ composed: true
10406
+ })
10407
+ );
10408
+ }
10123
10409
  /** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
10124
10410
  #appendStoppedNote() {
10125
10411
  const note = document.createElement("div");
@@ -10231,6 +10517,10 @@ var AgUiChat = class extends HTMLElement {
10231
10517
  return card;
10232
10518
  }
10233
10519
  };
10520
+ function confirmPhrase(interrupt) {
10521
+ const phrase = interrupt.metadata?.[X_CONFIRM_KEY];
10522
+ return typeof phrase === "string" && phrase.trim() !== "" ? phrase : void 0;
10523
+ }
10234
10524
  function restoredToolCalls(value) {
10235
10525
  return Array.isArray(value) ? value.filter(isRestoredToolCall) : [];
10236
10526
  }
@@ -10293,7 +10583,7 @@ function setControlValue(el, value) {
10293
10583
  }
10294
10584
 
10295
10585
  // src/version.ts
10296
- var VERSION = "0.23.1";
10586
+ var VERSION = "0.25.0";
10297
10587
  export {
10298
10588
  ATTACHMENT_EVENT,
10299
10589
  AgUiChat,
@@ -10308,6 +10598,7 @@ export {
10308
10598
  MAX_TOOL_ROUNDS,
10309
10599
  MESSAGE_ROLE,
10310
10600
  PAGE_ACTIONS,
10601
+ RUN_FINISHED_EVENT,
10311
10602
  RemoteConversationStore,
10312
10603
  RunIndex,
10313
10604
  STATE_EVENT,