@artooi/ag-ui-web-component 0.28.0 → 0.29.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 (59) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/README.md +371 -5
  3. package/dist/ag-ui-web-component.bundle.js +308 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +207 -0
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +38 -0
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +7 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1516 -76
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +18 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -1
  17. package/dist/ui/checkpoint_menu.d.ts +10 -0
  18. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  19. package/dist/ui/confirmation_card.d.ts +16 -0
  20. package/dist/ui/confirmation_card.d.ts.map +1 -1
  21. package/dist/ui/message_actions.d.ts +46 -0
  22. package/dist/ui/message_actions.d.ts.map +1 -0
  23. package/dist/ui/page_quote_offer.d.ts +33 -0
  24. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  25. package/dist/ui/quote_selection.d.ts +66 -0
  26. package/dist/ui/quote_selection.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +10 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/stick_to_bottom.d.ts +55 -0
  30. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  31. package/dist/ui/styles.d.ts +1 -1
  32. package/dist/ui/styles.d.ts.map +1 -1
  33. package/dist/ui/suggestion_chips.d.ts +29 -0
  34. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  35. package/dist/ui/thread_drawer.d.ts +10 -0
  36. package/dist/ui/thread_drawer.d.ts.map +1 -1
  37. package/dist/ui/tool_call_card.d.ts +8 -0
  38. package/dist/ui/tool_call_card.d.ts.map +1 -1
  39. package/dist/ui/ui_strings.d.ts +40 -0
  40. package/dist/ui/ui_strings.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/constants.ts +75 -0
  43. package/src/core/ag_ui_chat.ts +927 -73
  44. package/src/core/agui_client.ts +63 -0
  45. package/src/index.ts +39 -0
  46. package/src/ui/approval_card.ts +90 -2
  47. package/src/ui/checkpoint_menu.ts +22 -5
  48. package/src/ui/confirmation_card.ts +29 -1
  49. package/src/ui/message_actions.ts +158 -0
  50. package/src/ui/page_quote_offer.ts +215 -0
  51. package/src/ui/quote_selection.ts +345 -0
  52. package/src/ui/relative_time.ts +11 -0
  53. package/src/ui/stick_to_bottom.ts +126 -0
  54. package/src/ui/styles.ts +227 -0
  55. package/src/ui/suggestion_chips.ts +73 -0
  56. package/src/ui/thread_drawer.ts +22 -2
  57. package/src/ui/tool_call_card.ts +9 -0
  58. package/src/ui/ui_strings.ts +60 -0
  59. package/src/version.ts +1 -1
@@ -109,6 +109,24 @@ export interface AgUiClientHandlers {
109
109
  /** Fired when the reasoning block ends (before the answer text streams). */
110
110
  onReasoningEnd(): void;
111
111
  onRunEnd(): void;
112
+ /**
113
+ * The server replaced the whole conversation with `MESSAGES_SNAPSHOT`.
114
+ *
115
+ * `@ag-ui/client` applies the event before any subscriber sees it, so by the
116
+ * time this fires `agent.messages` **is** the server's list -- and the run
117
+ * loop persists `agent.messages`. The replacement therefore reaches the
118
+ * conversation store whatever the host does; this hook exists so the host can
119
+ * stop that being invisible.
120
+ */
121
+ onMessagesSnapshot(messages: readonly Message[]): void;
122
+ /**
123
+ * The agent sent a `CUSTOM` event.
124
+ *
125
+ * Forwarded whole and uninterpreted: `name` is an open string the protocol
126
+ * does not enumerate, so a client that decided which names were legal would
127
+ * be the thing the open field exists to avoid.
128
+ */
129
+ onCustomEvent(name: string, value: unknown): void;
112
130
  onError(message: string): void;
113
131
  /**
114
132
  * Fired when the user cancelled the run ({@link AgUiClient.cancel}) — the
@@ -271,6 +289,45 @@ export class AgUiClient {
271
289
  await this.#run();
272
290
  }
273
291
 
292
+ /**
293
+ * Drop everything after the most recent user message, so the same question
294
+ * can be asked again.
295
+ *
296
+ * Returns the retained history, or `null` when there is nothing to retry (no
297
+ * user message has been sent yet). **Truncates only** -- the caller re-renders
298
+ * from the returned list and then calls {@link resume}, because the transcript
299
+ * belongs to the element and a client that reached into it would own two
300
+ * things. Running here instead would stream the new answer in underneath the
301
+ * old one.
302
+ *
303
+ * Re-running answers the question the agent was last asked, rather than
304
+ * telling it its answer was wrong, which is what makes the result a
305
+ * *different* answer instead of a conversation about the previous one.
306
+ *
307
+ * **A retried turn re-runs its tools.** For a page-driving agent that is not
308
+ * neutral: the previous attempt already clicked what it clicked, and this
309
+ * does not undo it.
310
+ */
311
+ truncateToLastUser(): readonly Message[] | null {
312
+ const messages = [...this.#agent.messages];
313
+ // Forward, keeping the last match, rather than a reverse scan with an
314
+ // index lookup: `noUncheckedIndexedAccess` makes the latter reach for an
315
+ // optional chain whose null arm cannot happen and cannot be covered.
316
+ let lastUser = -1;
317
+ for (const [index, message] of messages.entries()) {
318
+ if (message.role === "user") {
319
+ lastUser = index;
320
+ }
321
+ }
322
+ if (lastUser === -1) {
323
+ return null;
324
+ }
325
+ const kept = messages.slice(0, lastUser + 1);
326
+ this.#agent.setMessages(kept);
327
+ this.#onPersist(this.#agent.messages);
328
+ return kept;
329
+ }
330
+
274
331
  /**
275
332
  * Resume the run loop after a navigating tool's result was supplied
276
333
  * post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
@@ -483,6 +540,12 @@ export class AgUiClient {
483
540
  // Emitted after the client has written the patched messages, which is the
484
541
  // first moment the result exists. Only the ids marked above are looked at,
485
542
  // so an ordinary text delta does not walk the transcript.
543
+ onCustomEvent({ event }) {
544
+ h.onCustomEvent(event.name, event.value);
545
+ },
546
+ onMessagesSnapshotEvent({ event }) {
547
+ h.onMessagesSnapshot(event.messages as readonly Message[]);
548
+ },
486
549
  onMessagesChanged({ messages }) {
487
550
  if (pendingDeltas.size === 0) {
488
551
  return;
package/src/index.ts CHANGED
@@ -4,13 +4,18 @@ export {
4
4
  ATTACHMENT_EVENT,
5
5
  CHART_ACTIVITY_TYPE,
6
6
  COMPACTION_ACTIVITY_TYPE,
7
+ CUSTOM_AGENT_EVENT,
7
8
  ELEMENT_TAG,
9
+ FEEDBACK_EVENT,
10
+ INVALIDATE_CUSTOM_NAME,
11
+ INVALIDATE_EVENT,
8
12
  LOAD_CAPABILITY_TOOL,
9
13
  MAX_TOOL_ROUNDS,
10
14
  MESSAGE_ROLE,
11
15
  RUN_FINISHED_EVENT,
12
16
  STATE_EVENT,
13
17
  SUBMIT_EVENT,
18
+ SUGGESTIONS_ACTIVITY_TYPE,
14
19
  TOGGLE_EVENT,
15
20
  TOOL_CALL_STATUS,
16
21
  TOOL_DISPLAY,
@@ -21,8 +26,13 @@ export {
21
26
  X_SUMMARY_KEY,
22
27
  } from "./constants.js";
23
28
  export {
29
+ type ActivityRegistration,
30
+ type ActivityRenderer,
24
31
  AgUiChat,
25
32
  type AttachmentsDetail,
33
+ type CustomAgentDetail,
34
+ type FeedbackDetail,
35
+ type InvalidateDetail,
26
36
  type MessageRole,
27
37
  type RunFinishedDetail,
28
38
  type StateDetail,
@@ -143,6 +153,16 @@ export {
143
153
  type ConfirmationRequest,
144
154
  requestConfirmation,
145
155
  } from "./ui/confirmation_card.js";
156
+ export {
157
+ attachMessageActions,
158
+ type MessageActionsOptions,
159
+ messageActionBar,
160
+ } from "./ui/message_actions.js";
161
+ export {
162
+ attachQuoteOffer,
163
+ type PageQuoteOffer,
164
+ type PageQuoteOfferOptions,
165
+ } from "./ui/page_quote_offer.js";
146
166
  export { prettifyToolName } from "./ui/prettify_tool_name.js";
147
167
  export {
148
168
  type QuestionOptions,
@@ -150,7 +170,26 @@ export {
150
170
  type QuestionRequest,
151
171
  requestQuestion,
152
172
  } from "./ui/question_card.js";
173
+ // Quoting. The transcript wires these itself; they are exported for the half
174
+ // the component cannot reach -- a selection made in the **host page**, which
175
+ // a host reads its own way and hands to `AgUiChat.quote()`.
176
+ export {
177
+ asQuote,
178
+ MAX_QUOTE_CHARS,
179
+ type QuotableSelection,
180
+ quotableSelection,
181
+ } from "./ui/quote_selection.js";
182
+ export {
183
+ type RelativeTimeFormatter,
184
+ relativeTime,
185
+ } from "./ui/relative_time.js";
153
186
  export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
187
+ export {
188
+ MAX_SUGGESTION_CHARS,
189
+ MAX_SUGGESTIONS,
190
+ renderSuggestionChips,
191
+ suggestionPrompts,
192
+ } from "./ui/suggestion_chips.js";
154
193
  export {
155
194
  type SettledStatus,
156
195
  ToolCallCard,
@@ -10,6 +10,11 @@ export interface ApprovalRequest {
10
10
  message?: string;
11
11
  /** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
12
12
  toolName?: string;
13
+ /**
14
+ * The call's arguments, shown for editing when {@link ApprovalOptions.onEdit}
15
+ * is set. Omitted when the interrupt names no call whose arguments are known.
16
+ */
17
+ args?: Record<string, unknown>;
13
18
  }
14
19
 
15
20
  /** Build a labelled action button. */
@@ -31,6 +36,19 @@ export interface ApprovalOptions {
31
36
  signal?: AbortSignal;
32
37
  /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
33
38
  strings?: UiStrings;
39
+ /**
40
+ * Offer the call's arguments for editing, and receive what the user approved.
41
+ *
42
+ * Called **only** on approval, and only when the text parses and differs from
43
+ * what was proposed -- an untouched call resolves as a plain approval, so a
44
+ * server sees `editedArgs` exactly when something was actually edited.
45
+ *
46
+ * Absent means no editor, which is deliberate: AG-UI gates this on the
47
+ * agent's own `approveWithEdits` capability, and a card that let a user
48
+ * rewrite arguments a server will discard is worse than one that does not
49
+ * offer to.
50
+ */
51
+ onEdit?: (args: Record<string, unknown>) => void;
34
52
  }
35
53
 
36
54
  /**
@@ -78,6 +96,8 @@ export function requestApproval(
78
96
  body.setAttribute("part", "approval-body");
79
97
  body.textContent = request.message ?? strings.approvalPrompt;
80
98
 
99
+ const editor = buildEditor(request, options, strings);
100
+
81
101
  const actions = document.createElement("div");
82
102
  actions.className = "approval-actions";
83
103
  actions.setAttribute("part", "approval-actions");
@@ -98,11 +118,19 @@ export function requestApproval(
98
118
  };
99
119
 
100
120
  deny.addEventListener("click", () => close(false));
101
- approve.addEventListener("click", () => close(true));
121
+ approve.addEventListener("click", () => {
122
+ if (editor !== null && !editor.commit()) {
123
+ // Unparseable JSON: say so on the card and stay open. Approving what
124
+ // the user did not write -- the original arguments -- would be the one
125
+ // outcome they cannot see coming.
126
+ return;
127
+ }
128
+ close(true);
129
+ });
102
130
  options.signal?.addEventListener("abort", () => close(false), { once: true });
103
131
 
104
132
  actions.append(deny, approve);
105
- card.append(body, actions);
133
+ card.append(body, ...(editor === null ? [] : [editor.root]), actions);
106
134
  host.appendChild(card);
107
135
  if (options.signal?.aborted === true) {
108
136
  // The run was cancelled before the card could ask; record the denial.
@@ -112,3 +140,63 @@ export function requestApproval(
112
140
  approve.focus();
113
141
  });
114
142
  }
143
+
144
+ /** The editable-arguments region, or `null` when this card does not offer one. */
145
+ function buildEditor(
146
+ request: ApprovalRequest,
147
+ options: ApprovalOptions,
148
+ strings: UiStrings,
149
+ ): { root: HTMLElement; commit: () => boolean } | null {
150
+ const { onEdit } = options;
151
+ if (onEdit === undefined || request.args === undefined) {
152
+ return null;
153
+ }
154
+ const original = JSON.stringify(request.args, null, 2);
155
+ const root = document.createElement("div");
156
+ root.className = "approval-edit";
157
+ root.setAttribute("part", "approval-edit");
158
+
159
+ const field = document.createElement("textarea");
160
+ field.className = "approval-args";
161
+ field.setAttribute("part", "approval-args");
162
+ field.setAttribute("aria-label", strings.approvalEditArgs);
163
+ field.rows = Math.min(10, original.split("\n").length);
164
+ field.value = original;
165
+
166
+ const error = document.createElement("div");
167
+ error.className = "approval-error";
168
+ error.setAttribute("part", "approval-error");
169
+ // A live region: it appears in response to pressing Approve, and a message
170
+ // that only exists visually leaves a screen-reader user with a button that
171
+ // silently did nothing.
172
+ error.setAttribute("role", "alert");
173
+ error.hidden = true;
174
+
175
+ root.append(field, error);
176
+ return {
177
+ root,
178
+ commit: () => {
179
+ if (field.value === original) {
180
+ return true;
181
+ }
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(field.value);
185
+ } catch {
186
+ error.textContent = strings.approvalArgsInvalid;
187
+ error.hidden = false;
188
+ field.focus();
189
+ return false;
190
+ }
191
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
192
+ error.textContent = strings.approvalArgsNotAnObject;
193
+ error.hidden = false;
194
+ field.focus();
195
+ return false;
196
+ }
197
+ error.hidden = true;
198
+ onEdit(parsed as Record<string, unknown>);
199
+ return true;
200
+ },
201
+ };
202
+ }
@@ -1,5 +1,5 @@
1
1
  import type { RunRow } from "../core/run_index.js";
2
- import { relativeTime } from "./relative_time.js";
2
+ import { type RelativeTimeFormatter, relativeTime } from "./relative_time.js";
3
3
  import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
4
4
 
5
5
  /** How the host continues a picked run. */
@@ -46,6 +46,7 @@ export class CheckpointMenu {
46
46
  readonly #heading: HTMLSpanElement;
47
47
  /** What had focus before the panel opened, restored on close. */
48
48
  #lastFocused: HTMLElement | null = null;
49
+ #formatRelativeTime: RelativeTimeFormatter | null = null;
49
50
  #strings: UiStrings;
50
51
  #runs: readonly RunRow[] = [];
51
52
 
@@ -91,6 +92,25 @@ export class CheckpointMenu {
91
92
  }
92
93
 
93
94
  /** Re-localize a panel built before the host's strings resolved. */
95
+ /**
96
+ * Replace the timestamp formatter, or restore the built-in with `null`.
97
+ *
98
+ * The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
99
+ * in this component, so it never disagrees with a host's own formatting by
100
+ * guessing a locale. That is a defensible default and a poor requirement, so
101
+ * this is the way out.
102
+ */
103
+ setRelativeTimeFormatter(format: RelativeTimeFormatter | null): void {
104
+ this.#formatRelativeTime = format;
105
+ }
106
+
107
+ /** This row's timestamp, through the host's formatter when it set one. */
108
+ #formatTime(timestamp: number): string {
109
+ return this.#formatRelativeTime !== null
110
+ ? this.#formatRelativeTime(timestamp)
111
+ : relativeTime(timestamp, Date.now(), this.#strings);
112
+ }
113
+
94
114
  setStrings(strings: UiStrings): void {
95
115
  this.#strings = strings;
96
116
  this.element.setAttribute("aria-label", strings.checkpoints);
@@ -207,10 +227,7 @@ export class CheckpointMenu {
207
227
  row.setAttribute("part", "checkpoint-row");
208
228
 
209
229
  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);
230
+ const time = run.started_at === null ? null : this.#formatTime(Date.parse(run.started_at));
214
231
 
215
232
  const label = document.createElement("span");
216
233
  label.className = "checkpoint-label";
@@ -31,6 +31,16 @@ export interface ConfirmationOptions {
31
31
  signal?: AbortSignal;
32
32
  /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
33
33
  strings?: UiStrings;
34
+ /**
35
+ * Offer a third button — "always allow, this session" — and call this when
36
+ * the user picks it. The card still resolves `true`: the extra decision is
37
+ * *in addition to* approving this call, not instead of it.
38
+ *
39
+ * Absent means no button, which is deliberate: presence of the handler is
40
+ * what enables it, so the affordance can never be rendered with nothing
41
+ * listening. A caller that cannot honour the waiver simply does not pass one.
42
+ */
43
+ onAlwaysAllow?: () => void;
34
44
  }
35
45
 
36
46
  /**
@@ -43,6 +53,12 @@ export interface ConfirmationOptions {
43
53
  * Answering it removes it: the record of the decision belongs to the tool card
44
54
  * this gates, which settles to `done` or `declined` and carries it. A spent
45
55
  * form left in place reads as still outstanding.
56
+ *
57
+ * With `onAlwaysAllow` set the card offers a third button. A prompt that is
58
+ * approved nearly every time is not a decision, it is a speed bump — and the
59
+ * reflex it trains is what makes the rare refusal easy to miss. Letting the
60
+ * user say "not this one again, this session" is the affordance that keeps the
61
+ * remaining prompts meaningful.
46
62
  */
47
63
  export function requestConfirmation(
48
64
  host: Node & ParentNode,
@@ -75,6 +91,10 @@ export function requestConfirmation(
75
91
  actions.setAttribute("part", "confirm-actions");
76
92
 
77
93
  const cancel = actionButton("cancel", strings.cancel);
94
+ const always =
95
+ options.onAlwaysAllow === undefined
96
+ ? null
97
+ : actionButton("always", strings.confirmAlways.replace("{tool}", request.toolName));
78
98
  const confirm = actionButton("confirm", strings.confirm);
79
99
 
80
100
  let settled = false;
@@ -93,9 +113,17 @@ export function requestConfirmation(
93
113
 
94
114
  cancel.addEventListener("click", () => close(false));
95
115
  confirm.addEventListener("click", () => close(true));
116
+ always?.addEventListener("click", () => {
117
+ // Recorded before resolving, so a caller that reads its own allowlist
118
+ // synchronously on the next call already sees this one.
119
+ options.onAlwaysAllow?.();
120
+ close(true);
121
+ });
96
122
  options.signal?.addEventListener("abort", () => close(false), { once: true });
97
123
 
98
- actions.append(cancel, confirm);
124
+ // Confirm stays last, and rightmost: the waiver is the wider decision, and
125
+ // putting it where the eye lands for "yes" is how it gets taken by accident.
126
+ actions.append(cancel, ...(always === null ? [] : [always]), confirm);
99
127
  card.append(body, args, actions);
100
128
  host.appendChild(card);
101
129
  if (options.signal?.aborted === true) {
@@ -0,0 +1,158 @@
1
+ import type { UiStrings } from "./ui_strings.js";
2
+
3
+ /** How long a button shows its confirmation before reverting. */
4
+ const CONFIRM_MS = 1500;
5
+
6
+ /** What an action bar can do, beyond copying. */
7
+ export interface MessageActionsOptions {
8
+ /** Localized strings. */
9
+ strings: UiStrings;
10
+ /**
11
+ * The text Copy puts on the clipboard. A function rather than a string
12
+ * because a bubble's content is rewritten while it streams, and the bar is
13
+ * attached to the element rather than to a snapshot of it.
14
+ */
15
+ text: () => string;
16
+ /**
17
+ * Report a rating for this message. Absent means no feedback buttons.
18
+ *
19
+ * The component stores nothing: a rating is the host's to keep, and a
20
+ * write-only table nobody reads is not worth a schema.
21
+ */
22
+ onFeedback?: (rating: "up" | "down") => void;
23
+ }
24
+
25
+ /**
26
+ * Give one finished message bubble its row of actions.
27
+ *
28
+ * **Finished** is load-bearing. A streaming bubble reassigns its `innerHTML` on
29
+ * every delta, so anything attached mid-stream is discarded and rebuilt for
30
+ * each one -- the same constraint `attachCopyButtons` records, one level up.
31
+ *
32
+ * Retry is deliberately **not** here: it belongs to the last turn only, so the
33
+ * element owns it and moves it as the transcript grows. Everything on this bar
34
+ * is safe to offer on any message, however old.
35
+ *
36
+ * Idempotent -- a bubble already given a bar is skipped, so a re-render or a
37
+ * second call cannot stack rows.
38
+ */
39
+ export function attachMessageActions(bubble: HTMLElement, options: MessageActionsOptions): void {
40
+ if (existingBar(bubble) !== null) {
41
+ return;
42
+ }
43
+ const bar = messageActionBar(bubble, options.strings);
44
+ bar.appendChild(copyButton(options));
45
+ if (options.onFeedback !== undefined) {
46
+ bar.append(
47
+ feedbackButton("up", options.strings.feedbackUp, options.onFeedback),
48
+ feedbackButton("down", options.strings.feedbackDown, options.onFeedback),
49
+ );
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The empty action row on `bubble`, created if it has none yet.
55
+ *
56
+ * Shared so a bubble that wants *only* Retry -- a failed run, which has nothing
57
+ * worth copying and nothing to rate -- gets the same row, the same part name
58
+ * and the same accessible grouping as every other message, rather than a
59
+ * second thing that looks like one.
60
+ */
61
+ export function messageActionBar(bubble: HTMLElement, strings: UiStrings): HTMLElement {
62
+ const existing = existingBar(bubble);
63
+ if (existing !== null) {
64
+ return existing;
65
+ }
66
+ const bar = document.createElement("div");
67
+ bar.className = "message-actions";
68
+ bar.setAttribute("part", "message-actions");
69
+ // A group rather than a toolbar: these are independent actions on the message
70
+ // above, not a set the user arrows between.
71
+ bar.setAttribute("role", "group");
72
+ bar.setAttribute("aria-label", strings.messageActions);
73
+ // A **sibling**, never a child. Inside the bubble the buttons join its
74
+ // `textContent`, which is what Copy reads, what history persists and what
75
+ // every existing assertion about a message's text compares against -- so an
76
+ // answer would be copied back with the glyphs of the buttons that copied it.
77
+ // The bubble must therefore already be in the tree when this is called.
78
+ bubble.after(bar);
79
+ return bar;
80
+ }
81
+
82
+ /** The action row belonging to `bubble`, if it has one. */
83
+ function existingBar(bubble: HTMLElement): HTMLElement | null {
84
+ const next = bubble.nextElementSibling;
85
+ return next?.classList.contains("message-actions") === true ? (next as HTMLElement) : null;
86
+ }
87
+
88
+ /** Build one action button, labelled for screen readers rather than by glyph. */
89
+ export function messageActionButton(
90
+ modifier: string,
91
+ label: string,
92
+ glyph: string,
93
+ ): HTMLButtonElement {
94
+ const button = document.createElement("button");
95
+ button.type = "button";
96
+ button.className = `message-action message-action--${modifier}`;
97
+ button.setAttribute("part", `message-action message-action-${modifier}`);
98
+ button.title = label;
99
+ button.setAttribute("aria-label", label);
100
+ const icon = document.createElement("span");
101
+ icon.setAttribute("aria-hidden", "true");
102
+ icon.textContent = glyph;
103
+ button.appendChild(icon);
104
+ return button;
105
+ }
106
+
107
+ function copyButton(options: MessageActionsOptions): HTMLButtonElement {
108
+ const { strings } = options;
109
+ const button = messageActionButton("copy", strings.copyMessage, "⎘");
110
+ button.addEventListener("click", () => {
111
+ void navigator.clipboard.writeText(options.text()).then(
112
+ () => flash(button, strings.copied, strings.copyMessage),
113
+ // A denied clipboard permission is the common case, not an exception:
114
+ // say so on the button rather than throwing into an unhandled rejection.
115
+ () => flash(button, strings.copyFailed, strings.copyMessage),
116
+ );
117
+ });
118
+ return button;
119
+ }
120
+
121
+ function feedbackButton(
122
+ rating: "up" | "down",
123
+ label: string,
124
+ report: (rating: "up" | "down") => void,
125
+ ): HTMLButtonElement {
126
+ const button = messageActionButton(
127
+ rating === "up" ? "up" : "down",
128
+ label,
129
+ rating === "up" ? "\u{1F44D}" : "\u{1F44E}",
130
+ );
131
+ button.addEventListener("click", () => {
132
+ // Pressed rather than removed: the rating is a standing statement about the
133
+ // message, and a button that vanishes leaves no record of what was said.
134
+ const pressed = button.getAttribute("aria-pressed") === "true";
135
+ button.setAttribute("aria-pressed", pressed ? "false" : "true");
136
+ report(rating);
137
+ });
138
+ button.setAttribute("aria-pressed", "false");
139
+ return button;
140
+ }
141
+
142
+ /**
143
+ * Flash `message` on the button, then restore `label`.
144
+ *
145
+ * The label to restore is passed rather than read back off the element: the
146
+ * caller is the one that set it, and reading it would introduce a null arm that
147
+ * cannot happen and cannot be covered.
148
+ */
149
+ function flash(button: HTMLButtonElement, message: string, label: string): void {
150
+ button.title = message;
151
+ button.setAttribute("aria-label", message);
152
+ button.classList.add("message-action--confirmed");
153
+ setTimeout(() => {
154
+ button.title = label;
155
+ button.setAttribute("aria-label", label);
156
+ button.classList.remove("message-action--confirmed");
157
+ }, CONFIRM_MS);
158
+ }