@artooi/ag-ui-web-component 0.21.0 → 0.23.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.
@@ -3,6 +3,10 @@ import {
3
3
  ATTACHMENT_EVENT,
4
4
  COMPACTION_ACTIVITY_TYPE,
5
5
  DEFAULT_ATTACHMENT_MAX_BYTES,
6
+ ICON_ATTACH,
7
+ ICON_LAUNCHER,
8
+ ICON_SEND,
9
+ ICON_STOP,
6
10
  LOAD_CAPABILITY_TOOL,
7
11
  MESSAGE_ROLE,
8
12
  READ_PAGE_TOOL,
@@ -11,6 +15,7 @@ import {
11
15
  TOGGLE_EVENT,
12
16
  TOOL_CALL_STATUS,
13
17
  TOOL_DISPLAY,
18
+ UNREAD_EVENT,
14
19
  X_CONFIRM_KEY,
15
20
  X_SUMMARY_KEY,
16
21
  } from "../constants.js";
@@ -105,6 +110,11 @@ export interface ToggleDetail {
105
110
  readonly collapsed: boolean;
106
111
  }
107
112
 
113
+ /** `detail` shape of the {@link UNREAD_EVENT} CustomEvent. */
114
+ export interface UnreadDetail {
115
+ readonly unread: number;
116
+ }
117
+
108
118
  /**
109
119
  * Attributes read once while connecting, to decide what chrome exists at all.
110
120
  *
@@ -267,14 +277,17 @@ export class AgUiChat extends HTMLElement {
267
277
  ];
268
278
 
269
279
  /**
270
- * Per-run context provider. Defaults to the compact page map (when a
271
- * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on)
272
- * plus a one-line manifest of the files attached to the message being sent,
273
- * so the agent knows which `read_attachment` ids are available.
280
+ * Per-run context provider. Defaults to the compact page map, when a
281
+ * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on.
282
+ *
283
+ * It used to append a one-line manifest of the message's attachments too.
284
+ * The server now derives that manifest from the refs riding the messages, so
285
+ * the client's copy only duplicated it, on exactly the turn a file was
286
+ * attached. Attachments still reach the agent; they reach it through the
287
+ * message, which is where they were already.
274
288
  */
275
289
  getContext: () => Context[] = () => [
276
290
  ...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
277
- ...this.#attachmentContext(),
278
291
  ];
279
292
 
280
293
  /**
@@ -404,8 +417,12 @@ export class AgUiChat extends HTMLElement {
404
417
  readonly #attachSlot: HTMLDivElement;
405
418
  /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
406
419
  readonly #themeToggle: HTMLButtonElement;
407
- /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
408
- readonly #rail: HTMLButtonElement;
420
+ /** What the collapsed widget shrinks to: the floating launcher, or the sidebar rail. */
421
+ readonly #launcher: HTMLButtonElement;
422
+ /** The launcher's unread badge; hidden at zero, and when the host opts out. */
423
+ readonly #badge: HTMLSpanElement;
424
+ // Answers that finished while the widget was collapsed. Expanding clears it.
425
+ #unread = 0;
409
426
  /** Empty-state region at the top of the message list; hidden once anything renders. */
410
427
  readonly #emptyWrap: HTMLDivElement;
411
428
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
@@ -417,9 +434,6 @@ export class AgUiChat extends HTMLElement {
417
434
  /** Whether the element is currently in the DOM; gates the connect-time warning. */
418
435
  #connected = false;
419
436
 
420
- /** Refs attached to the message currently being sent (the context manifest). */
421
- #runAttachments: readonly AttachmentRef[] = [];
422
-
423
437
  #client: AgUiClient | null = null;
424
438
  // Seed for the next client. Once one exists it owns the live value (the
425
439
  // agent applies STATE_SNAPSHOT / STATE_DELTA into it), so this is only the
@@ -481,7 +495,8 @@ export class AgUiChat extends HTMLElement {
481
495
  this.#attachSlot = document.createElement("div");
482
496
  this.#voiceSlot = document.createElement("span");
483
497
  this.#themeToggle = document.createElement("button");
484
- this.#rail = document.createElement("button");
498
+ this.#launcher = document.createElement("button");
499
+ this.#badge = document.createElement("span");
485
500
  this.#emptyWrap = document.createElement("div");
486
501
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
487
502
  this.#drawer = new ThreadDrawer({
@@ -546,6 +561,7 @@ export class AgUiChat extends HTMLElement {
546
561
  return;
547
562
  }
548
563
  this.#input.value = "";
564
+ this.#autoGrow();
549
565
  const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
550
566
  const agent = this.agentFactory({
551
567
  endpoint,
@@ -964,7 +980,7 @@ export class AgUiChat extends HTMLElement {
964
980
  if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
965
981
  this.setAttribute("collapsed", "");
966
982
  }
967
- this.#syncRail();
983
+ this.#syncLauncher();
968
984
  this.#initSkills();
969
985
  // Namespace the built-in default store too (a host-injected store is used
970
986
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
@@ -1226,22 +1242,6 @@ export class AgUiChat extends HTMLElement {
1226
1242
  });
1227
1243
  }
1228
1244
 
1229
- /** The one-line manifest of the message's attachments, for the run context. */
1230
- #attachmentContext(): Context[] {
1231
- if (this.#runAttachments.length === 0) {
1232
- return [];
1233
- }
1234
- const lines = this.#runAttachments.map(
1235
- (ref) => `- ${ref.name} (id: ${ref.id}, ${ref.mime || "unknown type"}, ${ref.size} bytes)`,
1236
- );
1237
- return [
1238
- {
1239
- description: "Files the user attached to this message",
1240
- value: `${lines.join("\n")}\nUse the read_attachment tool with an id to read a file's contents.`,
1241
- },
1242
- ];
1243
- }
1244
-
1245
1245
  /**
1246
1246
  * When `data-threads-url` is set, route thread enumeration / load / rename /
1247
1247
  * delete through that server endpoint (wrapping the current store as the
@@ -1370,12 +1370,14 @@ export class AgUiChat extends HTMLElement {
1370
1370
  .replace("{fields}", missing.join(", "));
1371
1371
  this.#skillHint.hidden = false;
1372
1372
  this.#input.value = text;
1373
+ this.#autoGrow();
1373
1374
  this.#input.focus();
1374
1375
  this.#selectFirstPlaceholder(text);
1375
1376
  return;
1376
1377
  }
1377
1378
  this.#skillHint.hidden = true;
1378
1379
  this.#input.value = text;
1380
+ this.#autoGrow();
1379
1381
  if (skill.sendImmediately === false) {
1380
1382
  this.#input.focus();
1381
1383
  return;
@@ -1420,7 +1422,9 @@ export class AgUiChat extends HTMLElement {
1420
1422
  this.removeAttribute("collapsed");
1421
1423
  }
1422
1424
  sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1423
- this.#syncRail();
1425
+ // Expanding is what marks the waiting answers read; collapsing starts a
1426
+ // fresh count. Either way the badge is cleared and the host told.
1427
+ this.#setUnread(0);
1424
1428
  this.dispatchEvent(
1425
1429
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
1426
1430
  detail: { collapsed },
@@ -1430,6 +1434,16 @@ export class AgUiChat extends HTMLElement {
1430
1434
  );
1431
1435
  }
1432
1436
 
1437
+ /**
1438
+ * Answers that finished while the widget was collapsed, and that the user has
1439
+ * therefore not seen. Expanding (or {@link newChat}) clears it. The launcher's
1440
+ * badge renders this; {@link UNREAD_EVENT} announces every change, so a host
1441
+ * chrome can render its own instead.
1442
+ */
1443
+ get unread(): number {
1444
+ return this.#unread;
1445
+ }
1446
+
1433
1447
  /** Flip the collapsed state. Bound to the built-in header toggle. */
1434
1448
  toggleCollapsed(): void {
1435
1449
  this.setCollapsed(!this.collapsed);
@@ -1644,6 +1658,7 @@ export class AgUiChat extends HTMLElement {
1644
1658
  this.#resetState();
1645
1659
  this.#threadId = this.conversationStore.threadId();
1646
1660
  this.#setRunning(false);
1661
+ this.#setUnread(0);
1647
1662
  }
1648
1663
 
1649
1664
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
@@ -1656,7 +1671,6 @@ export class AgUiChat extends HTMLElement {
1656
1671
  this.#toolCards.clear();
1657
1672
  this.#serverSettled.clear();
1658
1673
  this.#initialMessages = [];
1659
- this.#runAttachments = [];
1660
1674
  this.#attachTray?.clear();
1661
1675
  // Keep the empty-state region; everything else clears.
1662
1676
  this.#messages.replaceChildren(this.#emptyWrap);
@@ -2007,18 +2021,35 @@ export class AgUiChat extends HTMLElement {
2007
2021
  inputRow.className = "input-row";
2008
2022
  inputRow.setAttribute("part", "composer");
2009
2023
 
2024
+ // One bordered surface holds the field and the tool row under it, so the
2025
+ // icon buttons stop competing with the field for weight.
2026
+ const composer = document.createElement("div");
2027
+ composer.className = "composer";
2028
+ composer.setAttribute("part", "composer-surface");
2029
+
2030
+ const tools = document.createElement("div");
2031
+ tools.className = "composer-tools";
2032
+ tools.setAttribute("part", "composer-tools");
2033
+
2010
2034
  this.#input.className = "input";
2011
2035
  this.#input.setAttribute("part", "input");
2012
2036
  this.#input.setAttribute("aria-label", this.#strings.message);
2013
- this.#input.rows = 2;
2037
+ this.#input.rows = 1;
2014
2038
  this.#input.placeholder = this.#strings.inputPlaceholder;
2015
2039
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
2016
2040
  this.#input.addEventListener("input", () => this.#onInput());
2017
2041
 
2042
+ // Icon-only, with both glyphs mounted at once and CSS showing the one the
2043
+ // state calls for — swapping a single glyph would leave a host that slotted
2044
+ // its own Send mark holding a stop icon mid-run.
2018
2045
  this.#send.className = "send";
2019
2046
  this.#send.type = "button";
2020
2047
  this.#send.setAttribute("part", "send");
2021
- this.#send.textContent = this.#strings.send;
2048
+ this.#send.append(
2049
+ this.#glyphSlot("icon-send", "send-send", ICON_SEND),
2050
+ this.#glyphSlot("icon-stop", "send-stop", ICON_STOP),
2051
+ );
2052
+ this.#send.title = this.#strings.send;
2022
2053
  this.#send.setAttribute("aria-label", this.#strings.send);
2023
2054
  this.#send.dataset["state"] = "idle";
2024
2055
  this.#send.addEventListener("click", () => {
@@ -2035,13 +2066,13 @@ export class AgUiChat extends HTMLElement {
2035
2066
  this.#skillHint.setAttribute("part", "skill-hint");
2036
2067
  this.#skillHint.hidden = true;
2037
2068
 
2038
- // File-upload affordance: a 📎 button (hidden until `data-attachments-url`
2039
- // is wired) opening a hidden multi-file input. Drag-and-drop covers the
2040
- // whole shell (wired in #enableDragAndDrop).
2069
+ // File-upload affordance: a paperclip button (hidden until
2070
+ // `data-attachments-url` is wired) opening a hidden multi-file input.
2071
+ // Drag-and-drop covers the whole shell (wired in #enableDragAndDrop).
2041
2072
  this.#attachButton.className = "attach-btn";
2042
2073
  this.#attachButton.type = "button";
2043
2074
  this.#attachButton.setAttribute("part", "attach-button");
2044
- this.#attachButton.textContent = "📎";
2075
+ this.#attachButton.append(this.#glyphSlot("icon-attach", "attach-glyph", ICON_ATTACH));
2045
2076
  this.#attachButton.title = this.#strings.attachFiles;
2046
2077
  this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
2047
2078
  this.#attachButton.hidden = true;
@@ -2062,7 +2093,9 @@ export class AgUiChat extends HTMLElement {
2062
2093
  const footer = document.createElement("slot");
2063
2094
  footer.name = "footer";
2064
2095
 
2065
- inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
2096
+ tools.append(this.#attachButton, this.#voiceSlot, this.#send);
2097
+ composer.append(this.#input, tools);
2098
+ inputRow.append(composer, this.#fileInput);
2066
2099
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
2067
2100
  // the missing-placeholder hint, and the pending-attachments tray.
2068
2101
  this.#chat.append(
@@ -2078,15 +2111,24 @@ export class AgUiChat extends HTMLElement {
2078
2111
  this.#checkpoints.element,
2079
2112
  );
2080
2113
 
2081
- // The collapsed-sidebar rail: a slim edge strip (the expand affordance),
2082
- // sibling of the panel so it survives the panel being hidden. CSS shows it
2083
- // only for `placement="sidebar"` + `collapsed`.
2084
- this.#rail.className = "rail";
2085
- this.#rail.type = "button";
2086
- this.#rail.setAttribute("part", "launcher");
2087
- this.#rail.setAttribute("aria-label", this.#strings.expand);
2088
- this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
2089
- this.#rail.addEventListener("click", () => this.setCollapsed(false));
2114
+ // What a collapsed widget shrinks to: a round floating button, or the slim
2115
+ // edge rail under `placement="sidebar"` one element, shaped by CSS.
2116
+ // A sibling of the panel, so it survives the panel being hidden.
2117
+ this.#launcher.className = "launcher";
2118
+ this.#launcher.type = "button";
2119
+ this.#launcher.setAttribute("part", "launcher");
2120
+ this.#launcher.setAttribute("aria-label", this.#strings.expand);
2121
+ this.#badge.className = "launcher-badge";
2122
+ this.#badge.setAttribute("part", "launcher-badge");
2123
+ // The count is announced through the launcher's own label, so the badge is
2124
+ // decoration to a screen reader rather than a second, context-free number.
2125
+ this.#badge.setAttribute("aria-hidden", "true");
2126
+ this.#badge.hidden = true;
2127
+ this.#launcher.append(
2128
+ this.#iconElement("launcher", "launcher-icon", ICON_LAUNCHER, this.#launcherIconUrl()),
2129
+ this.#badge,
2130
+ );
2131
+ this.#launcher.addEventListener("click", () => this.setCollapsed(false));
2090
2132
 
2091
2133
  this.#chat.append(
2092
2134
  createResizeHandle({
@@ -2104,7 +2146,7 @@ export class AgUiChat extends HTMLElement {
2104
2146
  label: this.#strings.resizePanel,
2105
2147
  }),
2106
2148
  );
2107
- this.#root.append(style, this.#chat, this.#rail);
2149
+ this.#root.append(style, this.#chat, this.#launcher);
2108
2150
  }
2109
2151
 
2110
2152
  /**
@@ -2133,16 +2175,44 @@ export class AgUiChat extends HTMLElement {
2133
2175
  }
2134
2176
 
2135
2177
  /**
2136
- * An icon holder wrapping a `<slot>` so a host can project custom markup; with
2137
- * a `data-icon-url` `<img>` as the slot's fallback, or a glyph when given.
2178
+ * A `<slot>` a host can project its own mark into, falling back to one of the
2179
+ * built-in glyphs. The markup is an author-written constant, never user or
2180
+ * server data, so it is assigned directly rather than sanitised.
2181
+ */
2182
+ #glyphSlot(slotName: string, className: string, markup: string): HTMLSlotElement {
2183
+ const slot = document.createElement("slot");
2184
+ slot.name = slotName;
2185
+ slot.className = className;
2186
+ slot.innerHTML = markup;
2187
+ return slot;
2188
+ }
2189
+
2190
+ /**
2191
+ * The launcher's own image URL. `data-launcher-icon-url` lets the collapsed
2192
+ * button carry a different mark from the header's — a product logo reads at
2193
+ * 22px in a header bar but rarely at 26px in a circle — and falls back to the
2194
+ * header icon so a single `data-icon-url` still feeds both.
2195
+ */
2196
+ #launcherIconUrl(): string | null {
2197
+ return this.getAttribute("data-launcher-icon-url") ?? this.getAttribute("data-icon-url");
2198
+ }
2199
+
2200
+ /**
2201
+ * An icon holder wrapping a `<slot>` so a host can project custom markup;
2202
+ * with an `<img>` as the slot's fallback when an icon URL is configured, or
2203
+ * the given glyph markup when it is not.
2138
2204
  */
2139
- #iconElement(slotName: string, part: string, fallbackGlyph: string | null): HTMLSpanElement {
2205
+ #iconElement(
2206
+ slotName: string,
2207
+ part: string,
2208
+ fallbackGlyph: string | null,
2209
+ iconUrl: string | null = this.getAttribute("data-icon-url"),
2210
+ ): HTMLSpanElement {
2140
2211
  const holder = document.createElement("span");
2141
2212
  holder.className = "icon-holder";
2142
2213
  holder.setAttribute("part", part);
2143
2214
  const slot = document.createElement("slot");
2144
2215
  slot.name = slotName;
2145
- const iconUrl = this.getAttribute("data-icon-url");
2146
2216
  if (iconUrl !== null) {
2147
2217
  const img = document.createElement("img");
2148
2218
  img.className = "icon-img";
@@ -2150,15 +2220,71 @@ export class AgUiChat extends HTMLElement {
2150
2220
  img.alt = "";
2151
2221
  slot.append(img);
2152
2222
  } else if (fallbackGlyph !== null) {
2153
- slot.append(document.createTextNode(fallbackGlyph));
2223
+ slot.innerHTML = fallbackGlyph;
2154
2224
  }
2155
2225
  holder.append(slot);
2156
2226
  return holder;
2157
2227
  }
2158
2228
 
2159
- /** Reflect the collapsed state on the rail's `aria-expanded`. */
2160
- #syncRail(): void {
2161
- this.#rail.setAttribute("aria-expanded", String(!this.collapsed));
2229
+ /**
2230
+ * Reflect the collapsed state and the unread count on the launcher.
2231
+ *
2232
+ * The count is also the launcher's accessible name: a badge that only exists
2233
+ * as a coloured dot says nothing to a screen reader, and "Expand" alone would
2234
+ * be a lie once answers are waiting behind it.
2235
+ */
2236
+ #syncLauncher(): void {
2237
+ this.#launcher.setAttribute("aria-expanded", String(!this.collapsed));
2238
+ const unread = this.#unread;
2239
+ // Past 9 the exact number stops being information and starts being a
2240
+ // layout problem — the badge is a circle, not a field.
2241
+ this.#badge.textContent = unread > 9 ? "9+" : String(unread);
2242
+ this.#badge.hidden = unread === 0 || !this.#badgeEnabled();
2243
+ const label = this.#badge.hidden
2244
+ ? this.#strings.expand
2245
+ : this.#strings.expandUnread.replace("{count}", String(unread));
2246
+ this.#launcher.setAttribute("aria-label", label);
2247
+ this.#launcher.title = label;
2248
+ }
2249
+
2250
+ /**
2251
+ * The unread badge, unlike every other affordance here, is on by default:
2252
+ * a collapsed widget is the one state where an answer can arrive with nothing
2253
+ * on screen to say so. `data-unread-badge="false"` turns it off for a host
2254
+ * that drives its own chrome from the `ag-ui-unread` event.
2255
+ */
2256
+ #badgeEnabled(): boolean {
2257
+ return this.getAttribute("data-unread-badge") !== "false";
2258
+ }
2259
+
2260
+ /**
2261
+ * Set the unread count, repaint the badge, and tell the host.
2262
+ *
2263
+ * The count is kept whether or not the badge renders it, so `unread` stays
2264
+ * truthful for a host chrome and switching the badge on mid-session doesn't
2265
+ * start from a number that was never counted.
2266
+ */
2267
+ #setUnread(count: number): void {
2268
+ this.#unread = count;
2269
+ this.#syncLauncher();
2270
+ this.dispatchEvent(
2271
+ new CustomEvent<UnreadDetail>(UNREAD_EVENT, {
2272
+ detail: { unread: count },
2273
+ bubbles: true,
2274
+ composed: true,
2275
+ }),
2276
+ );
2277
+ }
2278
+
2279
+ /**
2280
+ * Count an answer the user cannot have seen: one that finished while the
2281
+ * widget was collapsed. Expanding is what marks them read.
2282
+ */
2283
+ #noteUnread(): void {
2284
+ if (!this.collapsed) {
2285
+ return;
2286
+ }
2287
+ this.#setUnread(this.#unread + 1);
2162
2288
  }
2163
2289
 
2164
2290
  /** Hide the empty-state region once the message list holds anything else. */
@@ -2170,6 +2296,7 @@ export class AgUiChat extends HTMLElement {
2170
2296
  #onInput(): void {
2171
2297
  this.#skillsMenu.onInput(this.#input.value);
2172
2298
  this.#skillHint.hidden = true;
2299
+ this.#autoGrow();
2173
2300
  }
2174
2301
 
2175
2302
  #onKeydown(event: KeyboardEvent): void {
@@ -2202,15 +2329,34 @@ export class AgUiChat extends HTMLElement {
2202
2329
  this.#client?.cancel();
2203
2330
  }
2204
2331
 
2205
- /** Swap the composer button between Send (idle) and Stop (running). */
2332
+ /**
2333
+ * Swap the composer button between Send (idle) and Stop (running).
2334
+ *
2335
+ * The glyph is swapped by CSS from `data-state` — both are mounted — so this
2336
+ * only has to move the accessible name, which is the button's whole label now
2337
+ * that it carries no text.
2338
+ */
2206
2339
  #setRunning(running: boolean): void {
2207
2340
  this.#running = running;
2208
2341
  const label = running ? this.#strings.stop : this.#strings.send;
2209
- this.#send.textContent = label;
2342
+ this.#send.title = label;
2210
2343
  this.#send.setAttribute("aria-label", label);
2211
2344
  this.#send.dataset["state"] = running ? "running" : "idle";
2212
2345
  }
2213
2346
 
2347
+ /**
2348
+ * Size the field to its content: one row when empty, growing with what is
2349
+ * typed until the CSS ceiling takes over and it scrolls.
2350
+ *
2351
+ * Resetting to `auto` first is what makes it shrink again — `scrollHeight`
2352
+ * never reports less than the current height, so measuring without the reset
2353
+ * would ratchet the composer taller and never back down.
2354
+ */
2355
+ #autoGrow(): void {
2356
+ this.#input.style.height = "auto";
2357
+ this.#input.style.height = `${this.#input.scrollHeight}px`;
2358
+ }
2359
+
2214
2360
  async #submit(): Promise<void> {
2215
2361
  // Ignore a submit while a run is in flight — the single choke point for
2216
2362
  // both Enter and the Send button. The button already turns into Stop, but
@@ -2227,6 +2373,7 @@ export class AgUiChat extends HTMLElement {
2227
2373
  return;
2228
2374
  }
2229
2375
  this.#input.value = "";
2376
+ this.#autoGrow();
2230
2377
  // A file still uploading does not ride along — `readyRefs()` returns only
2231
2378
  // settled ones, and `clearReady()` deliberately keeps the rest for a
2232
2379
  // follow-up. Nothing said so, which is the whole defect: attachments are
@@ -2275,8 +2422,6 @@ export class AgUiChat extends HTMLElement {
2275
2422
  if (attachments.length > 0) {
2276
2423
  bubble.appendChild(renderAttachmentChips(attachments));
2277
2424
  }
2278
- // Surfaced to the run via the context manifest until the run settles.
2279
- this.#runAttachments = attachments;
2280
2425
  this.dispatchEvent(
2281
2426
  new CustomEvent<SubmitDetail>(SUBMIT_EVENT, {
2282
2427
  detail: { content, attachments },
@@ -2581,6 +2726,7 @@ export class AgUiChat extends HTMLElement {
2581
2726
  }
2582
2727
  attachCopyButtons(bubble, this.#strings);
2583
2728
  this.#streamingBubble = null;
2729
+ this.#noteUnread();
2584
2730
  },
2585
2731
  onToolCall: (call) => {
2586
2732
  this.#hidePending();
@@ -2639,9 +2785,6 @@ export class AgUiChat extends HTMLElement {
2639
2785
  this.#hidePending();
2640
2786
  this.#setRunning(false);
2641
2787
  this.#streamingBubble = null;
2642
- // The attachment manifest was for this run only; the model has read what
2643
- // it needed (results now live in history).
2644
- this.#runAttachments = [];
2645
2788
  // Belt-and-suspenders: a tool card still pending at settle (e.g. a
2646
2789
  // server tool whose result never streamed because the connection
2647
2790
  // dropped) would hang forever — settle it to the no-result fallback.
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  TOGGLE_EVENT,
13
13
  TOOL_CALL_STATUS,
14
14
  TOOL_DISPLAY,
15
+ UNREAD_EVENT,
15
16
  X_CONFIRM_KEY,
16
17
  X_DESTRUCTIVE_KEY,
17
18
  X_NAVIGATES_KEY,
@@ -24,6 +25,7 @@ export {
24
25
  type StateDetail,
25
26
  type SubmitDetail,
26
27
  type ToggleDetail,
28
+ type UnreadDetail,
27
29
  } from "./core/ag_ui_chat.js";
28
30
  export {
29
31
  AgUiClient,
@@ -1,3 +1,4 @@
1
+ import { ICON_FILE, ICON_FILE_IMAGE, ICON_FILE_PDF, ICON_FILE_TEXT } from "../constants.js";
1
2
  import type { AttachmentRef } from "../core/attachment.js";
2
3
 
3
4
  /**
@@ -24,7 +25,7 @@ function renderChip(ref: AttachmentRef): HTMLDivElement {
24
25
  const icon = document.createElement("span");
25
26
  icon.className = "attachment-chip-icon";
26
27
  icon.setAttribute("part", "attachment-chip-icon");
27
- icon.textContent = iconFor(ref.mime);
28
+ icon.innerHTML = iconFor(ref.mime);
28
29
  icon.setAttribute("aria-hidden", "true");
29
30
 
30
31
  const name = document.createElement("span");
@@ -42,18 +43,29 @@ function renderChip(ref: AttachmentRef): HTMLDivElement {
42
43
  return chip;
43
44
  }
44
45
 
45
- /** A coarse type icon for a chip — image, document, or generic file. */
46
+ /**
47
+ * A coarse type mark for a chip — image, PDF, text document, or generic file —
48
+ * as the inline SVG markup of one of the built-in glyphs.
49
+ *
50
+ * Returns markup rather than a character because chips sit beside the
51
+ * composer's SVG send, attach and mic buttons: emoji render at a different
52
+ * optical weight, vary by platform, and take neither the glyph size nor
53
+ * `currentColor`, so the two never matched.
54
+ *
55
+ * The MIME string only selects among author-written constants and is never
56
+ * interpolated into one, so the result is safe to assign as markup.
57
+ */
46
58
  export function iconFor(mime: string): string {
47
59
  if (mime.startsWith("image/")) {
48
- return "🖼";
60
+ return ICON_FILE_IMAGE;
49
61
  }
50
62
  if (mime === "application/pdf") {
51
- return "📕";
63
+ return ICON_FILE_PDF;
52
64
  }
53
65
  if (mime.startsWith("text/")) {
54
- return "📄";
66
+ return ICON_FILE_TEXT;
55
67
  }
56
- return "📎";
68
+ return ICON_FILE;
57
69
  }
58
70
 
59
71
  /** A compact human-readable byte size (e.g. `1.2 MB`). */
@@ -215,7 +215,8 @@ export class AttachmentTray {
215
215
  const icon = document.createElement("span");
216
216
  icon.className = "attachment-chip-icon";
217
217
  icon.setAttribute("part", "attachment-chip-icon");
218
- icon.textContent = iconFor(item.file.type);
218
+ // Author-written glyph markup, selected by MIME family — see iconFor.
219
+ icon.innerHTML = iconFor(item.file.type);
219
220
  icon.setAttribute("aria-hidden", "true");
220
221
 
221
222
  const name = document.createElement("span");