@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
@@ -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
  /**
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export {
27
27
  type AgUiToolCall,
28
28
  ConnectionLostError,
29
29
  type ExecuteTool,
30
+ type InterruptResponse,
31
+ type ResolveInterrupts,
30
32
  type ToolExecution,
31
33
  } from "./core/agui_client.js";
32
34
  export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
@@ -98,12 +100,24 @@ export {
98
100
  type RouteWithParams,
99
101
  } from "./tools/route_map.js";
100
102
  export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
103
+ export {
104
+ type ApprovalOptions,
105
+ type ApprovalRenderer,
106
+ type ApprovalRequest,
107
+ requestApproval,
108
+ } from "./ui/approval_card.js";
101
109
  export {
102
110
  type ConfirmationOptions,
103
111
  type ConfirmationRequest,
104
112
  requestConfirmation,
105
113
  } from "./ui/confirmation_card.js";
106
114
  export { prettifyToolName } from "./ui/prettify_tool_name.js";
115
+ export {
116
+ type QuestionOptions,
117
+ type QuestionRenderer,
118
+ type QuestionRequest,
119
+ requestQuestion,
120
+ } from "./ui/question_card.js";
107
121
  export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
108
122
  export {
109
123
  type SettledStatus,
@@ -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);
@@ -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?.();
@@ -168,19 +205,23 @@ export class AttachmentTray {
168
205
  #renderChip(item: TrayItem): HTMLDivElement {
169
206
  const chip = document.createElement("div");
170
207
  chip.className = `attachment-chip attachment-chip--${item.status}`;
208
+ chip.setAttribute("part", "attachment-chip");
171
209
 
172
210
  const icon = document.createElement("span");
173
211
  icon.className = "attachment-chip-icon";
212
+ icon.setAttribute("part", "attachment-chip-icon");
174
213
  icon.textContent = iconFor(item.file.type);
175
214
  icon.setAttribute("aria-hidden", "true");
176
215
 
177
216
  const name = document.createElement("span");
178
217
  name.className = "attachment-chip-name";
218
+ name.setAttribute("part", "attachment-chip-name");
179
219
  name.textContent = item.file.name;
180
220
  name.title = item.file.name;
181
221
 
182
222
  const meta = document.createElement("span");
183
223
  meta.className = "attachment-chip-size";
224
+ meta.setAttribute("part", "attachment-chip-size");
184
225
  meta.textContent =
185
226
  item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
186
227
 
@@ -189,8 +230,10 @@ export class AttachmentTray {
189
230
  if (item.status === ATTACHMENT_STATUS.UPLOADING) {
190
231
  const bar = document.createElement("div");
191
232
  bar.className = "attachment-chip-bar";
233
+ bar.setAttribute("part", "attachment-chip-bar");
192
234
  const fill = document.createElement("div");
193
235
  fill.className = "attachment-chip-bar-fill";
236
+ fill.setAttribute("part", "attachment-chip-bar-fill");
194
237
  fill.style.width = `${Math.round(item.progress * 100)}%`;
195
238
  bar.appendChild(fill);
196
239
  chip.appendChild(bar);
@@ -200,6 +243,7 @@ export class AttachmentTray {
200
243
  const retry = document.createElement("button");
201
244
  retry.type = "button";
202
245
  retry.className = "attachment-chip-retry";
246
+ retry.setAttribute("part", "attachment-chip-retry");
203
247
  retry.title = this.#strings.retry;
204
248
  retry.setAttribute("aria-label", this.#strings.retryUpload);
205
249
  retry.textContent = "↻";
@@ -210,6 +254,7 @@ export class AttachmentTray {
210
254
  const remove = document.createElement("button");
211
255
  remove.type = "button";
212
256
  remove.className = "attachment-chip-remove";
257
+ remove.setAttribute("part", "attachment-chip-remove");
213
258
  remove.title = this.#strings.remove;
214
259
  remove.setAttribute("aria-label", this.#strings.removeAttachment);
215
260
  remove.textContent = "✕";