@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
@@ -10,6 +10,7 @@ import {
10
10
  LOAD_CAPABILITY_TOOL,
11
11
  MESSAGE_ROLE,
12
12
  READ_PAGE_TOOL,
13
+ RUN_FINISHED_EVENT,
13
14
  STATE_EVENT,
14
15
  SUBMIT_EVENT,
15
16
  TOGGLE_EVENT,
@@ -105,6 +106,23 @@ export interface StateDetail {
105
106
  readonly state: Readonly<Record<string, unknown>>;
106
107
  }
107
108
 
109
+ /** One tool that ran during an interaction, as {@link RunFinishedDetail} lists it. */
110
+ export interface ToolRun {
111
+ readonly name: string;
112
+ /**
113
+ * Where it executed. `"server"` is the one a data-rendering host cares about:
114
+ * a `"client"` tool ran in the host's own handler, so the host already knows
115
+ * whatever it did.
116
+ */
117
+ readonly side: "server" | "client";
118
+ }
119
+
120
+ /** `detail` shape of the {@link RUN_FINISHED_EVENT} CustomEvent. */
121
+ export interface RunFinishedDetail {
122
+ /** In settle order. Empty when the interaction called no tools. */
123
+ readonly tools: readonly ToolRun[];
124
+ }
125
+
108
126
  /** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
109
127
  export interface ToggleDetail {
110
128
  readonly collapsed: boolean;
@@ -375,6 +393,12 @@ export class AgUiChat extends HTMLElement {
375
393
  * the real output with the generic "executed on the server" fallback.
376
394
  */
377
395
  readonly #serverSettled = new Set<string>();
396
+ /**
397
+ * Tool calls made during the current interaction, in the order they started,
398
+ * so {@link RUN_FINISHED_EVENT} can report them once the whole thing settles.
399
+ * Spans tool rounds and an approval interrupt; cleared when the event fires.
400
+ */
401
+ #runTools: { readonly id: string; readonly name: string }[] = [];
378
402
  readonly #root: ShadowRoot;
379
403
  readonly #chat: HTMLDivElement;
380
404
  readonly #messages: HTMLDivElement;
@@ -1572,6 +1596,11 @@ export class AgUiChat extends HTMLElement {
1572
1596
  * {@link toggleCollapsed} and {@link toggleTheme}.
1573
1597
  */
1574
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();
1575
1604
  void this.#refreshDrawer();
1576
1605
  this.#drawer.open();
1577
1606
  }
@@ -1584,10 +1613,31 @@ export class AgUiChat extends HTMLElement {
1584
1613
  * an empty panel.
1585
1614
  */
1586
1615
  openCheckpoints(): void {
1616
+ // The other half of the pair — see `openThreads`.
1617
+ this.#drawer.close();
1587
1618
  void this.#refreshCheckpoints();
1588
1619
  this.#checkpoints.open();
1589
1620
  }
1590
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
+
1591
1641
  /**
1592
1642
  * Start a fresh conversation: forget the persisted history, drop the
1593
1643
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -1909,8 +1959,11 @@ export class AgUiChat extends HTMLElement {
1909
1959
  const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
1910
1960
  history.addEventListener("click", () => this.openThreads());
1911
1961
 
1912
- const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
1913
- 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());
1914
1967
 
1915
1968
  const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
1916
1969
  newChat.addEventListener("click", () => this.newChat());
@@ -2053,6 +2106,25 @@ export class AgUiChat extends HTMLElement {
2053
2106
  this.#checkpoints.element,
2054
2107
  );
2055
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
+
2056
2128
  // What a collapsed widget shrinks to: a round floating button, or the slim
2057
2129
  // edge rail under `placement="sidebar"` — one element, shaped by CSS.
2058
2130
  // A sibling of the panel, so it survives the panel being hidden.
@@ -2566,55 +2638,82 @@ export class AgUiChat extends HTMLElement {
2566
2638
  * Render an approval card per server-side-tool interrupt and collect the
2567
2639
  * user's decisions (approve → run it, deny → decline it).
2568
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
+ *
2569
2653
  * The run is suspended on these cards. A Stop while any is open aborts the
2570
2654
  * shared {@link #confirmAbort} controller, resolving every still-open card as
2571
2655
  * denied. An approved tool runs on the follow-up resume run and streams its
2572
- * result into the same pending card; a denied one settles here, since no
2573
- * 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.
2574
2658
  */
2575
2659
  async #resolveInterrupts(
2576
2660
  interrupts: readonly Interrupt[],
2577
2661
  ): Promise<Record<string, InterruptResponse>> {
2578
- const responses: Record<string, InterruptResponse> = {};
2579
2662
  // One controller covers the whole batch: a single Stop denies all of them.
2580
2663
  this.#confirmAbort = new AbortController();
2581
2664
  this.#hidePending();
2582
- for (const interrupt of interrupts) {
2583
- const request: ApprovalRequest = {};
2584
- if (interrupt.message !== undefined) {
2585
- request.message = interrupt.message;
2586
- }
2587
- const card =
2588
- interrupt.toolCallId !== undefined ? this.#toolCards.get(interrupt.toolCallId) : undefined;
2589
- const toolName = card?.element.getAttribute("data-tool-name");
2590
- if (toolName !== null && toolName !== undefined) {
2591
- request.toolName = toolName;
2592
- }
2593
- const signal = this.#confirmAbort.signal;
2594
- // A host-supplied renderer takes full control of the approval UI;
2595
- // otherwise the built-in inline card renders into the current answer group.
2596
- const approved =
2597
- this.approvalRenderer !== null
2598
- ? await this.approvalRenderer(request, { signal })
2599
- : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
2600
- this.#updateEmptyState();
2601
- this.#messages.scrollTop = this.#messages.scrollHeight;
2602
- // Same annotation as the client-side confirmation gate. Without it the
2603
- // two gates read differently for the same act: a locally-confirmed call
2604
- // said who let it through and a server-gated one said nothing, which is
2605
- // backwards, since the server-side gate is the one guarding the tools
2606
- // that actually run on the backend.
2607
- card?.recordDecision(approved ? "approved" : "declined");
2608
- if (approved) {
2609
- responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
2610
- } else {
2611
- responses[interrupt.id] = { status: "cancelled" };
2612
- // No TOOL_CALL_RESULT will stream for a denied tool settle its pending
2613
- // card now rather than leaving it hanging until the onSettled sweep.
2614
- card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
2615
- }
2616
- }
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;
2617
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
+ }
2618
2717
  return responses;
2619
2718
  }
2620
2719
 
@@ -2671,6 +2770,9 @@ export class AgUiChat extends HTMLElement {
2671
2770
  if (this.#noticeIfSkillLoad(call)) {
2672
2771
  return;
2673
2772
  }
2773
+ // Recorded after the skill-load return: a capability load is the agent
2774
+ // arranging itself, not work a host's data could have moved under.
2775
+ this.#runTools.push({ id: call.id, name: call.name });
2674
2776
  this.#cardFor(call);
2675
2777
  },
2676
2778
  onActivity: (activityType, content) => {
@@ -2735,10 +2837,35 @@ export class AgUiChat extends HTMLElement {
2735
2837
  }
2736
2838
  this.#currentGroup = null;
2737
2839
  this.#thoughts = null;
2840
+ this.#dispatchRunFinished();
2738
2841
  },
2739
2842
  };
2740
2843
  }
2741
2844
 
2845
+ /**
2846
+ * Tell the host the interaction is over and what ran in it.
2847
+ *
2848
+ * Last thing in `onSettled`, so a listener that refetches sees a transcript
2849
+ * that has already stopped changing. `side` is read from the streamed-result
2850
+ * bookkeeping rather than from the tool list: whether a call executed on the
2851
+ * server is a fact about the run, and a name can appear on both sides across a
2852
+ * conversation.
2853
+ */
2854
+ #dispatchRunFinished(): void {
2855
+ const tools: ToolRun[] = this.#runTools.map(({ id, name }) => ({
2856
+ name,
2857
+ side: this.#serverSettled.has(id) ? "server" : "client",
2858
+ }));
2859
+ this.#runTools = [];
2860
+ this.dispatchEvent(
2861
+ new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
2862
+ detail: { tools },
2863
+ bubbles: true,
2864
+ composed: true,
2865
+ }),
2866
+ );
2867
+ }
2868
+
2742
2869
  /** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
2743
2870
  #appendStoppedNote(): void {
2744
2871
  const note = document.createElement("div");
@@ -2866,6 +2993,25 @@ export class AgUiChat extends HTMLElement {
2866
2993
  }
2867
2994
  }
2868
2995
 
2996
+ /**
2997
+ * A server-authored question for a gated call, read off the interrupt's metadata.
2998
+ *
2999
+ * The question an AG-UI interrupt carries by default is the call itself, spelled
3000
+ * out: `Approve create_event({"title": "Design sync", …})?`. Accurate, and not
3001
+ * something to put in front of a person. A client-side confirmation has
3002
+ * `x-confirm` on the tool's schema for exactly this, so the same key is read here
3003
+ * — whichever end gates a call, the phrase comes from one place, and a server
3004
+ * that supplies none keeps the generated text.
3005
+ *
3006
+ * Narrowed rather than trusted: `metadata` is `Record<string, any>` on the wire,
3007
+ * so anything at all can arrive under that key, and a non-string would render as
3008
+ * "[object Object]" in the one place a person is being asked to allow a write.
3009
+ */
3010
+ function confirmPhrase(interrupt: Interrupt): string | undefined {
3011
+ const phrase = interrupt.metadata?.[X_CONFIRM_KEY];
3012
+ return typeof phrase === "string" && phrase.trim() !== "" ? phrase : undefined;
3013
+ }
3014
+
2869
3015
  /** One tool call as a restored assistant message carries it. */
2870
3016
  interface RestoredToolCall {
2871
3017
  readonly id: string;
@@ -58,6 +58,23 @@ export interface ClientConversationStore {
58
58
  setActiveThread(threadId: string): void;
59
59
  /** Set a thread's display title (the drawer renaming a row). */
60
60
  renameThread(threadId: string, title: string): void;
61
+ /**
62
+ * Whether `threadId` was minted here and has never been saved — a thread in
63
+ * which nothing has been sent yet.
64
+ *
65
+ * Exists so a server-backed store can skip asking a server for history that
66
+ * cannot be there: the element mints an id on first mount and immediately tries
67
+ * to restore it, which answers `404` and logs one in the console on every first
68
+ * visit. Nothing is wrong, and it looks exactly like something being wrong.
69
+ *
70
+ * Optional, and deliberately narrow. A store that cannot tell omits it and the
71
+ * fetch happens as before, which is also the right answer for a store that
72
+ * holds nothing locally: "I have no messages for this id" is not the same claim
73
+ * as "this id is new", and only the store that minted the id can make the
74
+ * second one. A thread picked from the drawer was never minted here, so it is
75
+ * still fetched.
76
+ */
77
+ isUnsent?(threadId: string): boolean;
61
78
  }
62
79
 
63
80
  const KEY_ROOT = "ag-ui-chat";
@@ -65,6 +82,9 @@ const THREAD_SUFFIX = "thread";
65
82
  const THREADS_SUFFIX = "threads";
66
83
  const MESSAGES_SUFFIX = "messages:";
67
84
  const CHECKPOINT_SUFFIX = "checkpoint:";
85
+ // Marks an id this store minted and nothing has been sent in yet. Dropped on
86
+ // the first save, so it never outlives the one question it answers.
87
+ const MINTED_SUFFIX = "minted:";
68
88
 
69
89
  const TITLE_LIMIT = 60;
70
90
  const PREVIEW_LIMIT = 100;
@@ -110,15 +130,24 @@ export class SessionStorageStore implements ClientConversationStore {
110
130
  }
111
131
  const id = randomUUID();
112
132
  sessionStorage.setItem(key, id);
133
+ sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
113
134
  return id;
114
135
  }
115
136
 
137
+ isUnsent(threadId: string): boolean {
138
+ return (
139
+ sessionStorage.getItem(this.#key(MINTED_SUFFIX + threadId)) !== null &&
140
+ sessionStorage.getItem(this.#key(MESSAGES_SUFFIX + threadId)) === null
141
+ );
142
+ }
143
+
116
144
  loadMessages(threadId: string): Promise<readonly Message[] | null> {
117
145
  return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
118
146
  }
119
147
 
120
148
  saveMessages(threadId: string, messages: readonly Message[]): void {
121
149
  sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
150
+ sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
122
151
  this.#touchThread(threadId, messages);
123
152
  }
124
153
 
@@ -138,6 +167,7 @@ export class SessionStorageStore implements ClientConversationStore {
138
167
  clear(threadId: string): void {
139
168
  sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
140
169
  sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
170
+ sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
141
171
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
142
172
  // Only drop the active pointer when the active thread itself is cleared, so
143
173
  // the next `threadId()` mints a fresh one. Deleting another thread from the
@@ -69,6 +69,11 @@ export class RemoteConversationStore implements ClientConversationStore {
69
69
  this.#local.setActiveThread(threadId);
70
70
  }
71
71
 
72
+ /** Delegated, so wrapping a store does not lose what it knows about its own ids. */
73
+ isUnsent(threadId: string): boolean {
74
+ return this.#local.isUnsent?.(threadId) === true;
75
+ }
76
+
72
77
  saveMessages(threadId: string, messages: readonly Message[]): void {
73
78
  // The agent run persists server-side; keep a local cache for offline replay.
74
79
  this.#local.saveMessages(threadId, messages);
@@ -103,6 +108,15 @@ export class RemoteConversationStore implements ClientConversationStore {
103
108
  }
104
109
 
105
110
  async loadMessages(threadId: string): Promise<readonly Message[] | null> {
111
+ // Don't ask the server for a thread it cannot have. The element mints an id
112
+ // on first mount and immediately tries to restore it, so every first visit
113
+ // spent a request to be told `404` — and logged one in the console, on a page
114
+ // where nothing had gone wrong. Only the store that minted the id can say
115
+ // that; a thread chosen from the drawer, or one created on another device, is
116
+ // still fetched. See `ClientConversationStore.isUnsent`.
117
+ if (this.#local.isUnsent?.(threadId) === true) {
118
+ return null;
119
+ }
106
120
  const response = await this.#get(`${this.#url}${encodeURIComponent(threadId)}/`);
107
121
  if (response === null || !response.ok) {
108
122
  return this.#local.loadMessages(threadId);
@@ -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. */
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  LOAD_CAPABILITY_TOOL,
8
8
  MAX_TOOL_ROUNDS,
9
9
  MESSAGE_ROLE,
10
+ RUN_FINISHED_EVENT,
10
11
  STATE_EVENT,
11
12
  SUBMIT_EVENT,
12
13
  TOGGLE_EVENT,
@@ -22,9 +23,11 @@ export {
22
23
  AgUiChat,
23
24
  type AttachmentsDetail,
24
25
  type MessageRole,
26
+ type RunFinishedDetail,
25
27
  type StateDetail,
26
28
  type SubmitDetail,
27
29
  type ToggleDetail,
30
+ type ToolRun,
28
31
  type UnreadDetail,
29
32
  } from "./core/ag_ui_chat.js";
30
33
  export {
@@ -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");