@artooi/ag-ui-web-component 0.9.0 → 0.10.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 (42) hide show
  1. package/CHANGELOG.md +61 -7
  2. package/dist/ag-ui-web-component.bundle.js +56 -56
  3. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  4. package/dist/core/ag_ui_chat.d.ts +10 -1
  5. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  6. package/dist/core/agui_client.d.ts.map +1 -1
  7. package/dist/core/attachment.d.ts +5 -0
  8. package/dist/core/attachment.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +8 -0
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  12. package/dist/core/upload_attachment.d.ts +8 -2
  13. package/dist/core/upload_attachment.d.ts.map +1 -1
  14. package/dist/index.js +300 -58
  15. package/dist/index.js.map +2 -2
  16. package/dist/ui/attachment_tray.d.ts +7 -1
  17. package/dist/ui/attachment_tray.d.ts.map +1 -1
  18. package/dist/ui/relative_time.d.ts +5 -3
  19. package/dist/ui/relative_time.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/thoughts_block.d.ts +2 -2
  23. package/dist/ui/thread_drawer.d.ts.map +1 -1
  24. package/dist/ui/tool_call_card.d.ts.map +1 -1
  25. package/dist/ui/voice_input.d.ts +9 -1
  26. package/dist/ui/voice_input.d.ts.map +1 -1
  27. package/dist/version.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/core/ag_ui_chat.ts +79 -11
  30. package/src/core/agui_client.ts +14 -3
  31. package/src/core/attachment.ts +21 -1
  32. package/src/core/conversation_store.ts +84 -18
  33. package/src/core/remote_conversation_store.ts +24 -3
  34. package/src/core/upload_attachment.ts +8 -1
  35. package/src/ui/attachment_tray.ts +42 -5
  36. package/src/ui/relative_time.ts +8 -3
  37. package/src/ui/styles.ts +9 -9
  38. package/src/ui/thoughts_block.ts +2 -2
  39. package/src/ui/thread_drawer.ts +83 -9
  40. package/src/ui/tool_call_card.ts +6 -0
  41. package/src/ui/voice_input.ts +21 -1
  42. package/src/version.ts +1 -1
@@ -64,10 +64,11 @@ export interface ClientConversationStore {
64
64
  renameThread(threadId: string, title: string): void;
65
65
  }
66
66
 
67
- const THREAD_KEY = "ag-ui-chat:thread";
68
- const THREADS_KEY = "ag-ui-chat:threads";
69
- const MESSAGES_PREFIX = "ag-ui-chat:messages:";
70
- const CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
67
+ const KEY_ROOT = "ag-ui-chat";
68
+ const THREAD_SUFFIX = "thread";
69
+ const THREADS_SUFFIX = "threads";
70
+ const MESSAGES_SUFFIX = "messages:";
71
+ const CHECKPOINT_SUFFIX = "checkpoint:";
71
72
 
72
73
  const TITLE_LIMIT = 60;
73
74
  const PREVIEW_LIMIT = 100;
@@ -90,33 +91,50 @@ interface StoredThread {
90
91
  * Tracks multiple threads per tab: the active id lives under one key, the
91
92
  * message history / checkpoint are namespaced by id, and a small index feeds
92
93
  * the drawer so it works with no server.
94
+ *
95
+ * An optional `namespace` scopes every key to one element (its `id`, else its
96
+ * endpoint), so two `<ag-ui-chat>` instances — or two apps — on the same origin
97
+ * keep separate active-thread pointers and drawer indexes instead of clobbering
98
+ * each other. Constructing with a namespace migrates any pre-namespacing
99
+ * (`ag-ui-chat:*`) keys into it once, so an existing conversation survives the
100
+ * upgrade; the default empty namespace keeps the legacy origin-global keys.
93
101
  */
94
102
  export class SessionStorageStore implements ClientConversationStore {
103
+ readonly #root: string;
104
+
105
+ constructor(namespace = "") {
106
+ this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
107
+ if (namespace !== "") {
108
+ this.#migrateLegacyKeys();
109
+ }
110
+ }
111
+
95
112
  threadId(): string {
96
- const existing = sessionStorage.getItem(THREAD_KEY);
113
+ const key = this.#key(THREAD_SUFFIX);
114
+ const existing = sessionStorage.getItem(key);
97
115
  if (existing !== null) {
98
116
  return existing;
99
117
  }
100
118
  const id = randomUUID();
101
- sessionStorage.setItem(THREAD_KEY, id);
119
+ sessionStorage.setItem(key, id);
102
120
  return id;
103
121
  }
104
122
 
105
123
  loadMessages(threadId: string): Promise<readonly Message[] | null> {
106
- return Promise.resolve(this.#readJson<Message[]>(MESSAGES_PREFIX + threadId));
124
+ return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
107
125
  }
108
126
 
109
127
  saveMessages(threadId: string, messages: readonly Message[]): void {
110
- sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
128
+ sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
111
129
  this.#touchThread(threadId, messages);
112
130
  }
113
131
 
114
132
  loadCheckpoint(threadId: string): NavigationCheckpoint | null {
115
- return this.#readJson<NavigationCheckpoint>(CHECKPOINT_PREFIX + threadId);
133
+ return this.#readJson<NavigationCheckpoint>(this.#key(CHECKPOINT_SUFFIX + threadId));
116
134
  }
117
135
 
118
136
  saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void {
119
- const key = CHECKPOINT_PREFIX + threadId;
137
+ const key = this.#key(CHECKPOINT_SUFFIX + threadId);
120
138
  if (checkpoint === null) {
121
139
  sessionStorage.removeItem(key);
122
140
  return;
@@ -125,14 +143,14 @@ export class SessionStorageStore implements ClientConversationStore {
125
143
  }
126
144
 
127
145
  clear(threadId: string): void {
128
- sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
129
- sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
146
+ sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
147
+ sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
130
148
  this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
131
149
  // Only drop the active pointer when the active thread itself is cleared, so
132
150
  // the next `threadId()` mints a fresh one. Deleting another thread from the
133
151
  // drawer must not disturb the conversation on screen.
134
- if (sessionStorage.getItem(THREAD_KEY) === threadId) {
135
- sessionStorage.removeItem(THREAD_KEY);
152
+ if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
153
+ sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
136
154
  }
137
155
  }
138
156
 
@@ -144,7 +162,7 @@ export class SessionStorageStore implements ClientConversationStore {
144
162
  }
145
163
 
146
164
  setActiveThread(threadId: string): void {
147
- sessionStorage.setItem(THREAD_KEY, threadId);
165
+ sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
148
166
  }
149
167
 
150
168
  renameThread(threadId: string, title: string): void {
@@ -183,15 +201,53 @@ export class SessionStorageStore implements ClientConversationStore {
183
201
  }
184
202
 
185
203
  #readThreads(): StoredThread[] {
186
- return this.#readJson<StoredThread[]>(THREADS_KEY) ?? [];
204
+ return this.#readJson<StoredThread[]>(this.#key(THREADS_SUFFIX)) ?? [];
187
205
  }
188
206
 
189
207
  #writeThreads(threads: readonly StoredThread[]): void {
208
+ const key = this.#key(THREADS_SUFFIX);
190
209
  if (threads.length === 0) {
191
- sessionStorage.removeItem(THREADS_KEY);
210
+ sessionStorage.removeItem(key);
192
211
  return;
193
212
  }
194
- sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
213
+ sessionStorage.setItem(key, JSON.stringify(threads));
214
+ }
215
+
216
+ /** This store's fully-qualified key for a suffix (namespaced when set). */
217
+ #key(suffix: string): string {
218
+ return `${this.#root}:${suffix}`;
219
+ }
220
+
221
+ /**
222
+ * One-time move of pre-namespacing (`ag-ui-chat:*`) keys into this instance's
223
+ * namespace, so an existing conversation isn't orphaned by the upgrade. Only
224
+ * this store's own keys move (thread pointer, drawer index, per-thread
225
+ * messages/checkpoints) — the element's `collapsed`/`theme` keys are left
226
+ * alone. The first namespaced instance to mount adopts the legacy data; a
227
+ * second namespace finds it gone and starts fresh.
228
+ */
229
+ #migrateLegacyKeys(): void {
230
+ const legacyRoot = `${KEY_ROOT}:`;
231
+ const moves: Array<readonly [string, string]> = [];
232
+ for (let i = 0; i < sessionStorage.length; i += 1) {
233
+ const key = sessionStorage.key(i);
234
+ if (key === null || !key.startsWith(legacyRoot)) {
235
+ continue;
236
+ }
237
+ const suffix = key.slice(legacyRoot.length);
238
+ if (isOwnedSuffix(suffix)) {
239
+ moves.push([key, this.#key(suffix)]);
240
+ }
241
+ }
242
+ // Collected first, mutated second — writing while iterating by index skips
243
+ // entries as the key list shifts.
244
+ for (const [from, to] of moves) {
245
+ const value = sessionStorage.getItem(from);
246
+ if (value !== null && sessionStorage.getItem(to) === null) {
247
+ sessionStorage.setItem(to, value);
248
+ }
249
+ sessionStorage.removeItem(from);
250
+ }
195
251
  }
196
252
 
197
253
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
@@ -208,6 +264,16 @@ export class SessionStorageStore implements ClientConversationStore {
208
264
  }
209
265
  }
210
266
 
267
+ /** Whether a legacy key suffix belongs to the store (vs the element's own keys). */
268
+ function isOwnedSuffix(suffix: string): boolean {
269
+ return (
270
+ suffix === THREAD_SUFFIX ||
271
+ suffix === THREADS_SUFFIX ||
272
+ suffix.startsWith(MESSAGES_SUFFIX) ||
273
+ suffix.startsWith(CHECKPOINT_SUFFIX)
274
+ );
275
+ }
276
+
211
277
  /** The thread title: the first user message, collapsed + truncated. */
212
278
  function deriveTitle(messages: readonly Message[]): string {
213
279
  for (const message of messages) {
@@ -96,7 +96,13 @@ export class RemoteConversationStore implements ClientConversationStore {
96
96
  if (response === null || !response.ok) {
97
97
  return this.#local.loadMessages(threadId);
98
98
  }
99
- const body = (await response.json()) as { messages?: readonly Message[] };
99
+ // A 200 whose body isn't the expected JSON (a proxy's HTML error page, a
100
+ // truncated stream) must not throw an unhandled rejection that the caller's
101
+ // `void #rehydrate()` swallows — fall back to the local cache instead.
102
+ const body = await this.#readJson<{ messages?: readonly Message[] }>(response);
103
+ if (body === null) {
104
+ return this.#local.loadMessages(threadId);
105
+ }
100
106
  return body.messages ?? null;
101
107
  }
102
108
 
@@ -105,15 +111,30 @@ export class RemoteConversationStore implements ClientConversationStore {
105
111
  if (response === null || !response.ok) {
106
112
  return null;
107
113
  }
108
- const body = (await response.json()) as { threads?: readonly ServerThreadRow[] };
114
+ const body = await this.#readJson<{ threads?: readonly ServerThreadRow[] }>(response);
115
+ if (body === null) {
116
+ return null;
117
+ }
109
118
  return body.threads ?? [];
110
119
  }
111
120
 
121
+ /** Parse a `Response` body as JSON, or `null` when it isn't valid JSON. */
122
+ async #readJson<T>(response: Response): Promise<T | null> {
123
+ try {
124
+ return (await response.json()) as T;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
112
130
  #toMeta(row: ServerThreadRow): ThreadMeta {
113
131
  return {
114
132
  threadId: row.thread_id,
115
133
  title: this.#renamed.get(row.thread_id) ?? row.title,
116
- updatedAt: row.updated_at === null ? 0 : Date.parse(row.updated_at),
134
+ // `null` or an unparseable date both become `NaN` (Date.parse's own
135
+ // signal), which `relativeTime` renders as a neutral label rather than
136
+ // "~2950w ago" (epoch 0) or "NaNw ago".
137
+ updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
117
138
  preview: row.preview,
118
139
  };
119
140
  }
@@ -7,10 +7,17 @@ import type { AttachmentRef } from "./attachment.js";
7
7
  * `tus-js-client` or direct-to-S3 adapter — via `AgUiChat.uploadHandler`,
8
8
  * **without** touching the tray, the chips, or the AG-UI wire (refs are
9
9
  * transport-agnostic).
10
+ *
11
+ * The optional third `signal` argument lets the tray abort an in-flight upload
12
+ * when its chip is removed (or the element is torn down), so a cancelled upload
13
+ * doesn't orphan a server-side file. It is non-breaking: existing two-argument
14
+ * handlers keep working (the extra argument is simply ignored), and a custom
15
+ * handler that honours it should abort its own transport when the signal fires.
10
16
  */
11
17
  export type UploadHandler = (
12
18
  file: File,
13
19
  onProgress: (fraction: number) => void,
20
+ signal?: AbortSignal,
14
21
  ) => Promise<AttachmentRef>;
15
22
 
16
23
  /** Options for {@link uploadAttachment}. */
@@ -22,7 +29,7 @@ export interface UploadOptions {
22
29
  /** Progress callback, `0..1`, fired as the body uploads. */
23
30
  readonly onProgress?: (fraction: number) => void;
24
31
  /** Abort signal to cancel the in-flight upload. */
25
- readonly signal?: AbortSignal;
32
+ readonly signal?: AbortSignal | undefined;
26
33
  }
27
34
 
28
35
  /**
@@ -30,6 +30,8 @@ interface TrayItem {
30
30
  progress: number;
31
31
  ref: AttachmentRef | null;
32
32
  error: string;
33
+ /** Aborts the in-flight upload when the chip is removed / the tray cleared. */
34
+ controller: AbortController | null;
33
35
  }
34
36
 
35
37
  /**
@@ -68,6 +70,7 @@ export class AttachmentTray {
68
70
  progress: 0,
69
71
  ref: null,
70
72
  error: "",
73
+ controller: null,
71
74
  };
72
75
  this.#items.push(item);
73
76
  const rejection = this.#reject(file);
@@ -110,12 +113,26 @@ export class AttachmentTray {
110
113
  this.#render();
111
114
  }
112
115
 
113
- /** Drop every chip (a reset / new-chat). */
116
+ /** Drop every chip (a reset / new-chat), aborting any in-flight upload. */
114
117
  clear(): void {
118
+ for (const item of this.#items) {
119
+ item.controller?.abort();
120
+ }
115
121
  this.#items = [];
116
122
  this.#render();
117
123
  }
118
124
 
125
+ /**
126
+ * Abort every in-flight upload without touching the rendered chips — the
127
+ * teardown path when the host element is removed mid-upload, so a cancelled
128
+ * transfer doesn't orphan a server-side file.
129
+ */
130
+ dispose(): void {
131
+ for (const item of this.#items) {
132
+ item.controller?.abort();
133
+ }
134
+ }
135
+
119
136
  /** The size/type rejection reason for a file, or `null` when accepted. */
120
137
  #reject(file: File): string | null {
121
138
  if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
@@ -128,15 +145,32 @@ export class AttachmentTray {
128
145
  }
129
146
 
130
147
  #upload(item: TrayItem): void {
148
+ // Re-apply the client guard on every attempt, not just the first `add` — an
149
+ // ERROR chip always renders retry, so without this a size/type-rejected file
150
+ // would upload in full on ↻.
151
+ const rejection = this.#reject(item.file);
152
+ if (rejection !== null) {
153
+ item.status = ATTACHMENT_STATUS.ERROR;
154
+ item.error = rejection;
155
+ this.#render();
156
+ this.#config.onChange?.();
157
+ return;
158
+ }
131
159
  item.status = ATTACHMENT_STATUS.UPLOADING;
132
160
  item.progress = 0;
133
161
  item.error = "";
162
+ const controller = new AbortController();
163
+ item.controller = controller;
134
164
  this.#render();
135
165
  this.#config
136
- .upload(item.file, (fraction) => {
137
- item.progress = fraction;
138
- this.#render();
139
- })
166
+ .upload(
167
+ item.file,
168
+ (fraction) => {
169
+ item.progress = fraction;
170
+ this.#render();
171
+ },
172
+ controller.signal,
173
+ )
140
174
  .then((ref) => {
141
175
  item.status = ATTACHMENT_STATUS.READY;
142
176
  item.ref = ref;
@@ -146,12 +180,15 @@ export class AttachmentTray {
146
180
  item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
147
181
  })
148
182
  .finally(() => {
183
+ item.controller = null;
149
184
  this.#render();
150
185
  this.#config.onChange?.();
151
186
  });
152
187
  }
153
188
 
154
189
  #remove(item: TrayItem): void {
190
+ // Abort an in-flight upload so removing its chip doesn't orphan the file.
191
+ item.controller?.abort();
155
192
  this.#items = this.#items.filter((other) => other !== item);
156
193
  this.#render();
157
194
  this.#config.onChange?.();
@@ -6,15 +6,20 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
6
6
  *
7
7
  * `now` is injectable so callers (and tests) can pin the reference point; it
8
8
  * defaults to the current time. A timestamp in the future (clock skew) reads as
9
- * `"just now"`. The unit words come from {@link UiStrings} (the `{n}` token is
10
- * filled in here) so a localized host translates them; the bucketing stays
11
- * integer-rounded and locale-neutral.
9
+ * `"just now"`. A non-finite timestamp an unparseable or missing `updated_at`
10
+ * that arrived as `NaN` has no meaningful age, so it falls back to `justNow`
11
+ * rather than rendering `"NaNw ago"` or `"~2950w ago"`. The unit words come from
12
+ * {@link UiStrings} (the `{n}` token is filled in here) so a localized host
13
+ * translates them; the bucketing stays integer-rounded and locale-neutral.
12
14
  */
13
15
  export function relativeTime(
14
16
  timestamp: number,
15
17
  now: number = Date.now(),
16
18
  strings: UiStrings = DEFAULT_UI_STRINGS,
17
19
  ): string {
20
+ if (!Number.isFinite(timestamp)) {
21
+ return strings.justNow;
22
+ }
18
23
  const seconds = Math.round((now - timestamp) / 1000);
19
24
  if (seconds < 60) {
20
25
  return strings.justNow;
package/src/ui/styles.ts CHANGED
@@ -146,7 +146,7 @@ export const STYLES = `
146
146
  --ag-ui-radius: 0;
147
147
  }
148
148
 
149
- /* Page (PAGE-1): full-bleed background with a centred reading column. Unlike
149
+ /* Page: full-bleed background with a centred reading column. Unlike
150
150
  "full" (edge-to-edge, left-aligned messages) the content sits in a column
151
151
  capped at --ag-ui-content-max-width. The column is produced by symmetric auto
152
152
  padding on the scroll area + composer (no per-row wrapper), so user pills
@@ -188,7 +188,7 @@ export const STYLES = `
188
188
  max-width: 100%;
189
189
  }
190
190
 
191
- /* Sidebar (CUST-3): a full-height docked panel that slides open/closed and
191
+ /* Sidebar: a full-height docked panel that slides open/closed and
192
192
  collapses to a slim icon rail (not the floating launcher). Docked right by
193
193
  default; data-side="left" docks it left. Overlay by default — set
194
194
  --ag-ui-position: static (and place this element in your own layout) for a
@@ -298,7 +298,7 @@ export const STYLES = `
298
298
  white-space: nowrap;
299
299
  }
300
300
 
301
- /* Header / launcher icon holder (CUST-2): a slot, with a data-icon-url <img>
301
+ /* Header / launcher icon holder: a slot, with a data-icon-url <img>
302
302
  fallback, sized via --ag-ui-icon-size. */
303
303
  .icon-holder {
304
304
  display: inline-flex;
@@ -372,7 +372,7 @@ export const STYLES = `
372
372
  gap: var(--ag-ui-space);
373
373
  }
374
374
 
375
- /* Empty-state region (CUST-1 slot): centred while it's the only thing in the
375
+ /* Empty-state region (slot): centred while it's the only thing in the
376
376
  list, hidden as soon as a message, card, or pending indicator renders. */
377
377
  .empty {
378
378
  margin: auto;
@@ -384,7 +384,7 @@ export const STYLES = `
384
384
  display: none;
385
385
  }
386
386
 
387
- /* ── Answer group / well (WELL-1) ─────────────────────────────────────────
387
+ /* ── Answer group / well ─────────────────────────────────────────
388
388
  One .answer per assistant turn wraps the streamed text, its tool cards,
389
389
  and the pending indicator so a whole answer reads (and can be boxed) as one
390
390
  unit. A flex column on the message-list gap, stretched to the list width so
@@ -553,7 +553,7 @@ export const STYLES = `
553
553
  }
554
554
  }
555
555
 
556
- /* ── Thoughts region (THINK-1) ────────────────────────────────────────────
556
+ /* ── Thoughts region ────────────────────────────────────────────
557
557
  A muted, collapsible chain-of-thought at the top of the answer group: open
558
558
  while the model reasons, folded once the answer text starts. */
559
559
  .thoughts {
@@ -646,7 +646,7 @@ export const STYLES = `
646
646
  word-break: break-word;
647
647
  }
648
648
 
649
- /* Leading status icon (CARD-1). Empty in the DOM — the glyph/spinner is drawn
649
+ /* Leading status icon. Empty in the DOM — the glyph/spinner is drawn
650
650
  here from the card's data-status, so it stays themeable. */
651
651
  .tool-call-icon {
652
652
  flex: none;
@@ -694,7 +694,7 @@ export const STYLES = `
694
694
  }
695
695
  }
696
696
 
697
- /* Inline display mode (CARD-1): the lightest card — drop the box chrome so the
697
+ /* Inline display mode: the lightest card — drop the box chrome so the
698
698
  status row reads as one line of the answer; the result toggle still expands
699
699
  below it. */
700
700
  .tool-call[data-display="inline"] {
@@ -824,7 +824,7 @@ export const STYLES = `
824
824
  display: none;
825
825
  }
826
826
 
827
- /* The 🎤 mic button (VOICE-1); shown only once #wireVoice mounts it. */
827
+ /* The 🎤 mic button; shown only once #wireVoice mounts it. */
828
828
  .voice-slot {
829
829
  display: contents;
830
830
  }
@@ -2,9 +2,9 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
2
 
3
3
  /**
4
4
  * A muted, collapsible "thinking" region for a reasoning model's streamed
5
- * chain-of-thought (THINK-1).
5
+ * chain-of-thought.
6
6
  *
7
- * Lives at the top of the current answer group (the WELL-1 turn container): it
7
+ * Lives at the top of the current answer group (the turn container): it
8
8
  * opens expanded while the model reasons — {@link stream} replaces its body with
9
9
  * the running reasoning buffer — and {@link collapse} folds it away once the
10
10
  * answer's first text token arrives, so the thoughts don't crowd the answer.
@@ -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. */
@@ -182,24 +233,47 @@ export class ThreadDrawer {
182
233
  return row;
183
234
  }
184
235
 
185
- /** Swap a row for an inline rename input; Enter commits, Escape cancels. */
236
+ /** Swap a row for an inline rename input; Enter/blur commits, Escape cancels. */
186
237
  #startRename(row: HTMLDivElement, meta: ThreadMeta): void {
187
238
  const input = document.createElement("input");
188
239
  input.type = "text";
189
240
  input.className = "drawer-rename-input";
190
241
  input.value = meta.title;
242
+ // One-shot: Enter, Escape, and blur can all fire for a single edit (Enter
243
+ // commits and re-renders, which blurs the detached input); the flag makes
244
+ // the later events no-ops so a rename isn't submitted twice.
245
+ let done = false;
246
+ const commit = (): void => {
247
+ if (done) {
248
+ return;
249
+ }
250
+ done = true;
251
+ const value = input.value.trim();
252
+ if (value === "" || value === meta.title) {
253
+ this.#renderList();
254
+ } else {
255
+ this.#callbacks.onRename(meta.threadId, value);
256
+ }
257
+ };
258
+ const cancel = (): void => {
259
+ if (done) {
260
+ return;
261
+ }
262
+ done = true;
263
+ this.#renderList();
264
+ };
191
265
  input.addEventListener("keydown", (event) => {
192
266
  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
- }
267
+ event.preventDefault();
268
+ commit();
199
269
  } else if (event.key === "Escape") {
200
- this.#renderList();
270
+ // Stop the panel's Escape handler from also closing the drawer.
271
+ event.preventDefault();
272
+ event.stopPropagation();
273
+ cancel();
201
274
  }
202
275
  });
276
+ input.addEventListener("blur", () => commit());
203
277
  row.replaceChildren(input);
204
278
  input.focus();
205
279
  input.select();
@@ -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];
@@ -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.10.0";