@artooi/ag-ui-web-component 0.24.0 → 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.
@@ -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. */
@@ -161,18 +161,43 @@ export class CheckpointMenu {
161
161
  row.className = "checkpoint-row";
162
162
  row.setAttribute("part", "checkpoint-row");
163
163
 
164
+ const preview =
165
+ run.preview !== undefined && run.preview !== null && run.preview !== "" ? run.preview : null;
166
+ const time =
167
+ run.started_at === null
168
+ ? null
169
+ : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
170
+
164
171
  const label = document.createElement("span");
165
172
  label.className = "checkpoint-label";
166
173
  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;
174
+ // What the run was about, if the server says. Otherwise the time, and only
175
+ // then the id a person recognises the first, reads the second, and
176
+ // recognises nothing at all in the third.
177
+ label.textContent = preview ?? time ?? run.run_id;
174
178
  row.append(label);
175
179
 
180
+ if (preview !== null && time !== null) {
181
+ // Demoted to a chip: with words in the label the time is no longer what
182
+ // identifies the run, but it still orders it.
183
+ const when = document.createElement("span");
184
+ when.className = "checkpoint-time";
185
+ when.setAttribute("part", "checkpoint-time");
186
+ when.textContent = time;
187
+ row.append(when);
188
+ } else if (run.started_at !== null) {
189
+ // No words to show, so the id has to do the identifying: two runs a few
190
+ // seconds apart both read "just now", and picking between them is picking
191
+ // blind. Shown rather than left in a tooltip, since a hover is not an
192
+ // identity either — and eight characters beats a full id on the row.
193
+ const short = document.createElement("span");
194
+ short.className = "checkpoint-id";
195
+ short.setAttribute("part", "checkpoint-id");
196
+ short.textContent = run.run_id.slice(0, 8);
197
+ short.title = run.run_id;
198
+ row.append(short);
199
+ }
200
+
176
201
  if (run.parent_run_id !== null) {
177
202
  // Lineage, so a branch doesn't read as a duplicate of its parent.
178
203
  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,123 @@ 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
+ /* A row is a label and two buttons, and nothing about the row itself is
2005
+ pressable. It used to light up on hover, which is the affordance of something
2006
+ clickable and made the buttons look like decoration on a clickable strip. The
2007
+ resting surface groups the row instead, so hover can mean what it says: only
2008
+ the buttons respond to it.
2009
+
2010
+ It wraps for the same reason the tool-call head does. Every child but the label
2011
+ is fixed-width, so in a narrow panel the label is the only thing that can give
2012
+ -- and a flex-basis of zero lets it give everything. Adding the run id was
2013
+ enough to crush "just now" to zero pixels: present, correct, and invisible.
2014
+ Wrapping puts the buttons on their own line instead. */
1945
2015
  .checkpoint-row {
1946
2016
  display: flex;
2017
+ flex-wrap: wrap;
1947
2018
  align-items: center;
1948
2019
  gap: 0.5rem;
1949
- padding: 0.25rem;
2020
+ padding: 0.3125rem 0.4375rem;
1950
2021
  border-radius: 0.375rem;
1951
- }
1952
-
1953
- .checkpoint-row:hover {
1954
2022
  background: var(--_hover);
1955
2023
  }
1956
2024
 
2025
+ /* Grows into spare room, and refuses to shrink past the shortest thing it ever
2026
+ says. A time is short and bounded, so there is no case for eliding it. */
1957
2027
  .checkpoint-label {
1958
- flex: 1;
2028
+ flex: 1 1 auto;
2029
+ min-width: 7ch;
1959
2030
  font-size: 0.8125rem;
1960
2031
  white-space: nowrap;
1961
2032
  overflow: hidden;
1962
2033
  text-overflow: ellipsis;
1963
2034
  }
1964
2035
 
2036
+ /* When the label holds the run's first message, the time moves here: still worth
2037
+ showing, no longer what identifies the row. Muted and unshrinkable, so it does
2038
+ not compete with the words beside it. */
2039
+ .checkpoint-time {
2040
+ flex: 0 0 auto;
2041
+ font-size: 0.6875rem;
2042
+ opacity: 0.7;
2043
+ white-space: nowrap;
2044
+ }
2045
+
2046
+ /* Enough of the run id to tell two runs apart when both say "just now". Muted
2047
+ and monospaced: it is a reference, not a name. */
2048
+ .checkpoint-id {
2049
+ flex: 0 0 auto;
2050
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2051
+ font-size: 0.6875rem;
2052
+ opacity: 0.55;
2053
+ }
2054
+
2055
+ /* On the panel's own surface, not the row's: the row now paints the hover token
2056
+ itself, and a badge the same colour as what it sits on is not a badge. */
1965
2057
  .checkpoint-branch {
1966
2058
  font-size: 0.6875rem;
1967
2059
  padding: 0 0.375rem;
1968
2060
  border-radius: 999px;
1969
- background: var(--_hover);
2061
+ background: var(--_assistant-bg);
1970
2062
  opacity: 0.8;
1971
2063
  }
1972
2064
 
2065
+ /* The two things in the row that actually do something, so they are the two
2066
+ things that look like it: a filled surface at rest rather than a transparent
2067
+ outline, which on top of the old row highlight was nearly invisible. */
1973
2068
  .checkpoint-action {
1974
2069
  font: inherit;
1975
2070
  font-size: 0.75rem;
2071
+ line-height: 1.4;
1976
2072
  cursor: pointer;
1977
- padding: 0.125rem 0.5rem;
2073
+ padding: 0.1875rem 0.5625rem;
1978
2074
  border: 1px solid var(--_border);
1979
2075
  border-radius: 0.375rem;
1980
- background: transparent;
2076
+ background: var(--_bg);
1981
2077
  color: inherit;
2078
+ transition:
2079
+ background var(--_motion) var(--_ease),
2080
+ border-color var(--_motion) var(--_ease),
2081
+ transform var(--_motion) var(--_ease);
1982
2082
  }
1983
2083
 
1984
- .checkpoint-action:hover {
2084
+ /* Resume is what a reader wants nine times in ten; fork is the deliberate choice
2085
+ beside it. Filled and outlined, the same pair the confirmation and approval
2086
+ cards already use for their primary and secondary action. */
2087
+ .checkpoint-resume {
2088
+ font-weight: 600;
2089
+ border-color: var(--_accent);
2090
+ background: var(--_accent);
2091
+ color: #ffffff;
2092
+ }
2093
+
2094
+ .checkpoint-fork:hover {
1985
2095
  background: var(--_hover);
2096
+ border-color: var(--_accent);
2097
+ }
2098
+
2099
+ /* The filled one cannot go lighter on hover without losing its contrast with the
2100
+ white label, so it dims instead. */
2101
+ .checkpoint-resume:hover {
2102
+ opacity: 0.88;
2103
+ }
2104
+
2105
+ /* Pressed: a pixel down, so the click is felt as well as seen. */
2106
+ .checkpoint-action:active {
2107
+ transform: translateY(1px);
2108
+ }
2109
+
2110
+ /* Keyboard focus was invisible here, in a panel that traps focus and is reached
2111
+ by Tab -- so the one navigation path guaranteed to land on these buttons was
2112
+ the one with nothing to show for it. */
2113
+ .checkpoint-action:focus-visible {
2114
+ outline: 2px solid var(--_accent);
2115
+ outline-offset: 2px;
1986
2116
  }
1987
2117
 
1988
2118
  .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.0";