@artooi/ag-ui-web-component 0.5.0 → 0.7.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 (53) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +208 -7
  3. package/dist/ag-ui-web-component.bundle.js +268 -58
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +16 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +33 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +23 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +35 -0
  12. package/dist/core/attachment.d.ts.map +1 -0
  13. package/dist/core/upload_attachment.d.ts +32 -0
  14. package/dist/core/upload_attachment.d.ts.map +1 -0
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1247 -245
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/page_action_tools.d.ts +31 -0
  20. package/dist/tools/page_action_tools.d.ts.map +1 -0
  21. package/dist/ui/attachment_chips.d.ts +13 -0
  22. package/dist/ui/attachment_chips.d.ts.map +1 -0
  23. package/dist/ui/attachment_tray.d.ts +45 -0
  24. package/dist/ui/attachment_tray.d.ts.map +1 -0
  25. package/dist/ui/confirmation_card.d.ts +4 -1
  26. package/dist/ui/confirmation_card.d.ts.map +1 -1
  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/styles.d.ts +1 -1
  30. package/dist/ui/styles.d.ts.map +1 -1
  31. package/dist/ui/thread_drawer.d.ts +7 -1
  32. package/dist/ui/thread_drawer.d.ts.map +1 -1
  33. package/dist/ui/tool_call_card.d.ts +6 -2
  34. package/dist/ui/tool_call_card.d.ts.map +1 -1
  35. package/dist/ui/ui_strings.d.ts +126 -0
  36. package/dist/ui/ui_strings.d.ts.map +1 -0
  37. package/package.json +1 -1
  38. package/src/constants.ts +18 -0
  39. package/src/core/ag_ui_chat.ts +389 -51
  40. package/src/core/agui_client.ts +48 -4
  41. package/src/core/attachment.ts +39 -0
  42. package/src/core/upload_attachment.ts +113 -0
  43. package/src/index.ts +13 -0
  44. package/src/tools/page_action_tools.ts +130 -0
  45. package/src/ui/attachment_chips.ts +68 -0
  46. package/src/ui/attachment_tray.ts +243 -0
  47. package/src/ui/confirmation_card.ts +15 -5
  48. package/src/ui/relative_time.ts +15 -8
  49. package/src/ui/styles.ts +208 -0
  50. package/src/ui/thread_drawer.ts +53 -25
  51. package/src/ui/tool_call_card.ts +40 -17
  52. package/src/ui/ui_strings.ts +208 -0
  53. package/src/version.ts +1 -1
@@ -0,0 +1,243 @@
1
+ import { randomUUID } from "@ag-ui/client";
2
+ import { ATTACHMENT_STATUS } from "../constants.js";
3
+ import type { AttachmentRef } from "../core/attachment.js";
4
+ import type { UploadHandler } from "../core/upload_attachment.js";
5
+ import { formatBytes, iconFor } from "./attachment_chips.js";
6
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
7
+
8
+ /** Status of a pending tray chip. */
9
+ type AttachmentStatus = (typeof ATTACHMENT_STATUS)[keyof typeof ATTACHMENT_STATUS];
10
+
11
+ /** Config the host ({@link AgUiChat}) hands the tray. */
12
+ export interface AttachmentTrayConfig {
13
+ /** Upload one file, reporting `0..1` progress; resolves to a durable ref. */
14
+ readonly upload: UploadHandler;
15
+ /** Client-side size cap in bytes (`0` = no cap). The server stays authoritative. */
16
+ readonly maxBytes: number;
17
+ /** Client-side accept list (`<input accept>` syntax; `""` = any). */
18
+ readonly accept: string;
19
+ /** Fired when the set of attachments changes (add / settle / remove). */
20
+ readonly onChange?: () => void;
21
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
22
+ readonly strings?: UiStrings;
23
+ }
24
+
25
+ /** One pending file in the tray, from pick to ready/error. */
26
+ interface TrayItem {
27
+ readonly localId: string;
28
+ readonly file: File;
29
+ status: AttachmentStatus;
30
+ progress: number;
31
+ ref: AttachmentRef | null;
32
+ error: string;
33
+ }
34
+
35
+ /**
36
+ * The composer's pending-attachments tray: a chip per picked file with a
37
+ * progress bar while it uploads, settling to a ready chip (holding the durable
38
+ * ref) or an error chip (with retry). A *stateful view* in the spirit of
39
+ * {@link ThreadDrawer} — the host appends {@link element}, calls {@link add} on
40
+ * pick/drop, reads {@link readyRefs} when the user sends, and clears it.
41
+ *
42
+ * Client-side size/type guards reject a bad file into an error chip without
43
+ * uploading — instant feedback, but the server is the authority.
44
+ */
45
+ export class AttachmentTray {
46
+ /** The tray root; append above the input row. Hidden while empty. */
47
+ readonly element: HTMLDivElement;
48
+
49
+ readonly #config: AttachmentTrayConfig;
50
+ readonly #strings: UiStrings;
51
+ #items: TrayItem[] = [];
52
+
53
+ constructor(config: AttachmentTrayConfig) {
54
+ this.#config = config;
55
+ this.#strings = config.strings ?? DEFAULT_UI_STRINGS;
56
+ this.element = document.createElement("div");
57
+ this.element.className = "attachment-tray";
58
+ this.element.setAttribute("part", "attachment-tray");
59
+ this.element.hidden = true;
60
+ }
61
+
62
+ /** Queue a file: reject oversize/disallowed into an error chip, else upload. */
63
+ add(file: File): void {
64
+ const item: TrayItem = {
65
+ localId: randomUUID(),
66
+ file,
67
+ status: ATTACHMENT_STATUS.UPLOADING,
68
+ progress: 0,
69
+ ref: null,
70
+ error: "",
71
+ };
72
+ this.#items.push(item);
73
+ const rejection = this.#reject(file);
74
+ if (rejection !== null) {
75
+ item.status = ATTACHMENT_STATUS.ERROR;
76
+ item.error = rejection;
77
+ this.#render();
78
+ this.#config.onChange?.();
79
+ return;
80
+ }
81
+ this.#render();
82
+ this.#config.onChange?.();
83
+ this.#upload(item);
84
+ }
85
+
86
+ /** The durable refs of every chip that finished uploading. */
87
+ readyRefs(): readonly AttachmentRef[] {
88
+ const refs: AttachmentRef[] = [];
89
+ for (const item of this.#items) {
90
+ if (item.ref !== null) {
91
+ refs.push(item.ref);
92
+ }
93
+ }
94
+ return refs;
95
+ }
96
+
97
+ /** Whether any chip is still uploading (a send would drop nothing if false). */
98
+ hasPending(): boolean {
99
+ return this.#items.some((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
100
+ }
101
+
102
+ /** Whether the tray holds no chips. */
103
+ isEmpty(): boolean {
104
+ return this.#items.length === 0;
105
+ }
106
+
107
+ /** Drop the settled (ready / error) chips, leaving any still uploading. */
108
+ clearReady(): void {
109
+ this.#items = this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
110
+ this.#render();
111
+ }
112
+
113
+ /** Drop every chip (a reset / new-chat). */
114
+ clear(): void {
115
+ this.#items = [];
116
+ this.#render();
117
+ }
118
+
119
+ /** The size/type rejection reason for a file, or `null` when accepted. */
120
+ #reject(file: File): string | null {
121
+ if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
122
+ return this.#strings.tooLarge.replace("{size}", formatBytes(this.#config.maxBytes));
123
+ }
124
+ if (!accepts(this.#config.accept, file)) {
125
+ return this.#strings.fileTypeNotAllowed;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ #upload(item: TrayItem): void {
131
+ item.status = ATTACHMENT_STATUS.UPLOADING;
132
+ item.progress = 0;
133
+ item.error = "";
134
+ this.#render();
135
+ this.#config
136
+ .upload(item.file, (fraction) => {
137
+ item.progress = fraction;
138
+ this.#render();
139
+ })
140
+ .then((ref) => {
141
+ item.status = ATTACHMENT_STATUS.READY;
142
+ item.ref = ref;
143
+ })
144
+ .catch((error: unknown) => {
145
+ item.status = ATTACHMENT_STATUS.ERROR;
146
+ item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
147
+ })
148
+ .finally(() => {
149
+ this.#render();
150
+ this.#config.onChange?.();
151
+ });
152
+ }
153
+
154
+ #remove(item: TrayItem): void {
155
+ this.#items = this.#items.filter((other) => other !== item);
156
+ this.#render();
157
+ this.#config.onChange?.();
158
+ }
159
+
160
+ #render(): void {
161
+ this.element.replaceChildren();
162
+ this.element.hidden = this.#items.length === 0;
163
+ for (const item of this.#items) {
164
+ this.element.appendChild(this.#renderChip(item));
165
+ }
166
+ }
167
+
168
+ #renderChip(item: TrayItem): HTMLDivElement {
169
+ const chip = document.createElement("div");
170
+ chip.className = `attachment-chip attachment-chip--${item.status}`;
171
+
172
+ const icon = document.createElement("span");
173
+ icon.className = "attachment-chip-icon";
174
+ icon.textContent = iconFor(item.file.type);
175
+ icon.setAttribute("aria-hidden", "true");
176
+
177
+ const name = document.createElement("span");
178
+ name.className = "attachment-chip-name";
179
+ name.textContent = item.file.name;
180
+ name.title = item.file.name;
181
+
182
+ const meta = document.createElement("span");
183
+ meta.className = "attachment-chip-size";
184
+ meta.textContent =
185
+ item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
186
+
187
+ chip.append(icon, name, meta);
188
+
189
+ if (item.status === ATTACHMENT_STATUS.UPLOADING) {
190
+ const bar = document.createElement("div");
191
+ bar.className = "attachment-chip-bar";
192
+ const fill = document.createElement("div");
193
+ fill.className = "attachment-chip-bar-fill";
194
+ fill.style.width = `${Math.round(item.progress * 100)}%`;
195
+ bar.appendChild(fill);
196
+ chip.appendChild(bar);
197
+ }
198
+
199
+ if (item.status === ATTACHMENT_STATUS.ERROR) {
200
+ const retry = document.createElement("button");
201
+ retry.type = "button";
202
+ retry.className = "attachment-chip-retry";
203
+ retry.title = this.#strings.retry;
204
+ retry.setAttribute("aria-label", this.#strings.retryUpload);
205
+ retry.textContent = "↻";
206
+ retry.addEventListener("click", () => this.#upload(item));
207
+ chip.appendChild(retry);
208
+ }
209
+
210
+ const remove = document.createElement("button");
211
+ remove.type = "button";
212
+ remove.className = "attachment-chip-remove";
213
+ remove.title = this.#strings.remove;
214
+ remove.setAttribute("aria-label", this.#strings.removeAttachment);
215
+ remove.textContent = "✕";
216
+ remove.addEventListener("click", () => this.#remove(item));
217
+ chip.appendChild(remove);
218
+
219
+ return chip;
220
+ }
221
+ }
222
+
223
+ /** Whether `file` matches an `<input accept>` list (`""` accepts anything). */
224
+ function accepts(accept: string, file: File): boolean {
225
+ const tokens = accept
226
+ .split(",")
227
+ .map((token) => token.trim().toLowerCase())
228
+ .filter((token) => token !== "");
229
+ if (tokens.length === 0) {
230
+ return true;
231
+ }
232
+ const mime = file.type.toLowerCase();
233
+ const name = file.name.toLowerCase();
234
+ return tokens.some((token) => {
235
+ if (token.startsWith(".")) {
236
+ return name.endsWith(token);
237
+ }
238
+ if (token.endsWith("/*")) {
239
+ return mime.startsWith(token.slice(0, -1));
240
+ }
241
+ return mime === token;
242
+ });
243
+ }
@@ -1,10 +1,12 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
1
3
  /** What the inline confirmation card displays. */
2
4
  export interface ConfirmationRequest {
3
5
  toolName: string;
4
6
  args: Record<string, unknown>;
5
7
  /**
6
8
  * Human-readable prompt (from the tool's `x-confirm` metadata), e.g.
7
- * "Activate this project?". Falls back to a generic `Run "<tool>"?`.
9
+ * "Activate this project?". Falls back to the generic `confirmRun` template.
8
10
  */
9
11
  message?: string;
10
12
  }
@@ -14,6 +16,7 @@ function actionButton(modifier: string, label: string): HTMLButtonElement {
14
16
  const button = document.createElement("button");
15
17
  button.type = "button";
16
18
  button.className = `confirm-btn confirm-btn--${modifier}`;
19
+ button.setAttribute("part", `confirm-button confirm-${modifier}`);
17
20
  button.textContent = label;
18
21
  return button;
19
22
  }
@@ -26,6 +29,8 @@ export interface ConfirmationOptions {
26
29
  * pending confirmation when the user cancels the whole run.
27
30
  */
28
31
  signal?: AbortSignal;
32
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
33
+ strings?: UiStrings;
29
34
  }
30
35
 
31
36
  /**
@@ -43,26 +48,31 @@ export function requestConfirmation(
43
48
  request: ConfirmationRequest,
44
49
  options: ConfirmationOptions = {},
45
50
  ): Promise<boolean> {
51
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
46
52
  return new Promise<boolean>((resolve) => {
47
53
  const card = document.createElement("div");
48
54
  card.className = "confirm";
55
+ card.setAttribute("part", "confirm");
49
56
  card.setAttribute("data-tool-name", request.toolName);
50
57
  card.setAttribute("role", "group");
51
- card.setAttribute("aria-label", "Confirm action");
58
+ card.setAttribute("aria-label", strings.confirmAction);
52
59
 
53
60
  const body = document.createElement("div");
54
61
  body.className = "confirm-body";
55
- body.textContent = request.message ?? `Run “${request.toolName}”?`;
62
+ body.setAttribute("part", "confirm-body");
63
+ body.textContent = request.message ?? strings.confirmRun.replace("{tool}", request.toolName);
56
64
 
57
65
  const args = document.createElement("pre");
58
66
  args.className = "confirm-args";
67
+ args.setAttribute("part", "confirm-args");
59
68
  args.textContent = JSON.stringify(request.args, null, 2);
60
69
 
61
70
  const actions = document.createElement("div");
62
71
  actions.className = "confirm-actions";
72
+ actions.setAttribute("part", "confirm-actions");
63
73
 
64
- const cancel = actionButton("cancel", "Cancel");
65
- const confirm = actionButton("confirm", "Confirm");
74
+ const cancel = actionButton("cancel", strings.cancel);
75
+ const confirm = actionButton("confirm", strings.confirm);
66
76
 
67
77
  let settled = false;
68
78
  const close = (accepted: boolean): void => {
@@ -1,28 +1,35 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
1
3
  /**
2
4
  * A compact relative timestamp for a thread row — e.g. `"just now"`, `"5m ago"`,
3
5
  * `"3h ago"`, `"2d ago"`, `"4w ago"`.
4
6
  *
5
7
  * `now` is injectable so callers (and tests) can pin the reference point; it
6
8
  * defaults to the current time. A timestamp in the future (clock skew) reads as
7
- * `"just now"`. Kept locale-independent on purpose so the rendered label is
8
- * stable across environments.
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
12
  */
10
- export function relativeTime(timestamp: number, now: number = Date.now()): string {
13
+ export function relativeTime(
14
+ timestamp: number,
15
+ now: number = Date.now(),
16
+ strings: UiStrings = DEFAULT_UI_STRINGS,
17
+ ): string {
11
18
  const seconds = Math.round((now - timestamp) / 1000);
12
19
  if (seconds < 60) {
13
- return "just now";
20
+ return strings.justNow;
14
21
  }
15
22
  const minutes = Math.round(seconds / 60);
16
23
  if (minutes < 60) {
17
- return `${minutes}m ago`;
24
+ return strings.minutesAgo.replace("{n}", String(minutes));
18
25
  }
19
26
  const hours = Math.round(minutes / 60);
20
27
  if (hours < 24) {
21
- return `${hours}h ago`;
28
+ return strings.hoursAgo.replace("{n}", String(hours));
22
29
  }
23
30
  const days = Math.round(hours / 24);
24
31
  if (days < 7) {
25
- return `${days}d ago`;
32
+ return strings.daysAgo.replace("{n}", String(days));
26
33
  }
27
- return `${Math.round(days / 7)}w ago`;
34
+ return strings.weeksAgo.replace("{n}", String(Math.round(days / 7)));
28
35
  }
package/src/ui/styles.ts CHANGED
@@ -133,6 +133,71 @@ export const STYLES = `
133
133
  --ag-ui-radius: 0;
134
134
  }
135
135
 
136
+ /* Sidebar (CUST-3): a full-height docked panel that slides open/closed and
137
+ collapses to a slim icon rail (not the floating launcher). Docked right by
138
+ default; data-side="left" docks it left. Overlay by default — set
139
+ --ag-ui-position: static (and place this element in your own layout) for a
140
+ host-managed push instead. */
141
+ :host([placement="sidebar"]) {
142
+ --ag-ui-inset: 0 0 0 auto;
143
+ --ag-ui-width: 420px;
144
+ --ag-ui-height: 100vh;
145
+ --ag-ui-max-height: 100vh;
146
+ --ag-ui-radius: 0;
147
+ --ag-ui-rail-width: 52px;
148
+ transition: width 0.28s ease;
149
+ }
150
+
151
+ :host([placement="sidebar"][data-side="left"]) {
152
+ --ag-ui-inset: 0 auto 0 0;
153
+ }
154
+
155
+ :host([placement="sidebar"]) .chat {
156
+ transition: transform 0.28s ease;
157
+ }
158
+
159
+ /* Collapsed sidebar: shrink the host to the rail width, hide the panel, and
160
+ reveal the rail. Higher specificity than the generic collapse rules, so it
161
+ wins regardless of source order. */
162
+ :host([placement="sidebar"][collapsed]) {
163
+ width: var(--ag-ui-rail-width);
164
+ height: 100vh;
165
+ max-height: 100vh;
166
+ bottom: 0;
167
+ }
168
+
169
+ :host([placement="sidebar"][collapsed]) .chat {
170
+ display: none;
171
+ }
172
+
173
+ /* The rail is a sibling of the panel (so it survives the panel being hidden);
174
+ shown only for a collapsed sidebar. */
175
+ .rail {
176
+ display: none;
177
+ border: none;
178
+ font: inherit;
179
+ }
180
+
181
+ :host([placement="sidebar"][collapsed]) .rail {
182
+ display: flex;
183
+ position: absolute;
184
+ inset: 0;
185
+ align-items: flex-start;
186
+ justify-content: center;
187
+ padding-top: 16px;
188
+ border: 1px solid var(--ag-ui-border);
189
+ background: var(--ag-ui-header-bg);
190
+ color: var(--ag-ui-header-fg);
191
+ cursor: pointer;
192
+ }
193
+
194
+ @media (prefers-reduced-motion: reduce) {
195
+ :host([placement="sidebar"]),
196
+ :host([placement="sidebar"]) .chat {
197
+ transition: none;
198
+ }
199
+ }
200
+
136
201
  /* Embedded: drop the floating chrome and the high z-index stacking context so
137
202
  the widget lives in the host's own layout (fixes overlay/z-index clashes). */
138
203
  :host([placement="embedded"]) {
@@ -170,12 +235,33 @@ export const STYLES = `
170
235
  }
171
236
 
172
237
  .header-title {
238
+ flex: 1;
239
+ min-width: 0;
173
240
  font-weight: 600;
174
241
  overflow: hidden;
175
242
  text-overflow: ellipsis;
176
243
  white-space: nowrap;
177
244
  }
178
245
 
246
+ /* Header / launcher icon holder (CUST-2): a slot, with a data-icon-url <img>
247
+ fallback, sized via --ag-ui-icon-size. */
248
+ .icon-holder {
249
+ display: inline-flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ flex: none;
253
+ width: var(--ag-ui-icon-size, 22px);
254
+ height: var(--ag-ui-icon-size, 22px);
255
+ line-height: 1;
256
+ }
257
+
258
+ .icon-img {
259
+ width: 100%;
260
+ height: 100%;
261
+ object-fit: contain;
262
+ border-radius: var(--ag-ui-icon-radius, 4px);
263
+ }
264
+
179
265
  .header-controls {
180
266
  display: flex;
181
267
  gap: 2px;
@@ -231,6 +317,18 @@ export const STYLES = `
231
317
  gap: var(--ag-ui-space);
232
318
  }
233
319
 
320
+ /* Empty-state region (CUST-1 slot): centred while it's the only thing in the
321
+ list, hidden as soon as a message, card, or pending indicator renders. */
322
+ .empty {
323
+ margin: auto;
324
+ text-align: center;
325
+ color: var(--ag-ui-muted);
326
+ }
327
+
328
+ .empty[hidden] {
329
+ display: none;
330
+ }
331
+
234
332
  .message {
235
333
  max-width: 80%;
236
334
  padding: var(--ag-ui-msg-pad);
@@ -506,6 +604,116 @@ export const STYLES = `
506
604
  background: var(--ag-ui-muted);
507
605
  }
508
606
 
607
+ /* ── File attachments ───────────────────────────────────────────────────── */
608
+ /* The 📎 picker button sits left of the input; hidden until data-attachments-url. */
609
+ .attach-btn {
610
+ border: 1px solid var(--ag-ui-border);
611
+ border-radius: 8px;
612
+ padding: 0 10px;
613
+ background: var(--ag-ui-input-bg);
614
+ color: inherit;
615
+ font: inherit;
616
+ cursor: pointer;
617
+ }
618
+
619
+ .attach-btn:hover {
620
+ border-color: var(--ag-ui-accent);
621
+ }
622
+
623
+ .attach-input {
624
+ display: none;
625
+ }
626
+
627
+ /* Pending-attachments tray, above the input row; collapses (hidden) when empty. */
628
+ .attachment-slot {
629
+ display: contents;
630
+ }
631
+
632
+ .attachment-tray {
633
+ display: flex;
634
+ flex-wrap: wrap;
635
+ gap: 6px;
636
+ padding: 8px 12px 0;
637
+ }
638
+
639
+ .attachment-chips {
640
+ display: flex;
641
+ flex-wrap: wrap;
642
+ gap: 6px;
643
+ margin-top: 6px;
644
+ }
645
+
646
+ .attachment-chip {
647
+ display: inline-flex;
648
+ align-items: center;
649
+ gap: 6px;
650
+ max-width: 100%;
651
+ padding: 4px 8px;
652
+ border: 1px solid var(--ag-ui-border);
653
+ border-radius: 999px;
654
+ background: var(--ag-ui-assistant-bg);
655
+ font-size: 0.85em;
656
+ position: relative;
657
+ }
658
+
659
+ .attachment-chip--error {
660
+ border-color: var(--ag-ui-danger);
661
+ color: var(--ag-ui-danger);
662
+ }
663
+
664
+ .attachment-chip-name {
665
+ overflow: hidden;
666
+ text-overflow: ellipsis;
667
+ white-space: nowrap;
668
+ max-width: 14ch;
669
+ }
670
+
671
+ .attachment-chip-size {
672
+ color: var(--ag-ui-muted);
673
+ white-space: nowrap;
674
+ }
675
+
676
+ .attachment-chip--error .attachment-chip-size {
677
+ color: var(--ag-ui-danger);
678
+ }
679
+
680
+ /* The progress bar fills as the file uploads. */
681
+ .attachment-chip-bar {
682
+ flex-basis: 100%;
683
+ height: 3px;
684
+ border-radius: 2px;
685
+ background: var(--ag-ui-border);
686
+ overflow: hidden;
687
+ }
688
+
689
+ .attachment-chip-bar-fill {
690
+ height: 100%;
691
+ background: var(--ag-ui-accent);
692
+ transition: width 0.15s ease;
693
+ }
694
+
695
+ .attachment-chip-remove,
696
+ .attachment-chip-retry {
697
+ border: none;
698
+ background: none;
699
+ color: inherit;
700
+ cursor: pointer;
701
+ padding: 0;
702
+ line-height: 1;
703
+ opacity: 0.7;
704
+ }
705
+
706
+ .attachment-chip-remove:hover,
707
+ .attachment-chip-retry:hover {
708
+ opacity: 1;
709
+ }
710
+
711
+ /* A subtle outline while a file is dragged over the shell. */
712
+ .chat--dragover {
713
+ outline: 2px dashed var(--ag-ui-accent);
714
+ outline-offset: -4px;
715
+ }
716
+
509
717
  /* Muted "⏹ Stopped" line after a cancelled run — a note, not an error bubble. */
510
718
  .stopped-note {
511
719
  align-self: flex-start;