@artooi/ag-ui-web-component 0.9.0 → 0.11.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 (61) hide show
  1. package/CHANGELOG.md +104 -7
  2. package/README.md +89 -7
  3. package/dist/ag-ui-web-component.bundle.js +168 -47
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +39 -1
  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/attachment.d.ts +5 -0
  10. package/dist/core/attachment.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +8 -0
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  14. package/dist/core/upload_attachment.d.ts +8 -2
  15. package/dist/core/upload_attachment.d.ts.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +887 -128
  19. package/dist/index.js.map +4 -4
  20. package/dist/ui/approval_card.d.ts +51 -0
  21. package/dist/ui/approval_card.d.ts.map +1 -0
  22. package/dist/ui/attachment_chips.d.ts.map +1 -1
  23. package/dist/ui/attachment_tray.d.ts +7 -1
  24. package/dist/ui/attachment_tray.d.ts.map +1 -1
  25. package/dist/ui/question_card.d.ts +52 -0
  26. package/dist/ui/question_card.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +5 -3
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/skills_menu.d.ts.map +1 -1
  30. package/dist/ui/styles.d.ts +1 -1
  31. package/dist/ui/styles.d.ts.map +1 -1
  32. package/dist/ui/thoughts_block.d.ts +2 -2
  33. package/dist/ui/thoughts_block.d.ts.map +1 -1
  34. package/dist/ui/thread_drawer.d.ts.map +1 -1
  35. package/dist/ui/tool_call_card.d.ts.map +1 -1
  36. package/dist/ui/ui_strings.d.ts +16 -0
  37. package/dist/ui/ui_strings.d.ts.map +1 -1
  38. package/dist/ui/voice_input.d.ts +9 -1
  39. package/dist/ui/voice_input.d.ts.map +1 -1
  40. package/dist/version.d.ts.map +1 -1
  41. package/package.json +4 -4
  42. package/src/core/ag_ui_chat.ts +251 -14
  43. package/src/core/agui_client.ts +95 -9
  44. package/src/core/attachment.ts +21 -1
  45. package/src/core/conversation_store.ts +84 -18
  46. package/src/core/remote_conversation_store.ts +24 -3
  47. package/src/core/upload_attachment.ts +8 -1
  48. package/src/index.ts +14 -0
  49. package/src/ui/approval_card.ts +119 -0
  50. package/src/ui/attachment_chips.ts +5 -0
  51. package/src/ui/attachment_tray.ts +50 -5
  52. package/src/ui/question_card.ts +216 -0
  53. package/src/ui/relative_time.ts +8 -3
  54. package/src/ui/skills_menu.ts +6 -0
  55. package/src/ui/styles.ts +130 -9
  56. package/src/ui/thoughts_block.ts +3 -2
  57. package/src/ui/thread_drawer.ts +94 -9
  58. package/src/ui/tool_call_card.ts +6 -0
  59. package/src/ui/ui_strings.ts +30 -0
  60. package/src/ui/voice_input.ts +21 -1
  61. package/src/version.ts +1 -1
@@ -37,6 +37,8 @@ export class ThreadDrawer {
37
37
  #strings: UiStrings;
38
38
  #threads: readonly ThreadMeta[] = [];
39
39
  #activeId = "";
40
+ /** The element focused before the drawer opened, restored on close. */
41
+ #lastFocused: HTMLElement | null = null;
40
42
 
41
43
  constructor(callbacks: ThreadDrawerCallbacks, strings: UiStrings = DEFAULT_UI_STRINGS) {
42
44
  this.#callbacks = callbacks;
@@ -56,7 +58,10 @@ export class ThreadDrawer {
56
58
  this.#panel.className = "drawer-panel";
57
59
  this.#panel.setAttribute("part", "drawer-panel");
58
60
  this.#panel.setAttribute("role", "dialog");
61
+ this.#panel.setAttribute("aria-modal", "true");
59
62
  this.#panel.setAttribute("aria-label", strings.chatHistory);
63
+ // Escape closes the drawer; Tab is trapped within the panel while it's open.
64
+ this.#panel.addEventListener("keydown", (event) => this.#onPanelKeydown(event));
60
65
 
61
66
  const header = document.createElement("div");
62
67
  header.className = "drawer-header";
@@ -98,15 +103,61 @@ export class ThreadDrawer {
98
103
  }
99
104
 
100
105
  open(): void {
106
+ if (this.isOpen()) {
107
+ return;
108
+ }
109
+ // Remember what had focus so it's restored on close, then move focus into
110
+ // the panel (its first control) so keyboard users land inside the dialog.
111
+ this.#lastFocused = this.#activeElement() as HTMLElement | null;
101
112
  this.element.hidden = false;
113
+ this.#newButton.focus();
102
114
  }
103
115
 
104
116
  close(): void {
117
+ if (!this.isOpen()) {
118
+ return;
119
+ }
105
120
  this.element.hidden = true;
121
+ this.#lastFocused?.focus();
122
+ this.#lastFocused = null;
106
123
  }
107
124
 
108
125
  toggle(): void {
109
- this.element.hidden = !this.element.hidden;
126
+ if (this.isOpen()) {
127
+ this.close();
128
+ } else {
129
+ this.open();
130
+ }
131
+ }
132
+
133
+ /** The currently-focused element within the drawer's root (shadow-aware). */
134
+ #activeElement(): Element | null {
135
+ return (this.element.getRootNode() as Document | ShadowRoot).activeElement;
136
+ }
137
+
138
+ /** Escape-to-close and a Tab focus trap while the dialog is open. */
139
+ #onPanelKeydown(event: KeyboardEvent): void {
140
+ if (event.key === "Escape") {
141
+ event.preventDefault();
142
+ this.close();
143
+ return;
144
+ }
145
+ if (event.key !== "Tab") {
146
+ return;
147
+ }
148
+ const focusables = Array.from(
149
+ this.#panel.querySelectorAll<HTMLElement>("button, input, [tabindex]"),
150
+ ).filter((el) => !el.hidden);
151
+ const first = focusables[0];
152
+ const last = focusables[focusables.length - 1];
153
+ const active = this.#activeElement();
154
+ if (event.shiftKey && active === first) {
155
+ event.preventDefault();
156
+ last?.focus();
157
+ } else if (!event.shiftKey && active === last) {
158
+ event.preventDefault();
159
+ first?.focus();
160
+ }
110
161
  }
111
162
 
112
163
  /** Render the rows (or the empty state), highlighting the active thread. */
@@ -145,12 +196,15 @@ export class ThreadDrawer {
145
196
  select.setAttribute("part", "drawer-row-select");
146
197
  const title = document.createElement("span");
147
198
  title.className = "drawer-row-title";
199
+ title.setAttribute("part", "drawer-row-title");
148
200
  title.textContent = meta.title;
149
201
  const time = document.createElement("span");
150
202
  time.className = "drawer-row-time";
203
+ time.setAttribute("part", "drawer-row-time");
151
204
  time.textContent = relativeTime(meta.updatedAt, undefined, this.#strings);
152
205
  const preview = document.createElement("span");
153
206
  preview.className = "drawer-row-preview";
207
+ preview.setAttribute("part", "drawer-row-preview");
154
208
  preview.textContent = meta.preview;
155
209
  select.append(title, time, preview);
156
210
  select.addEventListener("click", () => {
@@ -161,6 +215,7 @@ export class ThreadDrawer {
161
215
  const rename = document.createElement("button");
162
216
  rename.type = "button";
163
217
  rename.className = "drawer-row-rename";
218
+ rename.setAttribute("part", "drawer-row-rename");
164
219
  rename.title = this.#strings.rename;
165
220
  rename.setAttribute("aria-label", this.#strings.renameConversation);
166
221
  rename.textContent = "✎";
@@ -169,6 +224,7 @@ export class ThreadDrawer {
169
224
  const remove = document.createElement("button");
170
225
  remove.type = "button";
171
226
  remove.className = "drawer-row-delete";
227
+ remove.setAttribute("part", "drawer-row-delete");
172
228
  remove.title = this.#strings.delete;
173
229
  remove.setAttribute("aria-label", this.#strings.deleteConversation);
174
230
  remove.textContent = "🗑";
@@ -176,30 +232,55 @@ export class ThreadDrawer {
176
232
 
177
233
  const actions = document.createElement("div");
178
234
  actions.className = "drawer-row-actions";
235
+ actions.setAttribute("part", "drawer-row-actions");
179
236
  actions.append(rename, remove);
180
237
 
181
238
  row.append(select, actions);
182
239
  return row;
183
240
  }
184
241
 
185
- /** Swap a row for an inline rename input; Enter commits, Escape cancels. */
242
+ /** Swap a row for an inline rename input; Enter/blur commits, Escape cancels. */
186
243
  #startRename(row: HTMLDivElement, meta: ThreadMeta): void {
187
244
  const input = document.createElement("input");
188
245
  input.type = "text";
189
246
  input.className = "drawer-rename-input";
247
+ input.setAttribute("part", "drawer-rename-input");
190
248
  input.value = meta.title;
249
+ // One-shot: Enter, Escape, and blur can all fire for a single edit (Enter
250
+ // commits and re-renders, which blurs the detached input); the flag makes
251
+ // the later events no-ops so a rename isn't submitted twice.
252
+ let done = false;
253
+ const commit = (): void => {
254
+ if (done) {
255
+ return;
256
+ }
257
+ done = true;
258
+ const value = input.value.trim();
259
+ if (value === "" || value === meta.title) {
260
+ this.#renderList();
261
+ } else {
262
+ this.#callbacks.onRename(meta.threadId, value);
263
+ }
264
+ };
265
+ const cancel = (): void => {
266
+ if (done) {
267
+ return;
268
+ }
269
+ done = true;
270
+ this.#renderList();
271
+ };
191
272
  input.addEventListener("keydown", (event) => {
192
273
  if (event.key === "Enter") {
193
- const value = input.value.trim();
194
- if (value === "") {
195
- this.#renderList();
196
- } else {
197
- this.#callbacks.onRename(meta.threadId, value);
198
- }
274
+ event.preventDefault();
275
+ commit();
199
276
  } else if (event.key === "Escape") {
200
- this.#renderList();
277
+ // Stop the panel's Escape handler from also closing the drawer.
278
+ event.preventDefault();
279
+ event.stopPropagation();
280
+ cancel();
201
281
  }
202
282
  });
283
+ input.addEventListener("blur", () => commit());
203
284
  row.replaceChildren(input);
204
285
  input.focus();
205
286
  input.select();
@@ -209,17 +290,21 @@ export class ThreadDrawer {
209
290
  #confirmDelete(row: HTMLDivElement, meta: ThreadMeta): void {
210
291
  const confirm = document.createElement("div");
211
292
  confirm.className = "drawer-confirm";
293
+ confirm.setAttribute("part", "drawer-confirm");
212
294
  const label = document.createElement("span");
213
295
  label.className = "drawer-confirm-label";
296
+ label.setAttribute("part", "drawer-confirm-label");
214
297
  label.textContent = this.#strings.deletePrompt;
215
298
  const yes = document.createElement("button");
216
299
  yes.type = "button";
217
300
  yes.className = "drawer-confirm-yes";
301
+ yes.setAttribute("part", "drawer-confirm-yes");
218
302
  yes.textContent = this.#strings.delete;
219
303
  yes.addEventListener("click", () => this.#callbacks.onDelete(meta.threadId));
220
304
  const no = document.createElement("button");
221
305
  no.type = "button";
222
306
  no.className = "drawer-confirm-no";
307
+ no.setAttribute("part", "drawer-confirm-no");
223
308
  no.textContent = this.#strings.cancel;
224
309
  no.addEventListener("click", () => this.#renderList());
225
310
  confirm.append(label, yes, no);
@@ -126,6 +126,12 @@ export class ToolCallCard {
126
126
  * `inline`), or the args + result together (`compact`).
127
127
  */
128
128
  settle(status: SettledStatus, text: string): void {
129
+ // Idempotent: a duplicate `TOOL_CALL_RESULT`, or a replayed tool message
130
+ // for an already-settled card, must not append a second toggle+body. The
131
+ // first settle wins; later calls are ignored.
132
+ if (this.#settled) {
133
+ return;
134
+ }
129
135
  this.#settled = true;
130
136
  this.element.setAttribute("data-status", status);
131
137
  this.#status.textContent = statusLabels(this.#strings)[status];
@@ -98,6 +98,26 @@ export interface UiStrings {
98
98
  /** Cancel button (confirmation + delete confirm). */
99
99
  cancel: string;
100
100
 
101
+ // ── Approval card (server-side tool gate) ───────────────────────────────────
102
+ /** `aria-label` of the inline server-side-tool approval card. */
103
+ approveAction: string;
104
+ /** Fallback approval prompt when the interrupt carries no message. */
105
+ approvalPrompt: string;
106
+ /** Approve button (runs the gated server-side tool). */
107
+ approve: string;
108
+ /** Deny button (declines the gated server-side tool). */
109
+ deny: string;
110
+
111
+ // ── Question card (the `ask_user` frontend tool) ────────────────────────────
112
+ /** `aria-label` of the inline question card. */
113
+ askUserAction: string;
114
+ /** Radio label for the free-text "other" choice (when custom answers are allowed). */
115
+ otherOption: string;
116
+ /** Placeholder for the free-text answer field. */
117
+ answerPlaceholder: string;
118
+ /** Submit button for the question card. */
119
+ submit: string;
120
+
101
121
  // ── Chat-history drawer ─────────────────────────────────────────────────────
102
122
  /** Drawer heading. */
103
123
  chats: string;
@@ -186,6 +206,16 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
186
206
  confirm: "Confirm",
187
207
  cancel: "Cancel",
188
208
 
209
+ approveAction: "Approve action",
210
+ approvalPrompt: "Approve this action?",
211
+ approve: "Approve",
212
+ deny: "Deny",
213
+
214
+ askUserAction: "Question",
215
+ otherOption: "Other…",
216
+ answerPlaceholder: "Type your answer…",
217
+ submit: "Submit",
218
+
189
219
  chats: "Chats",
190
220
  noConversations: "No conversations yet.",
191
221
  rename: "Rename",
@@ -15,7 +15,7 @@ export interface VoiceInputOptions {
15
15
  }
16
16
 
17
17
  /**
18
- * The composer's voice-input control (VOICE-1): a mic button that records via
18
+ * The composer's voice-input control: a mic button that records via
19
19
  * `MediaRecorder`, then POSTs the clip through a {@link TranscribeHandler} and
20
20
  * hands the transcript back via `onText`.
21
21
  *
@@ -38,6 +38,7 @@ export class VoiceInput {
38
38
  #recorder: MediaRecorder | null = null;
39
39
  #stream: MediaStream | null = null;
40
40
  #chunks: Blob[] = [];
41
+ #disposed = false;
41
42
 
42
43
  constructor(options: VoiceInputOptions) {
43
44
  this.#transcribe = options.transcribe;
@@ -94,7 +95,26 @@ export class VoiceInput {
94
95
  this.#recorder?.stop();
95
96
  }
96
97
 
98
+ /**
99
+ * Tear the control down — the teardown path when the host element is removed
100
+ * mid-recording. Stops any live `MediaRecorder`, releases the mic tracks (so
101
+ * the browser's recording indicator clears), and suppresses the pending
102
+ * transcription: a disconnected control must not fire `onText` back into a
103
+ * detached element.
104
+ */
105
+ dispose(): void {
106
+ this.#disposed = true;
107
+ if (this.#recorder !== null && this.#recorder.state !== "inactive") {
108
+ this.#recorder.stop();
109
+ }
110
+ this.#recorder = null;
111
+ this.#releaseStream();
112
+ }
113
+
97
114
  async #finish(mimeType: string): Promise<void> {
115
+ if (this.#disposed) {
116
+ return;
117
+ }
98
118
  this.#releaseStream();
99
119
  this.#setState("transcribing");
100
120
  const audio = new Blob(this.#chunks, { type: mimeType || "audio/webm" });
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.9.0";
1
+ export const VERSION: string = "0.11.0";