@artooi/ag-ui-web-component 0.10.0 → 0.12.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 (45) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +144 -7
  3. package/dist/ag-ui-web-component.bundle.js +237 -40
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +29 -0
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +28 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/run_index.d.ts +50 -0
  10. package/dist/core/run_index.d.ts.map +1 -0
  11. package/dist/index.d.ts +5 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +942 -101
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +51 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -0
  17. package/dist/ui/attachment_chips.d.ts.map +1 -1
  18. package/dist/ui/attachment_tray.d.ts.map +1 -1
  19. package/dist/ui/checkpoint_menu.d.ts +32 -0
  20. package/dist/ui/checkpoint_menu.d.ts.map +1 -0
  21. package/dist/ui/question_card.d.ts +52 -0
  22. package/dist/ui/question_card.d.ts.map +1 -0
  23. package/dist/ui/skills_menu.d.ts.map +1 -1
  24. package/dist/ui/styles.d.ts +1 -1
  25. package/dist/ui/styles.d.ts.map +1 -1
  26. package/dist/ui/thoughts_block.d.ts.map +1 -1
  27. package/dist/ui/thread_drawer.d.ts.map +1 -1
  28. package/dist/ui/ui_strings.d.ts +26 -0
  29. package/dist/ui/ui_strings.d.ts.map +1 -1
  30. package/package.json +4 -4
  31. package/src/core/ag_ui_chat.ts +263 -4
  32. package/src/core/agui_client.ts +86 -11
  33. package/src/core/run_index.ts +91 -0
  34. package/src/index.ts +16 -0
  35. package/src/ui/approval_card.ts +119 -0
  36. package/src/ui/attachment_chips.ts +5 -0
  37. package/src/ui/attachment_tray.ts +8 -0
  38. package/src/ui/checkpoint_menu.ts +153 -0
  39. package/src/ui/question_card.ts +216 -0
  40. package/src/ui/skills_menu.ts +6 -0
  41. package/src/ui/styles.ts +197 -0
  42. package/src/ui/thoughts_block.ts +1 -0
  43. package/src/ui/thread_drawer.ts +11 -0
  44. package/src/ui/ui_strings.ts +45 -0
  45. package/src/version.ts +1 -1
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
3
+ /** What the inline approval card displays for one server-side-tool interrupt. */
4
+ export interface ApprovalRequest {
5
+ /**
6
+ * Human-readable prompt from the AG-UI interrupt (e.g.
7
+ * `Approve delete_thing({…})?`). Falls back to the generic
8
+ * {@link UiStrings.approvalPrompt} when the interrupt carries no message.
9
+ */
10
+ message?: string;
11
+ /** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
12
+ toolName?: string;
13
+ }
14
+
15
+ /** Build a labelled action button. */
16
+ function actionButton(modifier: string, label: string): HTMLButtonElement {
17
+ const button = document.createElement("button");
18
+ button.type = "button";
19
+ button.className = `approval-btn approval-btn--${modifier}`;
20
+ button.setAttribute("part", `approval-button approval-${modifier}`);
21
+ button.textContent = label;
22
+ return button;
23
+ }
24
+
25
+ /** Options for {@link requestApproval}. */
26
+ export interface ApprovalOptions {
27
+ /**
28
+ * Aborting this signal resolves the card as **denied** (buttons disabled,
29
+ * `data-resolved="denied"`) — the hook a Stop control uses to dismiss a
30
+ * pending approval when the user cancels the whole run.
31
+ */
32
+ signal?: AbortSignal;
33
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
34
+ strings?: UiStrings;
35
+ }
36
+
37
+ /**
38
+ * A fully custom renderer for a server-side-tool approval, set via
39
+ * `AgUiChat.approvalRenderer`. Receives the {@link ApprovalRequest} (the
40
+ * interrupt's message + tool name) and an `AbortSignal` that fires when the run
41
+ * is stopped, and resolves `true` to approve or `false` to deny. When provided
42
+ * it **replaces** the built-in {@link requestApproval} card entirely — the host
43
+ * owns the DOM, so it can render a native modal, a framework component, or
44
+ * anything else. See the `strings` / `::part()` seams for styling the built-in
45
+ * card instead of replacing it.
46
+ */
47
+ export type ApprovalRenderer = (
48
+ request: ApprovalRequest,
49
+ options: { signal: AbortSignal },
50
+ ) => Promise<boolean>;
51
+
52
+ /**
53
+ * Append an inline **approval** card to ``host`` and resolve when the user
54
+ * decides whether a gated *server-side* tool may run.
55
+ *
56
+ * The server-side approval gate is distinct from the client-tool confirmation
57
+ * card ({@link requestConfirmation}): a destructive server tool defers instead
58
+ * of executing, and the run finishes on an AG-UI *interrupt* the client answers
59
+ * with `resume[]`. This card is the browser half of that loop — it reads
60
+ * naturally after the tool-call card whose execution it gates, and resolves
61
+ * ``true`` to approve (run the tool) or ``false`` to deny. The card stays in the
62
+ * transcript as a resolved record (buttons disabled, `data-resolved` set)
63
+ * rather than vanishing.
64
+ */
65
+ export function requestApproval(
66
+ host: Node & ParentNode,
67
+ request: ApprovalRequest,
68
+ options: ApprovalOptions = {},
69
+ ): Promise<boolean> {
70
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
71
+ return new Promise<boolean>((resolve) => {
72
+ const card = document.createElement("div");
73
+ card.className = "approval";
74
+ card.setAttribute("part", "approval");
75
+ if (request.toolName !== undefined) {
76
+ card.setAttribute("data-tool-name", request.toolName);
77
+ }
78
+ card.setAttribute("role", "group");
79
+ card.setAttribute("aria-label", strings.approveAction);
80
+
81
+ const body = document.createElement("div");
82
+ body.className = "approval-body";
83
+ body.setAttribute("part", "approval-body");
84
+ body.textContent = request.message ?? strings.approvalPrompt;
85
+
86
+ const actions = document.createElement("div");
87
+ actions.className = "approval-actions";
88
+ actions.setAttribute("part", "approval-actions");
89
+
90
+ const deny = actionButton("deny", strings.deny);
91
+ const approve = actionButton("approve", strings.approve);
92
+
93
+ let settled = false;
94
+ const close = (approved: boolean): void => {
95
+ if (settled) {
96
+ return;
97
+ }
98
+ settled = true;
99
+ deny.disabled = true;
100
+ approve.disabled = true;
101
+ card.setAttribute("data-resolved", approved ? "approved" : "denied");
102
+ resolve(approved);
103
+ };
104
+
105
+ deny.addEventListener("click", () => close(false));
106
+ approve.addEventListener("click", () => close(true));
107
+ options.signal?.addEventListener("abort", () => close(false), { once: true });
108
+
109
+ actions.append(deny, approve);
110
+ card.append(body, actions);
111
+ host.appendChild(card);
112
+ if (options.signal?.aborted === true) {
113
+ // The run was cancelled before the card could ask; record the denial.
114
+ close(false);
115
+ return;
116
+ }
117
+ approve.focus();
118
+ });
119
+ }
@@ -9,6 +9,7 @@ import type { AttachmentRef } from "../core/attachment.js";
9
9
  export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
10
10
  const list = document.createElement("div");
11
11
  list.className = "attachment-chips";
12
+ list.setAttribute("part", "attachment-chips");
12
13
  for (const ref of refs) {
13
14
  list.appendChild(renderChip(ref));
14
15
  }
@@ -18,19 +19,23 @@ export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivEl
18
19
  function renderChip(ref: AttachmentRef): HTMLDivElement {
19
20
  const chip = document.createElement("div");
20
21
  chip.className = "attachment-chip attachment-chip--ready";
22
+ chip.setAttribute("part", "attachment-chip");
21
23
 
22
24
  const icon = document.createElement("span");
23
25
  icon.className = "attachment-chip-icon";
26
+ icon.setAttribute("part", "attachment-chip-icon");
24
27
  icon.textContent = iconFor(ref.mime);
25
28
  icon.setAttribute("aria-hidden", "true");
26
29
 
27
30
  const name = document.createElement("span");
28
31
  name.className = "attachment-chip-name";
32
+ name.setAttribute("part", "attachment-chip-name");
29
33
  name.textContent = ref.name;
30
34
  name.title = ref.name;
31
35
 
32
36
  const size = document.createElement("span");
33
37
  size.className = "attachment-chip-size";
38
+ size.setAttribute("part", "attachment-chip-size");
34
39
  size.textContent = formatBytes(ref.size);
35
40
 
36
41
  chip.append(icon, name, size);
@@ -205,19 +205,23 @@ export class AttachmentTray {
205
205
  #renderChip(item: TrayItem): HTMLDivElement {
206
206
  const chip = document.createElement("div");
207
207
  chip.className = `attachment-chip attachment-chip--${item.status}`;
208
+ chip.setAttribute("part", "attachment-chip");
208
209
 
209
210
  const icon = document.createElement("span");
210
211
  icon.className = "attachment-chip-icon";
212
+ icon.setAttribute("part", "attachment-chip-icon");
211
213
  icon.textContent = iconFor(item.file.type);
212
214
  icon.setAttribute("aria-hidden", "true");
213
215
 
214
216
  const name = document.createElement("span");
215
217
  name.className = "attachment-chip-name";
218
+ name.setAttribute("part", "attachment-chip-name");
216
219
  name.textContent = item.file.name;
217
220
  name.title = item.file.name;
218
221
 
219
222
  const meta = document.createElement("span");
220
223
  meta.className = "attachment-chip-size";
224
+ meta.setAttribute("part", "attachment-chip-size");
221
225
  meta.textContent =
222
226
  item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
223
227
 
@@ -226,8 +230,10 @@ export class AttachmentTray {
226
230
  if (item.status === ATTACHMENT_STATUS.UPLOADING) {
227
231
  const bar = document.createElement("div");
228
232
  bar.className = "attachment-chip-bar";
233
+ bar.setAttribute("part", "attachment-chip-bar");
229
234
  const fill = document.createElement("div");
230
235
  fill.className = "attachment-chip-bar-fill";
236
+ fill.setAttribute("part", "attachment-chip-bar-fill");
231
237
  fill.style.width = `${Math.round(item.progress * 100)}%`;
232
238
  bar.appendChild(fill);
233
239
  chip.appendChild(bar);
@@ -237,6 +243,7 @@ export class AttachmentTray {
237
243
  const retry = document.createElement("button");
238
244
  retry.type = "button";
239
245
  retry.className = "attachment-chip-retry";
246
+ retry.setAttribute("part", "attachment-chip-retry");
240
247
  retry.title = this.#strings.retry;
241
248
  retry.setAttribute("aria-label", this.#strings.retryUpload);
242
249
  retry.textContent = "↻";
@@ -247,6 +254,7 @@ export class AttachmentTray {
247
254
  const remove = document.createElement("button");
248
255
  remove.type = "button";
249
256
  remove.className = "attachment-chip-remove";
257
+ remove.setAttribute("part", "attachment-chip-remove");
250
258
  remove.title = this.#strings.remove;
251
259
  remove.setAttribute("aria-label", this.#strings.removeAttachment);
252
260
  remove.textContent = "✕";
@@ -0,0 +1,153 @@
1
+ import type { RunRow } from "../core/run_index.js";
2
+ import { relativeTime } from "./relative_time.js";
3
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
4
+
5
+ /** How the host continues a picked run. */
6
+ export type CheckpointVerb = "resume" | "fork";
7
+
8
+ /**
9
+ * The checkpoint panel: continuable runs, each offering **resume** or **fork**.
10
+ *
11
+ * A separate surface from the thread drawer on purpose — they are different
12
+ * axes. A thread is a conversation you switch *to*; a checkpoint is a run you
13
+ * continue *from*, and one thread can hold many. Folding them into one list
14
+ * would make "resume" look like "open", which it isn't: resuming starts a new
15
+ * run seeded from a snapshot.
16
+ *
17
+ * Only rows the server marked `continuable` are worth offering, so the host
18
+ * feeds those; a run with no snapshot would resume from nothing. Pure DOM in
19
+ * the spirit of {@link SkillsMenu} — the host appends {@link element}, toggles
20
+ * it, feeds rows via {@link setRuns}, and acts on {@link onPick}.
21
+ */
22
+ export class CheckpointMenu {
23
+ /** The panel root. Append to the chat shell; hidden until opened. */
24
+ readonly element: HTMLDivElement;
25
+
26
+ readonly #onPick: (runId: string, verb: CheckpointVerb) => void;
27
+ readonly #list: HTMLDivElement;
28
+ readonly #heading: HTMLSpanElement;
29
+ #strings: UiStrings;
30
+ #runs: readonly RunRow[] = [];
31
+
32
+ constructor(
33
+ onPick: (runId: string, verb: CheckpointVerb) => void,
34
+ strings: UiStrings = DEFAULT_UI_STRINGS,
35
+ ) {
36
+ this.#onPick = onPick;
37
+ this.#strings = strings;
38
+
39
+ this.element = document.createElement("div");
40
+ this.element.className = "checkpoints";
41
+ this.element.setAttribute("part", "checkpoints");
42
+ this.element.setAttribute("role", "dialog");
43
+ this.element.setAttribute("aria-label", strings.checkpoints);
44
+ this.element.hidden = true;
45
+
46
+ const header = document.createElement("div");
47
+ header.className = "checkpoints-header";
48
+ header.setAttribute("part", "checkpoints-header");
49
+ this.#heading = document.createElement("span");
50
+ this.#heading.className = "checkpoints-title";
51
+ this.#heading.textContent = strings.checkpoints;
52
+ header.append(this.#heading);
53
+
54
+ this.#list = document.createElement("div");
55
+ this.#list.className = "checkpoints-list";
56
+ this.#list.setAttribute("part", "checkpoints-list");
57
+
58
+ this.element.append(header, this.#list);
59
+ this.element.addEventListener("keydown", (event) => {
60
+ if (event.key === "Escape") {
61
+ event.stopPropagation();
62
+ this.close();
63
+ }
64
+ });
65
+ }
66
+
67
+ /** Replace the rows. The host passes only `continuable` runs. */
68
+ setRuns(runs: readonly RunRow[]): void {
69
+ this.#runs = runs;
70
+ this.#render();
71
+ }
72
+
73
+ /** Re-localize a panel built before the host's strings resolved. */
74
+ setStrings(strings: UiStrings): void {
75
+ this.#strings = strings;
76
+ this.element.setAttribute("aria-label", strings.checkpoints);
77
+ this.#heading.textContent = strings.checkpoints;
78
+ this.#render();
79
+ }
80
+
81
+ open(): void {
82
+ this.element.hidden = false;
83
+ }
84
+
85
+ close(): void {
86
+ this.element.hidden = true;
87
+ }
88
+
89
+ get open_(): boolean {
90
+ return !this.element.hidden;
91
+ }
92
+
93
+ #render(): void {
94
+ this.#list.replaceChildren();
95
+ if (this.#runs.length === 0) {
96
+ const empty = document.createElement("div");
97
+ empty.className = "checkpoints-empty";
98
+ empty.setAttribute("part", "checkpoints-empty");
99
+ empty.textContent = this.#strings.noCheckpoints;
100
+ this.#list.append(empty);
101
+ return;
102
+ }
103
+ for (const run of this.#runs) {
104
+ this.#list.append(this.#row(run));
105
+ }
106
+ }
107
+
108
+ #row(run: RunRow): HTMLDivElement {
109
+ const row = document.createElement("div");
110
+ row.className = "checkpoint-row";
111
+ row.setAttribute("part", "checkpoint-row");
112
+
113
+ const label = document.createElement("span");
114
+ label.className = "checkpoint-label";
115
+ // A run id is opaque to a person, so the time is the identifying detail;
116
+ // the id rides `title` for anyone who needs to correlate with server logs.
117
+ label.textContent =
118
+ run.started_at === null
119
+ ? run.run_id
120
+ : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
121
+ label.title = run.run_id;
122
+ row.append(label);
123
+
124
+ if (run.parent_run_id !== null) {
125
+ // Lineage, so a branch doesn't read as a duplicate of its parent.
126
+ const branch = document.createElement("span");
127
+ branch.className = "checkpoint-branch";
128
+ branch.setAttribute("part", "checkpoint-branch");
129
+ branch.textContent = this.#strings.forkedRun;
130
+ branch.title = run.parent_run_id;
131
+ row.append(branch);
132
+ }
133
+
134
+ row.append(
135
+ this.#action(run.run_id, "resume", this.#strings.resumeRun),
136
+ this.#action(run.run_id, "fork", this.#strings.forkRun),
137
+ );
138
+ return row;
139
+ }
140
+
141
+ #action(runId: string, verb: CheckpointVerb, label: string): HTMLButtonElement {
142
+ const button = document.createElement("button");
143
+ button.type = "button";
144
+ button.className = `checkpoint-action checkpoint-${verb}`;
145
+ button.setAttribute("part", `checkpoint-action checkpoint-${verb}`);
146
+ button.textContent = label;
147
+ button.addEventListener("click", () => {
148
+ this.close();
149
+ this.#onPick(runId, verb);
150
+ });
151
+ return button;
152
+ }
153
+ }
@@ -0,0 +1,216 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
3
+ /** What the inline question card asks (the `ask_user` frontend tool's args). */
4
+ export interface QuestionRequest {
5
+ /** The question shown to the user. */
6
+ question: string;
7
+ /**
8
+ * Preset choices rendered as radios. When empty/omitted the card is a plain
9
+ * free-text prompt.
10
+ */
11
+ options?: readonly string[];
12
+ /**
13
+ * Whether the user may type a custom answer. With `options`, adds an "other"
14
+ * radio revealing a text field; without options the card is free-text anyway.
15
+ */
16
+ allowCustom?: boolean;
17
+ }
18
+
19
+ /** Options for {@link requestQuestion}. */
20
+ export interface QuestionOptions {
21
+ /**
22
+ * Aborting this signal resolves the card with an empty answer (fields
23
+ * disabled) — the hook a Stop control uses to dismiss an open question when
24
+ * the user cancels the whole run.
25
+ */
26
+ signal?: AbortSignal;
27
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
28
+ strings?: UiStrings;
29
+ }
30
+
31
+ /**
32
+ * A fully custom renderer for the `ask_user` question, set via
33
+ * `AgUiChat.askUserRenderer`. Receives the parsed {@link QuestionRequest} and an
34
+ * `AbortSignal` that fires when the run is stopped, and resolves with the user's
35
+ * answer (an empty string signals "no answer", e.g. on abort). When provided it
36
+ * **replaces** the built-in {@link requestQuestion} card entirely — the host owns
37
+ * the DOM, so it can render a native modal, a framework component, or anything
38
+ * else. See the `strings` / `::part()` seams for styling the built-in card
39
+ * instead of replacing it.
40
+ */
41
+ export type QuestionRenderer = (
42
+ request: QuestionRequest,
43
+ options: { signal: AbortSignal },
44
+ ) => Promise<string>;
45
+
46
+ /** A single custom-answer text field. */
47
+ function answerInput(placeholder: string): HTMLInputElement {
48
+ const input = document.createElement("input");
49
+ input.type = "text";
50
+ input.className = "question-input";
51
+ input.setAttribute("part", "question-input");
52
+ input.placeholder = placeholder;
53
+ return input;
54
+ }
55
+
56
+ /**
57
+ * Append an inline **question** card to ``host`` and resolve with the user's
58
+ * answer — the browser half of the built-in `ask_user` frontend tool.
59
+ *
60
+ * Unlike the confirmation/approval cards (which resolve a yes/no), this collects
61
+ * a typed answer: a radio pick from ``options``, or free text (when
62
+ * ``allowCustom`` or no ``options`` are given). The card stays in the transcript
63
+ * as a resolved record (controls disabled, `data-resolved` set) rather than
64
+ * vanishing. A Stop while it is open resolves it with an empty string.
65
+ */
66
+ export function requestQuestion(
67
+ host: Node & ParentNode,
68
+ request: QuestionRequest,
69
+ options: QuestionOptions = {},
70
+ ): Promise<string> {
71
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
72
+ const choices = request.options ?? [];
73
+ const hasChoices = choices.length > 0;
74
+ // Free text is offered when there are no preset choices, or when custom
75
+ // answers are explicitly allowed alongside them (via an "other" radio).
76
+ const allowsText = !hasChoices || request.allowCustom === true;
77
+
78
+ return new Promise<string>((resolve) => {
79
+ const card = document.createElement("div");
80
+ card.className = "question";
81
+ card.setAttribute("part", "question");
82
+ card.setAttribute("role", "group");
83
+ card.setAttribute("aria-label", strings.askUserAction);
84
+
85
+ const body = document.createElement("div");
86
+ body.className = "question-body";
87
+ body.setAttribute("part", "question-body");
88
+ body.textContent = request.question;
89
+
90
+ const form = document.createElement("div");
91
+ form.className = "question-options";
92
+ form.setAttribute("part", "question-options");
93
+
94
+ // `name` scopes the radio group to this card so multiple open cards don't
95
+ // interfere; a per-card token keeps it unique without module state.
96
+ const group = `q-${choices.length}-${request.question.length}`;
97
+ const radios: HTMLInputElement[] = [];
98
+ for (const choice of choices) {
99
+ const label = document.createElement("label");
100
+ label.className = "question-choice";
101
+ label.setAttribute("part", "question-choice");
102
+ const radio = document.createElement("input");
103
+ radio.type = "radio";
104
+ radio.name = group;
105
+ radio.value = choice;
106
+ radio.setAttribute("part", "question-radio");
107
+ const text = document.createElement("span");
108
+ text.setAttribute("part", "question-choice-text");
109
+ text.textContent = choice;
110
+ label.append(radio, text);
111
+ form.appendChild(label);
112
+ radios.push(radio);
113
+ }
114
+
115
+ // The "other" radio (only alongside choices) toggles the free-text field.
116
+ let otherRadio: HTMLInputElement | null = null;
117
+ let input: HTMLInputElement | null = null;
118
+ if (allowsText) {
119
+ input = answerInput(strings.answerPlaceholder);
120
+ if (hasChoices) {
121
+ const label = document.createElement("label");
122
+ label.className = "question-choice";
123
+ label.setAttribute("part", "question-choice");
124
+ otherRadio = document.createElement("input");
125
+ otherRadio.type = "radio";
126
+ otherRadio.name = group;
127
+ otherRadio.value = "";
128
+ otherRadio.setAttribute("part", "question-radio");
129
+ const text = document.createElement("span");
130
+ text.setAttribute("part", "question-choice-text");
131
+ text.textContent = strings.otherOption;
132
+ label.append(otherRadio, text);
133
+ form.appendChild(label);
134
+ input.disabled = true;
135
+ }
136
+ form.appendChild(input);
137
+ }
138
+
139
+ const actions = document.createElement("div");
140
+ actions.className = "question-actions";
141
+ actions.setAttribute("part", "question-actions");
142
+ const submit = document.createElement("button");
143
+ submit.type = "button";
144
+ submit.className = "question-btn";
145
+ submit.setAttribute("part", "question-button");
146
+ submit.textContent = strings.submit;
147
+ actions.appendChild(submit);
148
+
149
+ let settled = false;
150
+ const answerFor = (): string | null => {
151
+ const picked = radios.find((r) => r.checked);
152
+ if (picked !== undefined) {
153
+ return picked.value;
154
+ }
155
+ if (input !== null && (otherRadio === null || otherRadio.checked)) {
156
+ const typed = input.value.trim();
157
+ return typed === "" ? null : typed;
158
+ }
159
+ return null;
160
+ };
161
+ const refresh = (): void => {
162
+ if (input !== null && otherRadio !== null) {
163
+ input.disabled = !otherRadio.checked;
164
+ }
165
+ submit.disabled = answerFor() === null;
166
+ };
167
+ const close = (answer: string): void => {
168
+ if (settled) {
169
+ return;
170
+ }
171
+ settled = true;
172
+ submit.disabled = true;
173
+ for (const radio of radios) {
174
+ radio.disabled = true;
175
+ }
176
+ if (otherRadio !== null) {
177
+ otherRadio.disabled = true;
178
+ }
179
+ if (input !== null) {
180
+ input.disabled = true;
181
+ }
182
+ card.setAttribute("data-resolved", answer === "" ? "cancelled" : "answered");
183
+ resolve(answer);
184
+ };
185
+
186
+ for (const radio of [...radios, ...(otherRadio !== null ? [otherRadio] : [])]) {
187
+ radio.addEventListener("change", refresh);
188
+ }
189
+ input?.addEventListener("input", refresh);
190
+ input?.addEventListener("keydown", (event) => {
191
+ if (event.key === "Enter") {
192
+ event.preventDefault();
193
+ const answer = answerFor();
194
+ if (answer !== null) {
195
+ close(answer);
196
+ }
197
+ }
198
+ });
199
+ submit.addEventListener("click", () => {
200
+ const answer = answerFor();
201
+ if (answer !== null) {
202
+ close(answer);
203
+ }
204
+ });
205
+ options.signal?.addEventListener("abort", () => close(""), { once: true });
206
+
207
+ card.append(body, form, actions);
208
+ host.appendChild(card);
209
+ if (options.signal?.aborted === true) {
210
+ close("");
211
+ return;
212
+ }
213
+ refresh();
214
+ (hasChoices ? radios[0] : input)?.focus();
215
+ });
216
+ }
@@ -24,9 +24,11 @@ export class SkillsMenu {
24
24
  this.#onPick = onPick;
25
25
  this.chips = document.createElement("div");
26
26
  this.chips.className = "skill-chips";
27
+ this.chips.setAttribute("part", "skill-chips");
27
28
  this.chips.hidden = true;
28
29
  this.palette = document.createElement("div");
29
30
  this.palette.className = "skill-palette";
31
+ this.palette.setAttribute("part", "skill-palette");
30
32
  this.palette.setAttribute("role", "listbox");
31
33
  this.palette.hidden = true;
32
34
  }
@@ -137,6 +139,7 @@ export class SkillsMenu {
137
139
  const button = document.createElement("button");
138
140
  button.type = "button";
139
141
  button.className = "skill-chip";
142
+ button.setAttribute("part", "skill-chip");
140
143
  button.textContent = skill.title;
141
144
  button.addEventListener("click", () => this.#pick(skill));
142
145
  this.chips.appendChild(button);
@@ -149,17 +152,20 @@ export class SkillsMenu {
149
152
  const item = document.createElement("button");
150
153
  item.type = "button";
151
154
  item.className = "skill-item";
155
+ item.setAttribute("part", "skill-item");
152
156
  item.setAttribute("role", "option");
153
157
  item.setAttribute("aria-selected", index === this.#activeIndex ? "true" : "false");
154
158
 
155
159
  const title = document.createElement("span");
156
160
  title.className = "skill-item-title";
161
+ title.setAttribute("part", "skill-item-title");
157
162
  title.textContent = skill.title;
158
163
  item.appendChild(title);
159
164
 
160
165
  if (skill.description !== undefined) {
161
166
  const desc = document.createElement("span");
162
167
  desc.className = "skill-item-desc";
168
+ desc.setAttribute("part", "skill-item-desc");
163
169
  desc.textContent = skill.description;
164
170
  item.appendChild(desc);
165
171
  }