@artooi/ag-ui-web-component 0.21.0 → 0.22.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
  *
@@ -404,8 +414,12 @@ export class AgUiChat extends HTMLElement {
404
414
  readonly #attachSlot: HTMLDivElement;
405
415
  /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
406
416
  readonly #themeToggle: HTMLButtonElement;
407
- /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
408
- readonly #rail: HTMLButtonElement;
417
+ /** What the collapsed widget shrinks to: the floating launcher, or the sidebar rail. */
418
+ readonly #launcher: HTMLButtonElement;
419
+ /** The launcher's unread badge; hidden at zero, and when the host opts out. */
420
+ readonly #badge: HTMLSpanElement;
421
+ // Answers that finished while the widget was collapsed. Expanding clears it.
422
+ #unread = 0;
409
423
  /** Empty-state region at the top of the message list; hidden once anything renders. */
410
424
  readonly #emptyWrap: HTMLDivElement;
411
425
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
@@ -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.
@@ -1370,12 +1386,14 @@ export class AgUiChat extends HTMLElement {
1370
1386
  .replace("{fields}", missing.join(", "));
1371
1387
  this.#skillHint.hidden = false;
1372
1388
  this.#input.value = text;
1389
+ this.#autoGrow();
1373
1390
  this.#input.focus();
1374
1391
  this.#selectFirstPlaceholder(text);
1375
1392
  return;
1376
1393
  }
1377
1394
  this.#skillHint.hidden = true;
1378
1395
  this.#input.value = text;
1396
+ this.#autoGrow();
1379
1397
  if (skill.sendImmediately === false) {
1380
1398
  this.#input.focus();
1381
1399
  return;
@@ -1420,7 +1438,9 @@ export class AgUiChat extends HTMLElement {
1420
1438
  this.removeAttribute("collapsed");
1421
1439
  }
1422
1440
  sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1423
- this.#syncRail();
1441
+ // Expanding is what marks the waiting answers read; collapsing starts a
1442
+ // fresh count. Either way the badge is cleared and the host told.
1443
+ this.#setUnread(0);
1424
1444
  this.dispatchEvent(
1425
1445
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
1426
1446
  detail: { collapsed },
@@ -1430,6 +1450,16 @@ export class AgUiChat extends HTMLElement {
1430
1450
  );
1431
1451
  }
1432
1452
 
1453
+ /**
1454
+ * Answers that finished while the widget was collapsed, and that the user has
1455
+ * therefore not seen. Expanding (or {@link newChat}) clears it. The launcher's
1456
+ * badge renders this; {@link UNREAD_EVENT} announces every change, so a host
1457
+ * chrome can render its own instead.
1458
+ */
1459
+ get unread(): number {
1460
+ return this.#unread;
1461
+ }
1462
+
1433
1463
  /** Flip the collapsed state. Bound to the built-in header toggle. */
1434
1464
  toggleCollapsed(): void {
1435
1465
  this.setCollapsed(!this.collapsed);
@@ -1644,6 +1674,7 @@ export class AgUiChat extends HTMLElement {
1644
1674
  this.#resetState();
1645
1675
  this.#threadId = this.conversationStore.threadId();
1646
1676
  this.#setRunning(false);
1677
+ this.#setUnread(0);
1647
1678
  }
1648
1679
 
1649
1680
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
@@ -2007,18 +2038,35 @@ export class AgUiChat extends HTMLElement {
2007
2038
  inputRow.className = "input-row";
2008
2039
  inputRow.setAttribute("part", "composer");
2009
2040
 
2041
+ // One bordered surface holds the field and the tool row under it, so the
2042
+ // icon buttons stop competing with the field for weight.
2043
+ const composer = document.createElement("div");
2044
+ composer.className = "composer";
2045
+ composer.setAttribute("part", "composer-surface");
2046
+
2047
+ const tools = document.createElement("div");
2048
+ tools.className = "composer-tools";
2049
+ tools.setAttribute("part", "composer-tools");
2050
+
2010
2051
  this.#input.className = "input";
2011
2052
  this.#input.setAttribute("part", "input");
2012
2053
  this.#input.setAttribute("aria-label", this.#strings.message);
2013
- this.#input.rows = 2;
2054
+ this.#input.rows = 1;
2014
2055
  this.#input.placeholder = this.#strings.inputPlaceholder;
2015
2056
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
2016
2057
  this.#input.addEventListener("input", () => this.#onInput());
2017
2058
 
2059
+ // Icon-only, with both glyphs mounted at once and CSS showing the one the
2060
+ // state calls for — swapping a single glyph would leave a host that slotted
2061
+ // its own Send mark holding a stop icon mid-run.
2018
2062
  this.#send.className = "send";
2019
2063
  this.#send.type = "button";
2020
2064
  this.#send.setAttribute("part", "send");
2021
- this.#send.textContent = this.#strings.send;
2065
+ this.#send.append(
2066
+ this.#glyphSlot("icon-send", "send-send", ICON_SEND),
2067
+ this.#glyphSlot("icon-stop", "send-stop", ICON_STOP),
2068
+ );
2069
+ this.#send.title = this.#strings.send;
2022
2070
  this.#send.setAttribute("aria-label", this.#strings.send);
2023
2071
  this.#send.dataset["state"] = "idle";
2024
2072
  this.#send.addEventListener("click", () => {
@@ -2035,13 +2083,13 @@ export class AgUiChat extends HTMLElement {
2035
2083
  this.#skillHint.setAttribute("part", "skill-hint");
2036
2084
  this.#skillHint.hidden = true;
2037
2085
 
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).
2086
+ // File-upload affordance: a paperclip button (hidden until
2087
+ // `data-attachments-url` is wired) opening a hidden multi-file input.
2088
+ // Drag-and-drop covers the whole shell (wired in #enableDragAndDrop).
2041
2089
  this.#attachButton.className = "attach-btn";
2042
2090
  this.#attachButton.type = "button";
2043
2091
  this.#attachButton.setAttribute("part", "attach-button");
2044
- this.#attachButton.textContent = "📎";
2092
+ this.#attachButton.append(this.#glyphSlot("icon-attach", "attach-glyph", ICON_ATTACH));
2045
2093
  this.#attachButton.title = this.#strings.attachFiles;
2046
2094
  this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
2047
2095
  this.#attachButton.hidden = true;
@@ -2062,7 +2110,9 @@ export class AgUiChat extends HTMLElement {
2062
2110
  const footer = document.createElement("slot");
2063
2111
  footer.name = "footer";
2064
2112
 
2065
- inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
2113
+ tools.append(this.#attachButton, this.#voiceSlot, this.#send);
2114
+ composer.append(this.#input, tools);
2115
+ inputRow.append(composer, this.#fileInput);
2066
2116
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
2067
2117
  // the missing-placeholder hint, and the pending-attachments tray.
2068
2118
  this.#chat.append(
@@ -2078,15 +2128,24 @@ export class AgUiChat extends HTMLElement {
2078
2128
  this.#checkpoints.element,
2079
2129
  );
2080
2130
 
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));
2131
+ // What a collapsed widget shrinks to: a round floating button, or the slim
2132
+ // edge rail under `placement="sidebar"` one element, shaped by CSS.
2133
+ // A sibling of the panel, so it survives the panel being hidden.
2134
+ this.#launcher.className = "launcher";
2135
+ this.#launcher.type = "button";
2136
+ this.#launcher.setAttribute("part", "launcher");
2137
+ this.#launcher.setAttribute("aria-label", this.#strings.expand);
2138
+ this.#badge.className = "launcher-badge";
2139
+ this.#badge.setAttribute("part", "launcher-badge");
2140
+ // The count is announced through the launcher's own label, so the badge is
2141
+ // decoration to a screen reader rather than a second, context-free number.
2142
+ this.#badge.setAttribute("aria-hidden", "true");
2143
+ this.#badge.hidden = true;
2144
+ this.#launcher.append(
2145
+ this.#iconElement("launcher", "launcher-icon", ICON_LAUNCHER, this.#launcherIconUrl()),
2146
+ this.#badge,
2147
+ );
2148
+ this.#launcher.addEventListener("click", () => this.setCollapsed(false));
2090
2149
 
2091
2150
  this.#chat.append(
2092
2151
  createResizeHandle({
@@ -2104,7 +2163,7 @@ export class AgUiChat extends HTMLElement {
2104
2163
  label: this.#strings.resizePanel,
2105
2164
  }),
2106
2165
  );
2107
- this.#root.append(style, this.#chat, this.#rail);
2166
+ this.#root.append(style, this.#chat, this.#launcher);
2108
2167
  }
2109
2168
 
2110
2169
  /**
@@ -2133,16 +2192,44 @@ export class AgUiChat extends HTMLElement {
2133
2192
  }
2134
2193
 
2135
2194
  /**
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.
2195
+ * A `<slot>` a host can project its own mark into, falling back to one of the
2196
+ * built-in glyphs. The markup is an author-written constant, never user or
2197
+ * server data, so it is assigned directly rather than sanitised.
2198
+ */
2199
+ #glyphSlot(slotName: string, className: string, markup: string): HTMLSlotElement {
2200
+ const slot = document.createElement("slot");
2201
+ slot.name = slotName;
2202
+ slot.className = className;
2203
+ slot.innerHTML = markup;
2204
+ return slot;
2205
+ }
2206
+
2207
+ /**
2208
+ * The launcher's own image URL. `data-launcher-icon-url` lets the collapsed
2209
+ * button carry a different mark from the header's — a product logo reads at
2210
+ * 22px in a header bar but rarely at 26px in a circle — and falls back to the
2211
+ * header icon so a single `data-icon-url` still feeds both.
2212
+ */
2213
+ #launcherIconUrl(): string | null {
2214
+ return this.getAttribute("data-launcher-icon-url") ?? this.getAttribute("data-icon-url");
2215
+ }
2216
+
2217
+ /**
2218
+ * An icon holder wrapping a `<slot>` so a host can project custom markup;
2219
+ * with an `<img>` as the slot's fallback when an icon URL is configured, or
2220
+ * the given glyph markup when it is not.
2138
2221
  */
2139
- #iconElement(slotName: string, part: string, fallbackGlyph: string | null): HTMLSpanElement {
2222
+ #iconElement(
2223
+ slotName: string,
2224
+ part: string,
2225
+ fallbackGlyph: string | null,
2226
+ iconUrl: string | null = this.getAttribute("data-icon-url"),
2227
+ ): HTMLSpanElement {
2140
2228
  const holder = document.createElement("span");
2141
2229
  holder.className = "icon-holder";
2142
2230
  holder.setAttribute("part", part);
2143
2231
  const slot = document.createElement("slot");
2144
2232
  slot.name = slotName;
2145
- const iconUrl = this.getAttribute("data-icon-url");
2146
2233
  if (iconUrl !== null) {
2147
2234
  const img = document.createElement("img");
2148
2235
  img.className = "icon-img";
@@ -2150,15 +2237,71 @@ export class AgUiChat extends HTMLElement {
2150
2237
  img.alt = "";
2151
2238
  slot.append(img);
2152
2239
  } else if (fallbackGlyph !== null) {
2153
- slot.append(document.createTextNode(fallbackGlyph));
2240
+ slot.innerHTML = fallbackGlyph;
2154
2241
  }
2155
2242
  holder.append(slot);
2156
2243
  return holder;
2157
2244
  }
2158
2245
 
2159
- /** Reflect the collapsed state on the rail's `aria-expanded`. */
2160
- #syncRail(): void {
2161
- this.#rail.setAttribute("aria-expanded", String(!this.collapsed));
2246
+ /**
2247
+ * Reflect the collapsed state and the unread count on the launcher.
2248
+ *
2249
+ * The count is also the launcher's accessible name: a badge that only exists
2250
+ * as a coloured dot says nothing to a screen reader, and "Expand" alone would
2251
+ * be a lie once answers are waiting behind it.
2252
+ */
2253
+ #syncLauncher(): void {
2254
+ this.#launcher.setAttribute("aria-expanded", String(!this.collapsed));
2255
+ const unread = this.#unread;
2256
+ // Past 9 the exact number stops being information and starts being a
2257
+ // layout problem — the badge is a circle, not a field.
2258
+ this.#badge.textContent = unread > 9 ? "9+" : String(unread);
2259
+ this.#badge.hidden = unread === 0 || !this.#badgeEnabled();
2260
+ const label = this.#badge.hidden
2261
+ ? this.#strings.expand
2262
+ : this.#strings.expandUnread.replace("{count}", String(unread));
2263
+ this.#launcher.setAttribute("aria-label", label);
2264
+ this.#launcher.title = label;
2265
+ }
2266
+
2267
+ /**
2268
+ * The unread badge, unlike every other affordance here, is on by default:
2269
+ * a collapsed widget is the one state where an answer can arrive with nothing
2270
+ * on screen to say so. `data-unread-badge="false"` turns it off for a host
2271
+ * that drives its own chrome from the `ag-ui-unread` event.
2272
+ */
2273
+ #badgeEnabled(): boolean {
2274
+ return this.getAttribute("data-unread-badge") !== "false";
2275
+ }
2276
+
2277
+ /**
2278
+ * Set the unread count, repaint the badge, and tell the host.
2279
+ *
2280
+ * The count is kept whether or not the badge renders it, so `unread` stays
2281
+ * truthful for a host chrome and switching the badge on mid-session doesn't
2282
+ * start from a number that was never counted.
2283
+ */
2284
+ #setUnread(count: number): void {
2285
+ this.#unread = count;
2286
+ this.#syncLauncher();
2287
+ this.dispatchEvent(
2288
+ new CustomEvent<UnreadDetail>(UNREAD_EVENT, {
2289
+ detail: { unread: count },
2290
+ bubbles: true,
2291
+ composed: true,
2292
+ }),
2293
+ );
2294
+ }
2295
+
2296
+ /**
2297
+ * Count an answer the user cannot have seen: one that finished while the
2298
+ * widget was collapsed. Expanding is what marks them read.
2299
+ */
2300
+ #noteUnread(): void {
2301
+ if (!this.collapsed) {
2302
+ return;
2303
+ }
2304
+ this.#setUnread(this.#unread + 1);
2162
2305
  }
2163
2306
 
2164
2307
  /** Hide the empty-state region once the message list holds anything else. */
@@ -2170,6 +2313,7 @@ export class AgUiChat extends HTMLElement {
2170
2313
  #onInput(): void {
2171
2314
  this.#skillsMenu.onInput(this.#input.value);
2172
2315
  this.#skillHint.hidden = true;
2316
+ this.#autoGrow();
2173
2317
  }
2174
2318
 
2175
2319
  #onKeydown(event: KeyboardEvent): void {
@@ -2202,15 +2346,34 @@ export class AgUiChat extends HTMLElement {
2202
2346
  this.#client?.cancel();
2203
2347
  }
2204
2348
 
2205
- /** Swap the composer button between Send (idle) and Stop (running). */
2349
+ /**
2350
+ * Swap the composer button between Send (idle) and Stop (running).
2351
+ *
2352
+ * The glyph is swapped by CSS from `data-state` — both are mounted — so this
2353
+ * only has to move the accessible name, which is the button's whole label now
2354
+ * that it carries no text.
2355
+ */
2206
2356
  #setRunning(running: boolean): void {
2207
2357
  this.#running = running;
2208
2358
  const label = running ? this.#strings.stop : this.#strings.send;
2209
- this.#send.textContent = label;
2359
+ this.#send.title = label;
2210
2360
  this.#send.setAttribute("aria-label", label);
2211
2361
  this.#send.dataset["state"] = running ? "running" : "idle";
2212
2362
  }
2213
2363
 
2364
+ /**
2365
+ * Size the field to its content: one row when empty, growing with what is
2366
+ * typed until the CSS ceiling takes over and it scrolls.
2367
+ *
2368
+ * Resetting to `auto` first is what makes it shrink again — `scrollHeight`
2369
+ * never reports less than the current height, so measuring without the reset
2370
+ * would ratchet the composer taller and never back down.
2371
+ */
2372
+ #autoGrow(): void {
2373
+ this.#input.style.height = "auto";
2374
+ this.#input.style.height = `${this.#input.scrollHeight}px`;
2375
+ }
2376
+
2214
2377
  async #submit(): Promise<void> {
2215
2378
  // Ignore a submit while a run is in flight — the single choke point for
2216
2379
  // both Enter and the Send button. The button already turns into Stop, but
@@ -2227,6 +2390,7 @@ export class AgUiChat extends HTMLElement {
2227
2390
  return;
2228
2391
  }
2229
2392
  this.#input.value = "";
2393
+ this.#autoGrow();
2230
2394
  // A file still uploading does not ride along — `readyRefs()` returns only
2231
2395
  // settled ones, and `clearReady()` deliberately keeps the rest for a
2232
2396
  // follow-up. Nothing said so, which is the whole defect: attachments are
@@ -2581,6 +2745,7 @@ export class AgUiChat extends HTMLElement {
2581
2745
  }
2582
2746
  attachCopyButtons(bubble, this.#strings);
2583
2747
  this.#streamingBubble = null;
2748
+ this.#noteUnread();
2584
2749
  },
2585
2750
  onToolCall: (call) => {
2586
2751
  this.#hidePending();
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,