@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
@@ -1,6 +1,7 @@
1
1
  import { type AbstractAgent, type AgentSubscriber, randomUUID } from "@ag-ui/client";
2
2
  import type { Context, Message, Tool } from "@ag-ui/core";
3
3
  import { MAX_TOOL_ROUNDS } from "../constants.js";
4
+ import type { AttachmentRef } from "./attachment.js";
4
5
 
5
6
  /** A tool call surfaced to the host by {@link AgUiClient}. */
6
7
  export interface AgUiToolCall {
@@ -92,6 +93,26 @@ export interface AgUiClientConfig extends AgUiRunInputs {
92
93
  * conversation in-memory only.
93
94
  */
94
95
  onPersist?: (messages: readonly Message[]) => void;
96
+ /**
97
+ * Error text surfaced to {@link AgUiClientHandlers.onError} when a run's
98
+ * stream closes without a terminal AG-UI event (`RUN_FINISHED`/`RUN_ERROR`) —
99
+ * a dropped connection. Defaults to `"Connection lost"`; the host passes its
100
+ * localized string.
101
+ */
102
+ connectionLostMessage?: string;
103
+ }
104
+
105
+ /**
106
+ * Raised when a run's stream closes cleanly at the transport level but never
107
+ * emits a terminal AG-UI event, so the run neither finished nor errored. Routed
108
+ * to {@link AgUiClientHandlers.onError} (it is not an abort), turning a silent
109
+ * "stuck pending" into a visible "connection lost".
110
+ */
111
+ export class ConnectionLostError extends Error {
112
+ constructor(message: string) {
113
+ super(message);
114
+ this.name = "ConnectionLostError";
115
+ }
95
116
  }
96
117
 
97
118
  /**
@@ -108,6 +129,7 @@ export class AgUiClient {
108
129
  readonly #getContext: () => Context[];
109
130
  readonly #executeTool: ExecuteTool | null;
110
131
  readonly #onPersist: (messages: readonly Message[]) => void;
132
+ readonly #connectionLostMessage: string;
111
133
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
112
134
  // a cancel between frontend-tool rounds doesn't start another round.
113
135
  #cancelled = false;
@@ -119,6 +141,7 @@ export class AgUiClient {
119
141
  this.#getContext = config.getContext ?? (() => []);
120
142
  this.#executeTool = config.executeTool ?? null;
121
143
  this.#onPersist = config.onPersist ?? (() => {});
144
+ this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
122
145
  }
123
146
 
124
147
  /** Whether a run is currently in flight. */
@@ -137,9 +160,21 @@ export class AgUiClient {
137
160
  * When the agent calls frontend tools, this executes them and re-runs the
138
161
  * agent with the results, looping until the agent stops calling frontend
139
162
  * tools (bounded by {@link MAX_TOOL_ROUNDS}).
163
+ *
164
+ * `attachments` ride on the user message as a non-standard field so the
165
+ * default client store round-trips them for history replay; the agent learns
166
+ * the ids from the run context (the server's strict validation ignores the
167
+ * unknown message field), then reads bytes via the `read_attachment` tool.
140
168
  */
141
- async send(content: string): Promise<void> {
142
- this.#agent.addMessage({ id: randomUUID(), role: "user", content });
169
+ async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
170
+ // Cast at the AG-UI boundary: `attachments` is a web-component augmentation
171
+ // the strict `Message` union doesn't declare, but `addMessage` /
172
+ // `structuredClone` preserve it verbatim.
173
+ const message = { id: randomUUID(), role: "user", content } as Message;
174
+ if (attachments.length > 0) {
175
+ (message as { attachments?: readonly AttachmentRef[] }).attachments = attachments;
176
+ }
177
+ this.#agent.addMessage(message);
143
178
  this.#onPersist(this.#agent.messages);
144
179
  await this.#run();
145
180
  }
@@ -207,9 +242,10 @@ export class AgUiClient {
207
242
  return;
208
243
  }
209
244
  const pending: AgUiToolCall[] = [];
245
+ const runState = { terminal: false };
210
246
  await this.#agent.runAgent(
211
247
  { tools: this.#getTools(), context: this.#getContext() },
212
- this.#buildSubscriber(pending),
248
+ this.#buildSubscriber(pending, runState),
213
249
  );
214
250
  this.#onPersist(this.#agent.messages);
215
251
  // Cancelled mid-stream: the user said stop — don't execute the tool
@@ -217,6 +253,12 @@ export class AgUiClient {
217
253
  if (this.#cancelled) {
218
254
  return;
219
255
  }
256
+ // The stream resolved without RUN_FINISHED / RUN_ERROR: the transport
257
+ // dropped mid-run. Surface it as an error so the UI doesn't rest silently
258
+ // with a stuck pending indicator (caught by #run → onError).
259
+ if (!runState.terminal) {
260
+ throw new ConnectionLostError(this.#connectionLostMessage);
261
+ }
220
262
  if (this.#executeTool === null || pending.length === 0) {
221
263
  return;
222
264
  }
@@ -246,7 +288,7 @@ export class AgUiClient {
246
288
  }
247
289
  }
248
290
 
249
- #buildSubscriber(pending: AgUiToolCall[]): AgentSubscriber {
291
+ #buildSubscriber(pending: AgUiToolCall[], runState: { terminal: boolean }): AgentSubscriber {
250
292
  const h = this.#handlers;
251
293
  return {
252
294
  onRunInitialized() {
@@ -271,9 +313,11 @@ export class AgUiClient {
271
313
  h.onToolResult(event.toolCallId, event.content);
272
314
  },
273
315
  onRunErrorEvent({ event }) {
316
+ runState.terminal = true;
274
317
  h.onError(event.message);
275
318
  },
276
319
  onRunFinalized() {
320
+ runState.terminal = true;
277
321
  h.onRunEnd();
278
322
  },
279
323
  };
@@ -0,0 +1,39 @@
1
+ import type { Message } from "@ag-ui/core";
2
+
3
+ /**
4
+ * A durable, lightweight reference to one uploaded file — what an upload
5
+ * returns and what rides on a sent message, never the bytes.
6
+ *
7
+ * Mirrors django-ag-ui's `AttachmentRef`: a file uploads out-of-band to the
8
+ * attachments endpoint, the server hands back this ref, and the agent reads the
9
+ * actual content server-side via the `read_attachment` tool. Keeping the AG-UI
10
+ * message stream free of base64 mirrors how the tool catalog keeps schemas off
11
+ * the wire.
12
+ */
13
+ export interface AttachmentRef {
14
+ /** Opaque, owner-scoped handle the server resolves back to bytes. */
15
+ readonly id: string;
16
+ /** Original filename, for display on the chip. */
17
+ readonly name: string;
18
+ /** Declared content type (a hint — the server is authoritative). */
19
+ readonly mime: string;
20
+ /** Size in bytes. */
21
+ readonly size: number;
22
+ /** Optional direct fetch URL (the owner-checked download endpoint). */
23
+ readonly url?: string;
24
+ }
25
+
26
+ /**
27
+ * The attachment refs a user message carries.
28
+ *
29
+ * Refs are stored on the user message as a non-standard `attachments` field: a
30
+ * web-component augmentation that the default client store round-trips and
31
+ * `@ag-ui/client` preserves through `addMessage` / `structuredClone`, so a
32
+ * restored conversation re-renders its attachment chips. The server's strict
33
+ * `RunAgentInput` validation ignores the unknown field — the model learns the
34
+ * ids from the run context manifest instead.
35
+ */
36
+ export function messageAttachments(message: Message): readonly AttachmentRef[] {
37
+ const refs = (message as { attachments?: unknown }).attachments;
38
+ return Array.isArray(refs) ? (refs as readonly AttachmentRef[]) : [];
39
+ }
@@ -0,0 +1,113 @@
1
+ import type { AttachmentRef } from "./attachment.js";
2
+
3
+ /**
4
+ * The composer's upload contract: take a `File`, report `0..1` progress, and
5
+ * resolve to a durable {@link AttachmentRef}. The built-in handler is
6
+ * {@link uploadAttachment} (multipart POST); a host swaps in its own — e.g. a
7
+ * `tus-js-client` or direct-to-S3 adapter — via `AgUiChat.uploadHandler`,
8
+ * **without** touching the tray, the chips, or the AG-UI wire (refs are
9
+ * transport-agnostic).
10
+ */
11
+ export type UploadHandler = (
12
+ file: File,
13
+ onProgress: (fraction: number) => void,
14
+ ) => Promise<AttachmentRef>;
15
+
16
+ /** Options for {@link uploadAttachment}. */
17
+ export interface UploadOptions {
18
+ /** The attachments endpoint (`data-attachments-url`). */
19
+ readonly url: string;
20
+ /** Extra HTTP headers (CSRF / auth), read fresh per upload. */
21
+ readonly headers?: Record<string, string>;
22
+ /** Progress callback, `0..1`, fired as the body uploads. */
23
+ readonly onProgress?: (fraction: number) => void;
24
+ /** Abort signal to cancel the in-flight upload. */
25
+ readonly signal?: AbortSignal;
26
+ }
27
+
28
+ /**
29
+ * Upload one file to the attachments endpoint and resolve to its durable
30
+ * {@link AttachmentRef}.
31
+ *
32
+ * Uses `XMLHttpRequest` (not `fetch`) for real upload-progress events: the file
33
+ * is sent as multipart under the `file` field, with the element's `headers` so
34
+ * CSRF / auth ride along exactly like the skills/tools fetches. A non-2xx
35
+ * response or a network/abort error rejects, so the tray can show an error chip.
36
+ */
37
+ export function uploadAttachment(file: File, options: UploadOptions): Promise<AttachmentRef> {
38
+ return new Promise<AttachmentRef>((resolve, reject) => {
39
+ const form = new FormData();
40
+ form.append("file", file);
41
+
42
+ const xhr = new XMLHttpRequest();
43
+ xhr.open("POST", options.url);
44
+ for (const [key, value] of Object.entries(options.headers ?? {})) {
45
+ xhr.setRequestHeader(key, value);
46
+ }
47
+
48
+ const onProgress = options.onProgress;
49
+ if (onProgress !== undefined) {
50
+ xhr.upload.addEventListener("progress", (event) => {
51
+ if (event.lengthComputable) {
52
+ onProgress(event.total === 0 ? 0 : event.loaded / event.total);
53
+ }
54
+ });
55
+ }
56
+
57
+ xhr.addEventListener("load", () => {
58
+ if (xhr.status >= 200 && xhr.status < 300) {
59
+ try {
60
+ resolve(parseRef(JSON.parse(xhr.responseText)));
61
+ } catch {
62
+ reject(new Error("upload returned an unreadable response"));
63
+ }
64
+ } else {
65
+ reject(new Error(errorMessage(xhr)));
66
+ }
67
+ });
68
+ xhr.addEventListener("error", () => reject(new Error("upload failed")));
69
+ xhr.addEventListener("abort", () => reject(new Error("upload cancelled")));
70
+
71
+ const signal = options.signal;
72
+ if (signal !== undefined) {
73
+ signal.addEventListener("abort", () => xhr.abort());
74
+ }
75
+
76
+ xhr.send(form);
77
+ });
78
+ }
79
+
80
+ /** Validate + narrow the server's `201` body into an {@link AttachmentRef}. */
81
+ function parseRef(body: unknown): AttachmentRef {
82
+ if (typeof body !== "object" || body === null) {
83
+ throw new Error("not an object");
84
+ }
85
+ const o = body as Record<string, unknown>;
86
+ const id = o["id"];
87
+ const name = o["name"];
88
+ const mime = o["mime"];
89
+ const size = o["size"];
90
+ const url = o["url"];
91
+ if (
92
+ typeof id !== "string" ||
93
+ typeof name !== "string" ||
94
+ typeof mime !== "string" ||
95
+ typeof size !== "number"
96
+ ) {
97
+ throw new Error("missing fields");
98
+ }
99
+ return typeof url === "string" ? { id, name, mime, size, url } : { id, name, mime, size };
100
+ }
101
+
102
+ /** A human-readable message from a non-2xx upload response. */
103
+ function errorMessage(xhr: XMLHttpRequest): string {
104
+ try {
105
+ const body = JSON.parse(xhr.responseText) as { error?: unknown };
106
+ if (typeof body.error === "string") {
107
+ return body.error;
108
+ }
109
+ } catch {
110
+ // Non-JSON error body — fall through to the status text.
111
+ }
112
+ return `upload failed (${xhr.status})`;
113
+ }
package/src/index.ts CHANGED
@@ -25,9 +25,11 @@ export {
25
25
  type AgUiClientHandlers,
26
26
  type AgUiRunInputs,
27
27
  type AgUiToolCall,
28
+ ConnectionLostError,
28
29
  type ExecuteTool,
29
30
  type ToolExecution,
30
31
  } from "./core/agui_client.js";
32
+ export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
31
33
  export {
32
34
  type ClientConversationStore,
33
35
  type NavigationCheckpoint,
@@ -41,6 +43,11 @@ export {
41
43
  } from "./core/create_http_agent.js";
42
44
  export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
43
45
  export { RemoteConversationStore } from "./core/remote_conversation_store.js";
46
+ export {
47
+ type UploadHandler,
48
+ type UploadOptions,
49
+ uploadAttachment,
50
+ } from "./core/upload_attachment.js";
44
51
  export {
45
52
  type FlashOptions,
46
53
  focusWithFlash,
@@ -72,6 +79,11 @@ export type { Skill } from "./skills/skill.js";
72
79
  export { type ClientTool, ClientToolRegistry } from "./tools/client_tool_registry.js";
73
80
  export { isDestructive } from "./tools/is_destructive.js";
74
81
  export { isNavigates } from "./tools/is_navigates.js";
82
+ export {
83
+ createPageActionTools,
84
+ PAGE_ACTIONS,
85
+ type ResolvePageTarget,
86
+ } from "./tools/page_action_tools.js";
75
87
  export { createPageMapContext, type PageMap } from "./tools/page_map.js";
76
88
  export { parseToolCatalog, type ToolCatalogEntry } from "./tools/parse_tool_catalog.js";
77
89
  export {
@@ -94,4 +106,5 @@ export {
94
106
  type ToolCallStatus,
95
107
  type ToolDisplayMode,
96
108
  } from "./ui/tool_call_card.js";
109
+ export { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "./ui/ui_strings.js";
97
110
  export { VERSION } from "./version.js";
@@ -0,0 +1,130 @@
1
+ import { X_SUMMARY_KEY } from "../constants.js";
2
+ import { scrollIntoCenterView } from "../dom/animations.js";
3
+ import type { ClientTool } from "./client_tool_registry.js";
4
+
5
+ /**
6
+ * Resolve a page-action target string to a host-page element, or `null` when it
7
+ * matches nothing. The default (`document.querySelector`) treats the string as a
8
+ * CSS selector; a host with a page map overrides it to map its own element ids,
9
+ * the same way host packages wrap the DOM-driver primitives with environment-
10
+ * aware lookups.
11
+ */
12
+ export type ResolvePageTarget = (target: string) => HTMLElement | null;
13
+
14
+ /** The built-in page-action tool tokens, opted in via `data-page-actions`. */
15
+ export const PAGE_ACTIONS = {
16
+ SCROLL: "scroll",
17
+ DRAG: "drag",
18
+ } as const;
19
+
20
+ /**
21
+ * The opt-in page-action tools selected by `enabled` (a set of
22
+ * {@link PAGE_ACTIONS} tokens), bound to a {@link ResolvePageTarget}.
23
+ *
24
+ * - `scroll_to` — scroll a target (`top` / `bottom` / a resolver target) into
25
+ * view. Benign; no confirmation.
26
+ * - `drag_and_drop` — drag one element onto another, firing the standard HTML5
27
+ * drag sequence so the host page's own drop handler reacts. Not stamped
28
+ * destructive: a drag rearranges transient state and the durable change
29
+ * happens at the page's explicit commit. A host whose page persists *on drop*
30
+ * gates it with the element's `confirmPredicate`.
31
+ *
32
+ * Both report a clean, model-readable error when a target resolves to nothing,
33
+ * rather than throwing an opaque crash.
34
+ */
35
+ export function createPageActionTools(
36
+ enabled: ReadonlySet<string>,
37
+ resolveTarget: ResolvePageTarget,
38
+ ): ClientTool[] {
39
+ const tools: ClientTool[] = [];
40
+ if (enabled.has(PAGE_ACTIONS.SCROLL)) {
41
+ tools.push(scrollTool(resolveTarget));
42
+ }
43
+ if (enabled.has(PAGE_ACTIONS.DRAG)) {
44
+ tools.push(dragTool(resolveTarget));
45
+ }
46
+ return tools;
47
+ }
48
+
49
+ function scrollTool(resolveTarget: ResolvePageTarget): ClientTool {
50
+ return {
51
+ name: "scroll_to",
52
+ description:
53
+ "Scroll a target into view. `target` is `top`, `bottom`, or a CSS " +
54
+ "selector / page-map element id. Read-only: it changes nothing on the page.",
55
+ parameters: {
56
+ type: "object",
57
+ properties: { target: { type: "string" } },
58
+ required: ["target"],
59
+ [X_SUMMARY_KEY]: "Scroll into view",
60
+ },
61
+ handler: (args) => {
62
+ const target = String(args["target"] ?? "");
63
+ if (target === "top" || target === "bottom") {
64
+ const top = target === "top" ? 0 : document.body.scrollHeight;
65
+ window.scrollTo({ top, behavior: "smooth" });
66
+ return { scrolled: true, target };
67
+ }
68
+ const element = resolveTarget(target);
69
+ if (element === null) {
70
+ throw new Error(`no element matching "${target}"`);
71
+ }
72
+ scrollIntoCenterView(element);
73
+ return { scrolled: true, target };
74
+ },
75
+ };
76
+ }
77
+
78
+ function dragTool(resolveTarget: ResolvePageTarget): ClientTool {
79
+ return {
80
+ name: "drag_and_drop",
81
+ description:
82
+ "Drag the `from` element onto the `to` element (CSS selectors or page-map " +
83
+ "element ids), firing the page's native drag-and-drop. Use for reordering " +
84
+ "sortable lists. The page decides what the drop commits.",
85
+ parameters: {
86
+ type: "object",
87
+ properties: { from: { type: "string" }, to: { type: "string" } },
88
+ required: ["from", "to"],
89
+ [X_SUMMARY_KEY]: "Drag and drop",
90
+ },
91
+ handler: (args) => {
92
+ const fromTarget = String(args["from"] ?? "");
93
+ const toTarget = String(args["to"] ?? "");
94
+ const from = resolveTarget(fromTarget);
95
+ if (from === null) {
96
+ throw new Error(`no element matching "${fromTarget}"`);
97
+ }
98
+ const to = resolveTarget(toTarget);
99
+ if (to === null) {
100
+ throw new Error(`no element matching "${toTarget}"`);
101
+ }
102
+ dispatchDragSequence(from, to);
103
+ return { dragged: true, from: fromTarget, to: toTarget };
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Fire the standard HTML5 drag sequence — `dragstart` on the source, then
110
+ * `dragenter` / `dragover` / `drop` on the target, then `dragend` on the source —
111
+ * sharing one {@link DataTransfer}. Dispatched as typed, bubbling events (the
112
+ * `dataTransfer` is attached explicitly so a drop handler reads it in every
113
+ * environment).
114
+ */
115
+ function dispatchDragSequence(from: HTMLElement, to: HTMLElement): void {
116
+ const dataTransfer = new DataTransfer();
117
+ fire(from, "dragstart", dataTransfer);
118
+ fire(to, "dragenter", dataTransfer);
119
+ fire(to, "dragover", dataTransfer);
120
+ fire(to, "drop", dataTransfer);
121
+ fire(from, "dragend", dataTransfer);
122
+ }
123
+
124
+ function fire(target: HTMLElement, type: string, dataTransfer: DataTransfer): void {
125
+ const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
126
+ dataTransfer: DataTransfer;
127
+ };
128
+ event.dataTransfer = dataTransfer;
129
+ target.dispatchEvent(event);
130
+ }
@@ -0,0 +1,68 @@
1
+ import type { AttachmentRef } from "../core/attachment.js";
2
+
3
+ /**
4
+ * Render the read-only attachment chips shown on a sent user message bubble
5
+ * (and on restored history) — one chip per ref with a type icon, the filename,
6
+ * and a human size. Static by design: no progress, no remove (that lives in the
7
+ * composer tray); a restored bubble re-renders these with no animation.
8
+ */
9
+ export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
10
+ const list = document.createElement("div");
11
+ list.className = "attachment-chips";
12
+ for (const ref of refs) {
13
+ list.appendChild(renderChip(ref));
14
+ }
15
+ return list;
16
+ }
17
+
18
+ function renderChip(ref: AttachmentRef): HTMLDivElement {
19
+ const chip = document.createElement("div");
20
+ chip.className = "attachment-chip attachment-chip--ready";
21
+
22
+ const icon = document.createElement("span");
23
+ icon.className = "attachment-chip-icon";
24
+ icon.textContent = iconFor(ref.mime);
25
+ icon.setAttribute("aria-hidden", "true");
26
+
27
+ const name = document.createElement("span");
28
+ name.className = "attachment-chip-name";
29
+ name.textContent = ref.name;
30
+ name.title = ref.name;
31
+
32
+ const size = document.createElement("span");
33
+ size.className = "attachment-chip-size";
34
+ size.textContent = formatBytes(ref.size);
35
+
36
+ chip.append(icon, name, size);
37
+ return chip;
38
+ }
39
+
40
+ /** A coarse type icon for a chip — image, document, or generic file. */
41
+ export function iconFor(mime: string): string {
42
+ if (mime.startsWith("image/")) {
43
+ return "🖼";
44
+ }
45
+ if (mime === "application/pdf") {
46
+ return "📕";
47
+ }
48
+ if (mime.startsWith("text/")) {
49
+ return "📄";
50
+ }
51
+ return "📎";
52
+ }
53
+
54
+ /** A compact human-readable byte size (e.g. `1.2 MB`). */
55
+ export function formatBytes(bytes: number): string {
56
+ if (bytes < 1024) {
57
+ return `${bytes} B`;
58
+ }
59
+ const units = ["KB", "MB", "GB"];
60
+ let value = bytes / 1024;
61
+ let unit = 0;
62
+ while (value >= 1024 && unit < units.length - 1) {
63
+ value /= 1024;
64
+ unit += 1;
65
+ }
66
+ const rounded = value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
67
+ return `${rounded} ${units[unit]}`;
68
+ }