@artooi/ag-ui-web-component 0.24.0 → 0.25.1

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.
@@ -1596,6 +1596,11 @@ export class AgUiChat extends HTMLElement {
1596
1596
  * {@link toggleCollapsed} and {@link toggleTheme}.
1597
1597
  */
1598
1598
  openThreads(): void {
1599
+ // Two overlapping surfaces, so opening one dismisses the other. Clicking away
1600
+ // already covers the built-in buttons, but a host driving its own chrome
1601
+ // through these methods raises no pointer event — and the drawer would then
1602
+ // open *underneath* a popover still floating over it.
1603
+ this.#checkpoints.close();
1599
1604
  void this.#refreshDrawer();
1600
1605
  this.#drawer.open();
1601
1606
  }
@@ -1608,10 +1613,31 @@ export class AgUiChat extends HTMLElement {
1608
1613
  * an empty panel.
1609
1614
  */
1610
1615
  openCheckpoints(): void {
1616
+ // The other half of the pair — see `openThreads`.
1617
+ this.#drawer.close();
1611
1618
  void this.#refreshCheckpoints();
1612
1619
  this.#checkpoints.open();
1613
1620
  }
1614
1621
 
1622
+ /** Close the checkpoints panel, if it is open. */
1623
+ closeCheckpoints(): void {
1624
+ this.#checkpoints.close();
1625
+ }
1626
+
1627
+ /**
1628
+ * Open the checkpoints panel, or close it if it is already open — what the
1629
+ * built-in ⭯ button does, because a control that opens a panel is read as the
1630
+ * control that also dismisses it. {@link openCheckpoints} stays open-only for a
1631
+ * host that means exactly that.
1632
+ */
1633
+ toggleCheckpoints(): void {
1634
+ if (this.#checkpoints.open_) {
1635
+ this.#checkpoints.close();
1636
+ return;
1637
+ }
1638
+ this.openCheckpoints();
1639
+ }
1640
+
1615
1641
  /**
1616
1642
  * Start a fresh conversation: forget the persisted history, drop the
1617
1643
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -1933,8 +1959,11 @@ export class AgUiChat extends HTMLElement {
1933
1959
  const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
1934
1960
  history.addEventListener("click", () => this.openThreads());
1935
1961
 
1936
- const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
1937
- checkpoints.addEventListener("click", () => this.openCheckpoints());
1962
+ // rather than ⭯: the same idea in a glyph that has a font behind it in
1963
+ // every browser. The obscure one rendered as an unreadable mark at 14px, and a
1964
+ // header control nobody can name is one nobody presses.
1965
+ const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "↺");
1966
+ checkpoints.addEventListener("click", () => this.toggleCheckpoints());
1938
1967
 
1939
1968
  const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
1940
1969
  newChat.addEventListener("click", () => this.newChat());
@@ -2077,6 +2106,25 @@ export class AgUiChat extends HTMLElement {
2077
2106
  this.#checkpoints.element,
2078
2107
  );
2079
2108
 
2109
+ // Clicking away dismisses the checkpoints popover. Escape already did, and the
2110
+ // drawer has a backdrop that swallows the click — this popover has neither, so
2111
+ // it could only be closed by answering it.
2112
+ //
2113
+ // `pointerdown`, and the header button excluded: pointerdown runs *before* the
2114
+ // button's own click, so closing here and toggling there would land back open.
2115
+ // Composed path rather than `target`, because the event is retargeted at the
2116
+ // shadow boundary and every one of these nodes is inside it.
2117
+ this.#chat.addEventListener("pointerdown", (event) => {
2118
+ if (!this.#checkpoints.open_) {
2119
+ return;
2120
+ }
2121
+ const path = event.composedPath();
2122
+ if (path.includes(this.#checkpoints.element) || path.includes(checkpoints)) {
2123
+ return;
2124
+ }
2125
+ this.#checkpoints.close();
2126
+ });
2127
+
2080
2128
  // What a collapsed widget shrinks to: a round floating button, or the slim
2081
2129
  // edge rail under `placement="sidebar"` — one element, shaped by CSS.
2082
2130
  // A sibling of the panel, so it survives the panel being hidden.
@@ -2590,56 +2638,82 @@ export class AgUiChat extends HTMLElement {
2590
2638
  * Render an approval card per server-side-tool interrupt and collect the
2591
2639
  * user's decisions (approve → run it, deny → decline it).
2592
2640
  *
2641
+ * **One card per gated call, in that call's own tool card, all at once.** A run
2642
+ * can defer several calls, and the wire answers each independently — so the UI
2643
+ * has to let a person answer each independently, which means saying which is
2644
+ * which. The prompt cannot: it comes from the tool's `x-confirm` and is
2645
+ * identical for every call of that tool. The tool card can, by position, and it
2646
+ * is already showing the arguments. Asking them serially was the other half of
2647
+ * the problem: the second question only appeared once the first was answered,
2648
+ * so a person could neither compare them nor tell that more were coming.
2649
+ *
2650
+ * Each gated card is marked `deferred` for the wait. That is not cosmetic — at
2651
+ * `pending` it read "running…" while the stream was over and the server idle.
2652
+ *
2593
2653
  * The run is suspended on these cards. A Stop while any is open aborts the
2594
2654
  * shared {@link #confirmAbort} controller, resolving every still-open card as
2595
2655
  * denied. An approved tool runs on the follow-up resume run and streams its
2596
- * result into the same pending card; a denied one settles here, since no
2597
- * result will ever arrive.
2656
+ * result into the same card (returned to `pending`, since it now really is
2657
+ * running); a denied one settles here, as no result will ever arrive.
2598
2658
  */
2599
2659
  async #resolveInterrupts(
2600
2660
  interrupts: readonly Interrupt[],
2601
2661
  ): Promise<Record<string, InterruptResponse>> {
2602
- const responses: Record<string, InterruptResponse> = {};
2603
2662
  // One controller covers the whole batch: a single Stop denies all of them.
2604
2663
  this.#confirmAbort = new AbortController();
2605
2664
  this.#hidePending();
2606
- for (const interrupt of interrupts) {
2607
- const request: ApprovalRequest = {};
2608
- const phrase = confirmPhrase(interrupt) ?? interrupt.message;
2609
- if (phrase !== undefined) {
2610
- request.message = phrase;
2611
- }
2612
- const card =
2613
- interrupt.toolCallId !== undefined ? this.#toolCards.get(interrupt.toolCallId) : undefined;
2614
- const toolName = card?.element.getAttribute("data-tool-name");
2615
- if (toolName !== null && toolName !== undefined) {
2616
- request.toolName = toolName;
2617
- }
2618
- const signal = this.#confirmAbort.signal;
2619
- // A host-supplied renderer takes full control of the approval UI;
2620
- // otherwise the built-in inline card renders into the current answer group.
2621
- const approved =
2622
- this.approvalRenderer !== null
2623
- ? await this.approvalRenderer(request, { signal })
2624
- : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
2625
- this.#updateEmptyState();
2626
- this.#messages.scrollTop = this.#messages.scrollHeight;
2627
- // Same annotation as the client-side confirmation gate. Without it the
2628
- // two gates read differently for the same act: a locally-confirmed call
2629
- // said who let it through and a server-gated one said nothing, which is
2630
- // backwards, since the server-side gate is the one guarding the tools
2631
- // that actually run on the backend.
2632
- card?.recordDecision(approved ? "approved" : "declined");
2633
- if (approved) {
2634
- responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
2635
- } else {
2636
- responses[interrupt.id] = { status: "cancelled" };
2637
- // No TOOL_CALL_RESULT will stream for a denied tool — settle its pending
2638
- // card now rather than leaving it hanging until the onSettled sweep.
2639
- card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
2640
- }
2641
- }
2665
+ const signal = this.#confirmAbort.signal;
2666
+ const answered = await Promise.all(
2667
+ interrupts.map(async (interrupt) => {
2668
+ const card =
2669
+ interrupt.toolCallId !== undefined
2670
+ ? this.#toolCards.get(interrupt.toolCallId)
2671
+ : undefined;
2672
+ const request: ApprovalRequest = {};
2673
+ const phrase = confirmPhrase(interrupt) ?? interrupt.message;
2674
+ if (phrase !== undefined) {
2675
+ request.message = phrase;
2676
+ }
2677
+ const toolName = card?.element.getAttribute("data-tool-name");
2678
+ if (toolName !== null && toolName !== undefined) {
2679
+ request.toolName = toolName;
2680
+ }
2681
+ card?.mark(TOOL_CALL_STATUS.DEFERRED);
2682
+ // A host-supplied renderer takes full control of the approval UI. The
2683
+ // built-in card renders into the gated call's own card, falling back to
2684
+ // the answer group when the interrupt names no call we hold one for.
2685
+ const approved =
2686
+ this.approvalRenderer !== null
2687
+ ? await this.approvalRenderer(request, { signal })
2688
+ : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
2689
+ signal,
2690
+ strings: this.#strings,
2691
+ });
2692
+ // Same annotation as the client-side confirmation gate. Without it the
2693
+ // two gates read differently for the same act: a locally-confirmed call
2694
+ // said who let it through and a server-gated one said nothing, which is
2695
+ // backwards, since the server-side gate is the one guarding the tools
2696
+ // that actually run on the backend.
2697
+ card?.recordDecision(approved ? "approved" : "declined");
2698
+ if (approved) {
2699
+ card?.mark(TOOL_CALL_STATUS.PENDING);
2700
+ } else {
2701
+ // No TOOL_CALL_RESULT will stream for a denied tool — settle its card
2702
+ // now rather than leaving it hanging until the onSettled sweep.
2703
+ card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
2704
+ }
2705
+ return { id: interrupt.id, approved };
2706
+ }),
2707
+ );
2708
+ this.#updateEmptyState();
2709
+ this.#messages.scrollTop = this.#messages.scrollHeight;
2642
2710
  this.#confirmAbort = null;
2711
+ const responses: Record<string, InterruptResponse> = {};
2712
+ for (const { id, approved } of answered) {
2713
+ responses[id] = approved
2714
+ ? { status: "resolved", payload: { approved: true } }
2715
+ : { status: "cancelled" };
2716
+ }
2643
2717
  return responses;
2644
2718
  }
2645
2719
 
@@ -8,6 +8,16 @@ export interface RunRow {
8
8
  readonly started_at: string | null;
9
9
  /** Whether the run has a snapshot to seed from — see {@link RunIndex}. */
10
10
  readonly continuable: boolean;
11
+ /**
12
+ * The run's first user message, one line, already truncated by the server —
13
+ * the only field in a row a person recognises a conversation by.
14
+ *
15
+ * Optional because a server predating the field does not send it, and
16
+ * `null` where the run holds no words to show (seeded from history alone, or
17
+ * opened with an image and no caption). A row without one falls back to the
18
+ * time plus a short id, which is what every row used to be.
19
+ */
20
+ readonly preview?: string | null;
11
21
  }
12
22
 
13
23
  /** Live header source, read per request so rotated tokens / CSRF reach the server. */
@@ -5,6 +5,25 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
5
5
  /** How the host continues a picked run. */
6
6
  export type CheckpointVerb = "resume" | "fork";
7
7
 
8
+ /** The run's first message, or `null` where the row has no words to show. */
9
+ function previewOf(run: RunRow): string | null {
10
+ return run.preview !== undefined && run.preview !== null && run.preview !== ""
11
+ ? run.preview
12
+ : null;
13
+ }
14
+
15
+ /**
16
+ * A preview reduced to what the row puts on screen, for comparing two rows.
17
+ *
18
+ * Whitespace is collapsed because the rendered row collapses it anyway, so two
19
+ * previews that differ only there are one label to the eye. Case is kept: two
20
+ * spellings a person can tell apart are two labels, and an id beside them would
21
+ * answer a question nobody asked.
22
+ */
23
+ function asShown(preview: string): string {
24
+ return preview.replace(/\s+/g, " ").trim();
25
+ }
26
+
8
27
  /**
9
28
  * The checkpoint panel: continuable runs, each offering **resume** or **fork**.
10
29
  *
@@ -151,28 +170,84 @@ export class CheckpointMenu {
151
170
  this.#list.append(empty);
152
171
  return;
153
172
  }
173
+ const repeated = this.#repeatedPreviews();
174
+ for (const run of this.#runs) {
175
+ this.#list.append(this.#row(run, repeated));
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The previews more than one row shows.
181
+ *
182
+ * A preview identifies a run only while it is that run's alone. A real index
183
+ * answered with five runs opening on the same sentence, and those rows read
184
+ * alike exactly as bare timestamps used to — so where the words repeat the
185
+ * short id comes back, and where they do not the row is left as it is.
186
+ */
187
+ #repeatedPreviews(): ReadonlySet<string> {
188
+ const seen = new Set<string>();
189
+ const repeated = new Set<string>();
154
190
  for (const run of this.#runs) {
155
- this.#list.append(this.#row(run));
191
+ const preview = previewOf(run);
192
+ if (preview === null) {
193
+ continue;
194
+ }
195
+ const shown = asShown(preview);
196
+ if (seen.has(shown)) {
197
+ repeated.add(shown);
198
+ }
199
+ seen.add(shown);
156
200
  }
201
+ return repeated;
157
202
  }
158
203
 
159
- #row(run: RunRow): HTMLDivElement {
204
+ #row(run: RunRow, repeated: ReadonlySet<string>): HTMLDivElement {
160
205
  const row = document.createElement("div");
161
206
  row.className = "checkpoint-row";
162
207
  row.setAttribute("part", "checkpoint-row");
163
208
 
209
+ const preview = previewOf(run);
210
+ const time =
211
+ run.started_at === null
212
+ ? null
213
+ : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
214
+
164
215
  const label = document.createElement("span");
165
216
  label.className = "checkpoint-label";
166
217
  label.setAttribute("part", "checkpoint-label");
167
- // A run id is opaque to a person, so the time is the identifying detail;
168
- // the id rides `title` for anyone who needs to correlate with server logs.
169
- label.textContent =
170
- run.started_at === null
171
- ? run.run_id
172
- : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
173
- label.title = run.run_id;
218
+ // What the run was about, if the server says. Otherwise the time, and only
219
+ // then the id a person recognises the first, reads the second, and
220
+ // recognises nothing at all in the third.
221
+ label.textContent = preview ?? time ?? run.run_id;
174
222
  row.append(label);
175
223
 
224
+ if (preview !== null && time !== null) {
225
+ // Demoted to a chip: with words in the label the time is no longer what
226
+ // identifies the run, but it still orders it.
227
+ const when = document.createElement("span");
228
+ when.className = "checkpoint-time";
229
+ when.setAttribute("part", "checkpoint-time");
230
+ when.textContent = time;
231
+ row.append(when);
232
+ }
233
+
234
+ // The id, for a row whose label does not identify the run on its own: two
235
+ // runs a few seconds apart both read "just now", and words another row also
236
+ // opens with name neither of them. Picking between either pair is picking
237
+ // blind. Shown rather than left in a tooltip, since a hover is not an
238
+ // identity either — and eight characters beats a full id on the row. A
239
+ // label that already is the id needs no second copy, and a run that arrived
240
+ // without one has nothing better to offer than the words it shares.
241
+ const ambiguous = preview === null ? time !== null : repeated.has(asShown(preview));
242
+ if (ambiguous && run.run_id !== "") {
243
+ const short = document.createElement("span");
244
+ short.className = "checkpoint-id";
245
+ short.setAttribute("part", "checkpoint-id");
246
+ short.textContent = run.run_id.slice(0, 8);
247
+ short.title = run.run_id;
248
+ row.append(short);
249
+ }
250
+
176
251
  if (run.parent_run_id !== null) {
177
252
  // Lineage, so a branch doesn't read as a duplicate of its parent.
178
253
  const branch = document.createElement("span");
package/src/ui/styles.ts CHANGED
@@ -973,6 +973,13 @@ export const STYLES = `
973
973
  to { transform: rotate(360deg); }
974
974
  }
975
975
 
976
+ /* Deferred: no spinner, because nothing is spinning. A steady accent dot, since
977
+ the state is waiting-on-you rather than an outcome. */
978
+ .tool-call[data-status="deferred"] .tool-call-icon {
979
+ border-radius: 50%;
980
+ background: var(--_accent);
981
+ }
982
+
976
983
  /* Settled: a themeable glyph coloured by outcome. */
977
984
  .tool-call[data-status="done"] .tool-call-icon::before {
978
985
  content: var(--_tool-icon-done);
@@ -1015,6 +1022,10 @@ export const STYLES = `
1015
1022
  color: var(--_muted);
1016
1023
  }
1017
1024
 
1025
+ .tool-call[data-status="deferred"] .tool-call-status {
1026
+ color: var(--_accent);
1027
+ }
1028
+
1018
1029
  .tool-call[data-status="done"] .tool-call-status {
1019
1030
  color: var(--_success);
1020
1031
  }
@@ -1108,12 +1119,41 @@ export const STYLES = `
1108
1119
 
1109
1120
  /* A pending card has no result yet, and in the modes where the arguments are
1110
1121
  hidden too there is nothing behind the toggle. Hide the control rather than
1111
- offer one that expands onto nothing. */
1122
+ offer one that expands onto nothing. A deferred card is the same, and its
1123
+ arguments are shown unconditionally by the rules below. */
1112
1124
  .tool-call[data-status="pending"] .tool-call-toggle,
1125
+ .tool-call[data-status="deferred"] .tool-call-toggle,
1113
1126
  :host([data-tool-display="inline"]) .tool-call[data-status="pending"] .tool-call-toggle {
1114
1127
  display: none;
1115
1128
  }
1116
1129
 
1130
+ /* The approval prompt for a gated call, rendered inside that call's own card.
1131
+ Empty on every card nobody is being asked about, so it collapses instead of
1132
+ adding a gap to each one. */
1133
+ .tool-call-approval:empty {
1134
+ display: none;
1135
+ }
1136
+
1137
+ .tool-call-approval {
1138
+ margin-top: 8px;
1139
+ }
1140
+
1141
+ /* A card that is asking a question shows what it is asking about, in every
1142
+ display mode. Three gated calls of one tool ask the same words, so the
1143
+ arguments are the only thing telling them apart, and a density setting must
1144
+ not be able to hide the answer to "which one is this". */
1145
+ :host([data-tool-display="minimal"]) .tool-call[data-status="deferred"] .tool-call-body {
1146
+ display: flex;
1147
+ }
1148
+
1149
+ /* The arguments region only, never every section: the result region carries the
1150
+ hidden attribute until a result exists, and a display value here overrides it,
1151
+ framing an empty RESULT heading under the question. */
1152
+ :host([data-tool-display="compact"]) .tool-call[data-status="deferred"] .tool-call-section--args,
1153
+ :host([data-tool-display="inline"]) .tool-call[data-status="deferred"] .tool-call-section--args {
1154
+ display: flex;
1155
+ }
1156
+
1117
1157
  .tool-call-toggle {
1118
1158
  align-self: flex-start;
1119
1159
  border: none;
@@ -1333,6 +1373,20 @@ export const STYLES = `
1333
1373
  opacity: 0.6;
1334
1374
  }
1335
1375
 
1376
+ /* The same trap the attachment tray carries a note about, two rules along: an
1377
+ author display beats the UA stylesheet's rule for the hidden property, so a
1378
+ button the element has explicitly hidden keeps laying out and painting. The
1379
+ clip is hidden until a host supplies an upload handler or an attachments URL,
1380
+ and without this it is a visible control that cannot do anything.
1381
+
1382
+ The mic needs no such rule, and the asymmetry is worth knowing before adding
1383
+ one: it is not hidden when unconfigured, it is never built. The voice wiring
1384
+ returns before constructing the button, leaving only an empty voice slot that
1385
+ is display: contents. A hidden-state rule for the mic would match nothing. */
1386
+ .attach-btn[hidden] {
1387
+ display: none;
1388
+ }
1389
+
1336
1390
  /* Send closes the row on the right: a circle, the only filled control in the
1337
1391
  composer, so "the thing that acts" reads at a glance. */
1338
1392
  .send {
@@ -1942,47 +1996,118 @@ export const STYLES = `
1942
1996
  opacity: 0.7;
1943
1997
  }
1944
1998
 
1999
+ /* A row is a label and two buttons, and nothing about the row itself is
2000
+ pressable. It used to light up on hover, which is the affordance of something
2001
+ clickable and made the buttons look like decoration on a clickable strip. The
2002
+ resting surface groups the row instead, so hover can mean what it says: only
2003
+ the buttons respond to it.
2004
+
2005
+ It wraps for the same reason the tool-call head does. Every child but the label
2006
+ is fixed-width, so in a narrow panel the label is the only thing that can give
2007
+ -- and a flex-basis of zero lets it give everything. Adding the run id was
2008
+ enough to crush "just now" to zero pixels: present, correct, and invisible.
2009
+ Wrapping puts the buttons on their own line instead. */
1945
2010
  .checkpoint-row {
1946
2011
  display: flex;
2012
+ flex-wrap: wrap;
1947
2013
  align-items: center;
1948
2014
  gap: 0.5rem;
1949
- padding: 0.25rem;
2015
+ padding: 0.3125rem 0.4375rem;
1950
2016
  border-radius: 0.375rem;
1951
- }
1952
-
1953
- .checkpoint-row:hover {
1954
2017
  background: var(--_hover);
1955
2018
  }
1956
2019
 
2020
+ /* Grows into spare room, and refuses to shrink past the shortest thing it ever
2021
+ says. A time is short and bounded, so there is no case for eliding it. */
1957
2022
  .checkpoint-label {
1958
- flex: 1;
2023
+ flex: 1 1 auto;
2024
+ min-width: 7ch;
1959
2025
  font-size: 0.8125rem;
1960
2026
  white-space: nowrap;
1961
2027
  overflow: hidden;
1962
2028
  text-overflow: ellipsis;
1963
2029
  }
1964
2030
 
2031
+ /* When the label holds the run's first message, the time moves here: still worth
2032
+ showing, no longer what identifies the row. Muted and unshrinkable, so it does
2033
+ not compete with the words beside it. */
2034
+ .checkpoint-time {
2035
+ flex: 0 0 auto;
2036
+ font-size: 0.6875rem;
2037
+ opacity: 0.7;
2038
+ white-space: nowrap;
2039
+ }
2040
+
2041
+ /* Enough of the run id to tell two runs apart when both say "just now". Muted
2042
+ and monospaced: it is a reference, not a name. */
2043
+ .checkpoint-id {
2044
+ flex: 0 0 auto;
2045
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2046
+ font-size: 0.6875rem;
2047
+ opacity: 0.55;
2048
+ }
2049
+
2050
+ /* On the panel's own surface, not the row's: the row now paints the hover token
2051
+ itself, and a badge the same colour as what it sits on is not a badge. */
1965
2052
  .checkpoint-branch {
1966
2053
  font-size: 0.6875rem;
1967
2054
  padding: 0 0.375rem;
1968
2055
  border-radius: 999px;
1969
- background: var(--_hover);
2056
+ background: var(--_assistant-bg);
1970
2057
  opacity: 0.8;
1971
2058
  }
1972
2059
 
2060
+ /* The two things in the row that actually do something, so they are the two
2061
+ things that look like it: a filled surface at rest rather than a transparent
2062
+ outline, which on top of the old row highlight was nearly invisible. */
1973
2063
  .checkpoint-action {
1974
2064
  font: inherit;
1975
2065
  font-size: 0.75rem;
2066
+ line-height: 1.4;
1976
2067
  cursor: pointer;
1977
- padding: 0.125rem 0.5rem;
2068
+ padding: 0.1875rem 0.5625rem;
1978
2069
  border: 1px solid var(--_border);
1979
2070
  border-radius: 0.375rem;
1980
- background: transparent;
2071
+ background: var(--_bg);
1981
2072
  color: inherit;
2073
+ transition:
2074
+ background var(--_motion) var(--_ease),
2075
+ border-color var(--_motion) var(--_ease),
2076
+ transform var(--_motion) var(--_ease);
1982
2077
  }
1983
2078
 
1984
- .checkpoint-action:hover {
2079
+ /* Resume is what a reader wants nine times in ten; fork is the deliberate choice
2080
+ beside it. Filled and outlined, the same pair the confirmation and approval
2081
+ cards already use for their primary and secondary action. */
2082
+ .checkpoint-resume {
2083
+ font-weight: 600;
2084
+ border-color: var(--_accent);
2085
+ background: var(--_accent);
2086
+ color: #ffffff;
2087
+ }
2088
+
2089
+ .checkpoint-fork:hover {
1985
2090
  background: var(--_hover);
2091
+ border-color: var(--_accent);
2092
+ }
2093
+
2094
+ /* The filled one cannot go lighter on hover without losing its contrast with the
2095
+ white label, so it dims instead. */
2096
+ .checkpoint-resume:hover {
2097
+ opacity: 0.88;
2098
+ }
2099
+
2100
+ /* Pressed: a pixel down, so the click is felt as well as seen. */
2101
+ .checkpoint-action:active {
2102
+ transform: translateY(1px);
2103
+ }
2104
+
2105
+ /* Keyboard focus was invisible here, in a panel that traps focus and is reached
2106
+ by Tab -- so the one navigation path guaranteed to land on these buttons was
2107
+ the one with nothing to show for it. */
2108
+ .checkpoint-action:focus-visible {
2109
+ outline: 2px solid var(--_accent);
2110
+ outline-offset: 2px;
1986
2111
  }
1987
2112
 
1988
2113
  .drawer-backdrop {
@@ -7,13 +7,17 @@ export type ToolCallStatus = (typeof TOOL_CALL_STATUS)[keyof typeof TOOL_CALL_ST
7
7
  /** How much detail a card renders. */
8
8
  export type ToolDisplayMode = (typeof TOOL_DISPLAY)[keyof typeof TOOL_DISPLAY];
9
9
 
10
- /** The terminal states a card settles into (everything but `pending`). */
11
- export type SettledStatus = Exclude<ToolCallStatus, typeof TOOL_CALL_STATUS.PENDING>;
10
+ /** The two states a card can sit in before an outcome exists. */
11
+ export type UnsettledStatus = typeof TOOL_CALL_STATUS.PENDING | typeof TOOL_CALL_STATUS.DEFERRED;
12
+
13
+ /** The terminal states a card settles into (everything unsettled excluded). */
14
+ export type SettledStatus = Exclude<ToolCallStatus, UnsettledStatus>;
12
15
 
13
16
  /** Short pill text shown for each status, drawn from the string table. */
14
17
  function statusLabels(strings: UiStrings): Record<ToolCallStatus, string> {
15
18
  return {
16
19
  [TOOL_CALL_STATUS.PENDING]: strings.toolRunning,
20
+ [TOOL_CALL_STATUS.DEFERRED]: strings.toolDeferred,
17
21
  [TOOL_CALL_STATUS.DONE]: strings.toolDone,
18
22
  [TOOL_CALL_STATUS.ERROR]: strings.toolError,
19
23
  [TOOL_CALL_STATUS.DECLINED]: strings.toolDeclined,
@@ -64,6 +68,21 @@ export class ToolCallCard {
64
68
  /** The card's root element; append this into the message list. */
65
69
  readonly element: HTMLDivElement;
66
70
 
71
+ /**
72
+ * Where a question about *this* call renders — the approval prompt for a
73
+ * server-side tool the run deferred.
74
+ *
75
+ * It belongs to the card rather than to the transcript because a run can defer
76
+ * several calls at once, and a prompt written per *tool* ("Add this event to
77
+ * the board?") is identical for every one of them. Rendered into the answer
78
+ * group they were three anonymous copies of one question, below the three
79
+ * cards they gated; rendered here, position identifies them and the arguments
80
+ * are already on screen above the question.
81
+ *
82
+ * Empty until used, and hidden while empty by the shadow CSS.
83
+ */
84
+ readonly approvalSlot: HTMLDivElement;
85
+
67
86
  readonly #status: HTMLSpanElement;
68
87
  readonly #decision: HTMLSpanElement;
69
88
  readonly #toggle: HTMLButtonElement;
@@ -143,7 +162,26 @@ export class ToolCallCard {
143
162
  body.setAttribute("part", "tool-card-body");
144
163
  body.append(argsSection.root, resultSection.root);
145
164
 
146
- this.element.append(head, this.#toggle, body);
165
+ this.approvalSlot = document.createElement("div");
166
+ this.approvalSlot.className = "tool-call-approval";
167
+ this.approvalSlot.setAttribute("part", "tool-card-approval");
168
+
169
+ this.element.append(head, this.#toggle, body, this.approvalSlot);
170
+ }
171
+
172
+ /**
173
+ * Move between the two states that are not an outcome — `pending` (running)
174
+ * and `deferred` (gated, waiting on a person).
175
+ *
176
+ * Ignored once {@link settle} has run: a card that was declined must not be
177
+ * talked back into looking live by a late event.
178
+ */
179
+ mark(status: UnsettledStatus): void {
180
+ if (this.#settled) {
181
+ return;
182
+ }
183
+ this.element.setAttribute("data-status", status);
184
+ this.#status.textContent = statusLabels(this.#strings)[status];
147
185
  }
148
186
 
149
187
  /**
@@ -84,6 +84,8 @@ export interface UiStrings {
84
84
  // ── Tool-call card ──────────────────────────────────────────────────────────
85
85
  /** Status pill while the call runs. */
86
86
  toolRunning: string;
87
+ /** Status pill on a gated call the run deferred, waiting on a person. */
88
+ toolDeferred: string;
87
89
  /** Status pill on success. */
88
90
  toolDone: string;
89
91
  /** Status pill on error. */
@@ -244,6 +246,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
244
246
  transcriptionFailed: "Transcription failed",
245
247
 
246
248
  toolRunning: "running…",
249
+ toolDeferred: "waiting for you",
247
250
  toolDone: "✓ done",
248
251
  toolError: "⚠ error",
249
252
  toolDeclined: "⊘ declined",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.24.0";
1
+ export const VERSION: string = "0.25.1";