@artooi/ag-ui-web-component 0.20.1 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +192 -1
  2. package/README.md +404 -28
  3. package/dist/ag-ui-web-component.bundle.js +781 -451
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +29 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +93 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/create_http_agent.d.ts +9 -0
  10. package/dist/core/create_http_agent.d.ts.map +1 -1
  11. package/dist/core/remote_conversation_store.d.ts +8 -1
  12. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  13. package/dist/core/run_index.d.ts +8 -1
  14. package/dist/core/run_index.d.ts.map +1 -1
  15. package/dist/core/transcribe_audio.d.ts +7 -0
  16. package/dist/core/transcribe_audio.d.ts.map +1 -1
  17. package/dist/core/upload_attachment.d.ts +9 -0
  18. package/dist/core/upload_attachment.d.ts.map +1 -1
  19. package/dist/core/utils.d.ts +12 -0
  20. package/dist/core/utils.d.ts.map +1 -0
  21. package/dist/dom/animations.d.ts +51 -11
  22. package/dist/dom/animations.d.ts.map +1 -1
  23. package/dist/dom/dom_driver.d.ts +12 -7
  24. package/dist/dom/dom_driver.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1275 -520
  28. package/dist/index.js.map +3 -3
  29. package/dist/ui/styles.d.ts +1 -1
  30. package/dist/ui/styles.d.ts.map +1 -1
  31. package/dist/ui/ui_strings.d.ts +8 -1
  32. package/dist/ui/ui_strings.d.ts.map +1 -1
  33. package/dist/ui/voice_input.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/constants.ts +35 -0
  36. package/src/core/ag_ui_chat.ts +452 -55
  37. package/src/core/create_http_agent.ts +14 -3
  38. package/src/core/remote_conversation_store.ts +25 -6
  39. package/src/core/run_index.ts +27 -5
  40. package/src/core/transcribe_audio.ts +20 -5
  41. package/src/core/upload_attachment.ts +13 -0
  42. package/src/core/utils.ts +18 -0
  43. package/src/dom/animations.ts +175 -31
  44. package/src/dom/dom_driver.ts +18 -12
  45. package/src/index.ts +4 -0
  46. package/src/ui/styles.ts +751 -421
  47. package/src/ui/ui_strings.ts +9 -1
  48. package/src/ui/voice_input.ts +7 -1
  49. package/src/version.ts +1 -1
@@ -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";
@@ -75,6 +80,7 @@ import { RemoteConversationStore } from "./remote_conversation_store.js";
75
80
  import { RunIndex } from "./run_index.js";
76
81
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
77
82
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
83
+ import { withCredentials } from "./utils.js";
78
84
 
79
85
  /** The role a rendered chat message takes. */
80
86
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -104,6 +110,11 @@ export interface ToggleDetail {
104
110
  readonly collapsed: boolean;
105
111
  }
106
112
 
113
+ /** `detail` shape of the {@link UNREAD_EVENT} CustomEvent. */
114
+ export interface UnreadDetail {
115
+ readonly unread: number;
116
+ }
117
+
107
118
  /**
108
119
  * Attributes read once while connecting, to decide what chrome exists at all.
109
120
  *
@@ -133,6 +144,18 @@ const CONNECT_TIME_ATTRIBUTES = [
133
144
  "data-icon-url",
134
145
  ] as const;
135
146
 
147
+ /**
148
+ * The cookie policies `fetch` accepts. Anything else is a configuration
149
+ * mistake, and one that would otherwise surface as an unexplained 401 from a
150
+ * request the browser silently sent anonymously.
151
+ */
152
+ const CREDENTIALS_MODES: readonly string[] = ["omit", "same-origin", "include"];
153
+
154
+ /** Whether `value` is one of the three modes `fetch` understands. */
155
+ function isCredentialsMode(value: string): value is RequestCredentials {
156
+ return CREDENTIALS_MODES.includes(value);
157
+ }
158
+
136
159
  /** Per-tab persistence key for the collapsed state (survives MPA reloads). */
137
160
  const COLLAPSED_KEY = "ag-ui-chat:collapsed";
138
161
 
@@ -158,9 +181,34 @@ export class AgUiChat extends HTMLElement {
158
181
  /** Agent factory; override to inject a custom or fake agent (tests). */
159
182
  agentFactory: AgentFactory = createHttpAgent;
160
183
 
161
- /** Extra HTTP headers for the AG-UI endpoint (e.g. CSRF). */
184
+ /**
185
+ * Static extra HTTP headers, sent with **every** request this element makes —
186
+ * the agent run, the thread index and its messages, the tool and skill
187
+ * catalogs, the run index, uploads and transcription.
188
+ *
189
+ * Right for values fixed for the element's lifetime. A credential that
190
+ * rotates (a short-lived JWT, a re-issued CSRF token) belongs in
191
+ * {@link getHeaders} instead: this is read at request time, but only a
192
+ * re-assignment updates it, so a token captured here is pinned until the host
193
+ * remembers to assign again.
194
+ */
162
195
  headers: Record<string, string> = {};
163
196
 
197
+ /**
198
+ * Live header source, consulted immediately before every request — the way to
199
+ * supply rotating credentials.
200
+ *
201
+ * Set it to a function and each request calls it afresh: a token refreshed by
202
+ * the host between two requests reaches the second one, with nothing to
203
+ * re-assign and nothing to keep in sync.
204
+ *
205
+ * Composes with {@link headers} rather than replacing it: the two are merged
206
+ * per key with `getHeaders()` winning, so a static `X-Client` and a rotating
207
+ * `Authorization` can be configured independently and neither silently drops
208
+ * the other.
209
+ */
210
+ getHeaders: (() => Record<string, string>) | null = null;
211
+
164
212
  /**
165
213
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
166
214
  * model-controlled image URL is fetched with no user interaction, which
@@ -366,8 +414,12 @@ export class AgUiChat extends HTMLElement {
366
414
  readonly #attachSlot: HTMLDivElement;
367
415
  /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
368
416
  readonly #themeToggle: HTMLButtonElement;
369
- /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
370
- 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;
371
423
  /** Empty-state region at the top of the message list; hidden once anything renders. */
372
424
  readonly #emptyWrap: HTMLDivElement;
373
425
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
@@ -443,7 +495,8 @@ export class AgUiChat extends HTMLElement {
443
495
  this.#attachSlot = document.createElement("div");
444
496
  this.#voiceSlot = document.createElement("span");
445
497
  this.#themeToggle = document.createElement("button");
446
- this.#rail = document.createElement("button");
498
+ this.#launcher = document.createElement("button");
499
+ this.#badge = document.createElement("span");
447
500
  this.#emptyWrap = document.createElement("div");
448
501
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
449
502
  this.#drawer = new ThreadDrawer({
@@ -474,7 +527,11 @@ export class AgUiChat extends HTMLElement {
474
527
  return null;
475
528
  }
476
529
  if (this.#runIndex === null) {
477
- this.#runIndex = new RunIndex(url, () => this.headers);
530
+ this.#runIndex = new RunIndex(
531
+ url,
532
+ () => this.#requestHeaders(),
533
+ () => this.#requestCredentials(),
534
+ );
478
535
  }
479
536
  return this.#runIndex;
480
537
  }
@@ -504,11 +561,13 @@ export class AgUiChat extends HTMLElement {
504
561
  return;
505
562
  }
506
563
  this.#input.value = "";
564
+ this.#autoGrow();
507
565
  const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
508
566
  const agent = this.agentFactory({
509
567
  endpoint,
510
- headers: this.headers,
511
- getHeaders: () => this.headers,
568
+ headers: this.#requestHeaders(),
569
+ getHeaders: () => this.#requestHeaders(),
570
+ ...this.#credentialsOption(),
512
571
  threadId: this.#threadId,
513
572
  // The seed the endpoints assume: nothing. The snapshot is the history.
514
573
  initialMessages: [],
@@ -533,10 +592,24 @@ export class AgUiChat extends HTMLElement {
533
592
 
534
593
  /** Attributes the element reacts to after it has been connected. */
535
594
  static get observedAttributes(): string[] {
536
- return ["title-text", "placement", ...CONNECT_TIME_ATTRIBUTES];
595
+ return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
537
596
  }
538
597
 
539
598
  attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
599
+ if (name === "credentials") {
600
+ // Reported the moment the attribute is written — before connect, and
601
+ // whether it came from markup or the property setter. An unrecognised
602
+ // mode is otherwise inert, and the request it was meant to authorise
603
+ // goes out anonymously with nothing to show for it.
604
+ if (value !== null && !isCredentialsMode(value)) {
605
+ console.error(
606
+ `<ag-ui-chat>: credentials="${value}" is not a fetch credentials mode ` +
607
+ `(${CREDENTIALS_MODES.join(" / ")}) — it is being ignored, so requests use ` +
608
+ "the browser default and cross-origin cookies will not be sent.",
609
+ );
610
+ }
611
+ return;
612
+ }
540
613
  if (name === "placement") {
541
614
  // A placement owns the axes it fixes, so hand those back before anything
542
615
  // else: a size dragged under the previous placement would otherwise sit
@@ -777,6 +850,82 @@ export class AgUiChat extends HTMLElement {
777
850
  this.setAttribute("endpoint", value);
778
851
  }
779
852
 
853
+ /**
854
+ * Cookie policy for **every** request this element makes, as `fetch`'s own
855
+ * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
856
+ * the `credentials` attribute, so markup embeds can set it without script.
857
+ *
858
+ * `null` (the default) leaves the browser's default of `same-origin` in
859
+ * place. That default sends **no cookies at all** when the endpoints live on
860
+ * a different origin from the page — app.example.com calling
861
+ * api.example.com is cross-origin — and the request goes out anonymously
862
+ * rather than failing, so the symptom is a 401 from a server that looks
863
+ * correctly configured. A cookie-authenticated cross-origin deployment wants
864
+ * `"include"`, plus `Access-Control-Allow-Credentials: true` and a concrete
865
+ * (non-wildcard) `Access-Control-Allow-Origin` on the server.
866
+ *
867
+ * Read per request, so a late assignment applies to everything after it.
868
+ * `"omit"` cannot be honoured by the built-in **upload** transport, which is
869
+ * an `XMLHttpRequest` and only has a two-state cookie switch; every other
870
+ * endpoint honours all three modes.
871
+ */
872
+ get credentials(): RequestCredentials | null {
873
+ const attr = this.getAttribute("credentials");
874
+ return attr !== null && isCredentialsMode(attr) ? attr : null;
875
+ }
876
+
877
+ set credentials(value: RequestCredentials | null) {
878
+ if (value === null) {
879
+ this.removeAttribute("credentials");
880
+ return;
881
+ }
882
+ // Thrown, not warned: an unrecognised mode is inert at request time, and
883
+ // the whole failure this option exists to fix is a request that goes out
884
+ // wrong without saying so. Fail where the mistake was made instead.
885
+ if (!isCredentialsMode(value)) {
886
+ throw new TypeError(
887
+ `<ag-ui-chat>: credentials must be one of ${CREDENTIALS_MODES.map((mode) => `"${mode}"`).join(", ")} ` +
888
+ `(got ${JSON.stringify(value)}).`,
889
+ );
890
+ }
891
+ this.setAttribute("credentials", value);
892
+ }
893
+
894
+ /**
895
+ * The headers for the request about to go out: the static {@link headers}
896
+ * with {@link getHeaders}'s live values overlaid, per key.
897
+ *
898
+ * Every request site goes through here, so "how this element authenticates"
899
+ * is one answer rather than one per endpoint.
900
+ */
901
+ #requestHeaders(): Record<string, string> {
902
+ return { ...this.headers, ...this.getHeaders?.() };
903
+ }
904
+
905
+ /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
906
+ #requestCredentials(): RequestCredentials | undefined {
907
+ return this.credentials ?? undefined;
908
+ }
909
+
910
+ /**
911
+ * The `credentials` entry for an {@link AgentFactory} call, or nothing at all.
912
+ *
913
+ * Spread rather than assigned: `exactOptionalPropertyTypes` rejects an
914
+ * explicit `credentials: undefined`, and a factory should see the field
915
+ * absent — not present-and-empty — when no policy is configured. The agent
916
+ * reads it when it is built (first send, thread switch, continuation), by
917
+ * which time any host configuration has landed.
918
+ */
919
+ #credentialsOption(): { credentials?: RequestCredentials } {
920
+ const credentials = this.#requestCredentials();
921
+ return credentials === undefined ? {} : { credentials };
922
+ }
923
+
924
+ /** The `fetch` init for the element's own plain GETs (catalogs). */
925
+ #fetchInit(): RequestInit | undefined {
926
+ return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
927
+ }
928
+
780
929
  /**
781
930
  * How much detail tool-call cards show, from the `data-tool-display`
782
931
  * attribute (`minimal` / `inline` / `compact` / `full`). Defaults to `full`.
@@ -831,9 +980,8 @@ export class AgUiChat extends HTMLElement {
831
980
  if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
832
981
  this.setAttribute("collapsed", "");
833
982
  }
834
- this.#syncRail();
983
+ this.#syncLauncher();
835
984
  this.#initSkills();
836
- void this.#fetchToolCatalog();
837
985
  // Namespace the built-in default store too (a host-injected store is used
838
986
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
839
987
  if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
@@ -843,12 +991,72 @@ export class AgUiChat extends HTMLElement {
843
991
  this.#wireAttachments();
844
992
  this.#wireVoice();
845
993
  this.#threadId = this.conversationStore.threadId();
994
+ // The catalog requests go out a microtask later, so a host configuring
995
+ // through a framework ref still has a chance to be heard — see #startup.
996
+ queueMicrotask(() => this.#startup());
846
997
  void this.#rehydrate();
847
998
  // Last: everything above reads (and some of it sets) attributes, and none
848
999
  // of that should trip the connect-time warning.
849
1000
  this.#connected = true;
850
1001
  }
851
1002
 
1003
+ /**
1004
+ * The catalog requests the element issues on startup: the tool labels
1005
+ * (`data-tools-url`) and the backend skills (`data-skills-url`).
1006
+ *
1007
+ * Deliberately one microtask behind `connectedCallback`. A host that
1008
+ * configures the element through a framework ref necessarily does so *after*
1009
+ * inserting the node — React attaches refs and runs layout effects in the
1010
+ * same commit as the insertion, but strictly afterwards — so a request issued
1011
+ * from `connectedCallback` itself goes out before `headers`,
1012
+ * {@link getHeaders} or {@link credentials} exist, and comes back 401 in a
1013
+ * way that reads as a server fault rather than a mis-timed assignment. A
1014
+ * microtask lands after that commit and still before paint.
1015
+ *
1016
+ * Two things it is **not**. It is not a fix for configuration that arrives
1017
+ * later than the commit (a passive `useEffect`, an awaited token fetch):
1018
+ * configure before insertion (`createElement` → configure → `append`) or call
1019
+ * {@link reload} once configured, because a longer timer would hide that race
1020
+ * rather than close it. And it deliberately excludes the *history* replay,
1021
+ * which stays in `connectedCallback`: the replay renders into the transcript,
1022
+ * so deferring it lets a `sendMessage()` issued in the same task land first
1023
+ * and the replay then duplicate it. The thread history is therefore the one
1024
+ * request that can still go out before a ref is attached — {@link reload}
1025
+ * covers it.
1026
+ */
1027
+ #startup(): void {
1028
+ // An element can be inserted and removed inside one task (a discarded
1029
+ // render, a double-mount); nothing should go out for a node that has
1030
+ // already left the document.
1031
+ if (!this.#connected) {
1032
+ return;
1033
+ }
1034
+ void this.#fetchToolCatalog();
1035
+ void this.#fetchSkills();
1036
+ }
1037
+
1038
+ /**
1039
+ * Re-run everything the element loads on startup — the tool-label catalog,
1040
+ * the backend skill catalog and the thread's history — with the transport
1041
+ * configuration as it stands now.
1042
+ *
1043
+ * This is the answer for a host that can only configure the element after the
1044
+ * fact (a token fetched in a passive effect, an async auth handshake): the
1045
+ * startup requests already went out with whatever was set then, and this says
1046
+ * "try again, properly authenticated" without removing and re-inserting the
1047
+ * node.
1048
+ *
1049
+ * A reload, not a merge — the in-flight run is cancelled and the transcript
1050
+ * is rebuilt from the persisted history, so anything streamed since is
1051
+ * dropped. Call it once, when configuration lands; not between turns.
1052
+ */
1053
+ async reload(): Promise<void> {
1054
+ this.#cancelRun();
1055
+ this.#resetState();
1056
+ this.#setRunning(false);
1057
+ await Promise.all([this.#fetchToolCatalog(), this.#fetchSkills(), this.#rehydrate()]);
1058
+ }
1059
+
852
1060
  /**
853
1061
  * Tear down live resources when the element leaves the DOM (a removed node, a
854
1062
  * client-side route swap): cancel the in-flight run so its SSE stream closes,
@@ -940,7 +1148,13 @@ export class AgUiChat extends HTMLElement {
940
1148
  // Forward the tray's abort signal so removing a chip (or tearing the
941
1149
  // element down) cancels the XHR.
942
1150
  return (file, onProgress, signal) =>
943
- uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
1151
+ uploadAttachment(file, {
1152
+ url,
1153
+ headers: this.#requestHeaders(),
1154
+ ...this.#credentialsOption(),
1155
+ onProgress,
1156
+ signal,
1157
+ });
944
1158
  }
945
1159
 
946
1160
  /**
@@ -969,7 +1183,12 @@ export class AgUiChat extends HTMLElement {
969
1183
  if (url === null) {
970
1184
  return null;
971
1185
  }
972
- return (audio) => transcribeAudio(audio, { url, headers: this.headers });
1186
+ return (audio) =>
1187
+ transcribeAudio(audio, {
1188
+ url,
1189
+ headers: this.#requestHeaders(),
1190
+ ...this.#credentialsOption(),
1191
+ });
973
1192
  }
974
1193
 
975
1194
  /** Drop a voice transcript into the composer (appended to any typed text). */
@@ -1050,8 +1269,9 @@ export class AgUiChat extends HTMLElement {
1050
1269
  if (url !== null) {
1051
1270
  this.conversationStore = new RemoteConversationStore(
1052
1271
  url,
1053
- () => this.headers,
1272
+ () => this.#requestHeaders(),
1054
1273
  this.conversationStore,
1274
+ () => this.#requestCredentials(),
1055
1275
  );
1056
1276
  }
1057
1277
  }
@@ -1063,7 +1283,7 @@ export class AgUiChat extends HTMLElement {
1063
1283
  return;
1064
1284
  }
1065
1285
  try {
1066
- const response = await fetch(url, { headers: this.headers });
1286
+ const response = await fetch(url, this.#fetchInit());
1067
1287
  this.#toolCatalog = parseToolCatalog(await response.json());
1068
1288
  } catch {
1069
1289
  // Network/parse failure: cards fall back to toolSummaries / raw names.
@@ -1079,13 +1299,16 @@ export class AgUiChat extends HTMLElement {
1079
1299
  this.#recomputeSkills();
1080
1300
  }
1081
1301
 
1082
- /** Wire the skill surfaces: opt-in flags, embedded catalog, optional fetch. */
1302
+ /**
1303
+ * Wire the skill surfaces: opt-in flags and the embedded catalog. The backend
1304
+ * catalog is fetched from `#startup`, a microtask later, so it carries the
1305
+ * host's transport configuration.
1306
+ */
1083
1307
  #initSkills(): void {
1084
1308
  this.#skillsMenu.enableChips(this.#flag("data-prompt-chips"));
1085
1309
  this.#skillsMenu.enableSlash(this.#flag("data-slash-commands"));
1086
1310
  this.#embedSkills = this.#readEmbeddedSkills();
1087
1311
  this.#recomputeSkills();
1088
- void this.#fetchSkills();
1089
1312
  }
1090
1313
 
1091
1314
  /** Parse the inline `data-skills` JSON catalog (empty when absent/malformed). */
@@ -1108,7 +1331,7 @@ export class AgUiChat extends HTMLElement {
1108
1331
  return;
1109
1332
  }
1110
1333
  try {
1111
- const response = await fetch(url, { headers: this.headers });
1334
+ const response = await fetch(url, this.#fetchInit());
1112
1335
  this.#backendSkills = parseSkills(await response.json());
1113
1336
  this.#recomputeSkills();
1114
1337
  } catch {
@@ -1163,12 +1386,14 @@ export class AgUiChat extends HTMLElement {
1163
1386
  .replace("{fields}", missing.join(", "));
1164
1387
  this.#skillHint.hidden = false;
1165
1388
  this.#input.value = text;
1389
+ this.#autoGrow();
1166
1390
  this.#input.focus();
1167
1391
  this.#selectFirstPlaceholder(text);
1168
1392
  return;
1169
1393
  }
1170
1394
  this.#skillHint.hidden = true;
1171
1395
  this.#input.value = text;
1396
+ this.#autoGrow();
1172
1397
  if (skill.sendImmediately === false) {
1173
1398
  this.#input.focus();
1174
1399
  return;
@@ -1213,7 +1438,9 @@ export class AgUiChat extends HTMLElement {
1213
1438
  this.removeAttribute("collapsed");
1214
1439
  }
1215
1440
  sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1216
- 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);
1217
1444
  this.dispatchEvent(
1218
1445
  new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
1219
1446
  detail: { collapsed },
@@ -1223,6 +1450,16 @@ export class AgUiChat extends HTMLElement {
1223
1450
  );
1224
1451
  }
1225
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
+
1226
1463
  /** Flip the collapsed state. Bound to the built-in header toggle. */
1227
1464
  toggleCollapsed(): void {
1228
1465
  this.setCollapsed(!this.collapsed);
@@ -1397,6 +1634,34 @@ export class AgUiChat extends HTMLElement {
1397
1634
  this.#themeToggle.textContent = dark ? "☀️" : "🌙";
1398
1635
  }
1399
1636
 
1637
+ /**
1638
+ * Open the thread-history drawer: the imperative route to the control that
1639
+ * renders as `::part(history-button)`.
1640
+ *
1641
+ * A host that hides `::part(header)` to render its own title bar hides the
1642
+ * history, new-chat and collapse buttons with it — and thread switching then
1643
+ * has no route at all, because those controls live inside the header. Each of
1644
+ * them has a method, so a host chrome can rebuild the set: this one,
1645
+ * {@link openCheckpoints}, {@link newChat}, {@link toggleCollapsed} and
1646
+ * {@link toggleTheme}.
1647
+ */
1648
+ openThreads(): void {
1649
+ void this.#refreshDrawer();
1650
+ this.#drawer.open();
1651
+ }
1652
+
1653
+ /**
1654
+ * Open the checkpoints panel (the `::part(checkpoints-button)` route).
1655
+ *
1656
+ * It lists the runs the `data-runs-url` server reports as continuable;
1657
+ * without that attribute the built-in button is never rendered and this opens
1658
+ * an empty panel.
1659
+ */
1660
+ openCheckpoints(): void {
1661
+ void this.#refreshCheckpoints();
1662
+ this.#checkpoints.open();
1663
+ }
1664
+
1400
1665
  /**
1401
1666
  * Start a fresh conversation: forget the persisted history, drop the
1402
1667
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -1409,6 +1674,7 @@ export class AgUiChat extends HTMLElement {
1409
1674
  this.#resetState();
1410
1675
  this.#threadId = this.conversationStore.threadId();
1411
1676
  this.#setRunning(false);
1677
+ this.#setUnread(0);
1412
1678
  }
1413
1679
 
1414
1680
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
@@ -1713,17 +1979,13 @@ export class AgUiChat extends HTMLElement {
1713
1979
  controls.className = "header-controls";
1714
1980
  controls.setAttribute("part", "header-controls");
1715
1981
 
1982
+ // Both controls delegate to the public methods, so a host chrome driving
1983
+ // them imperatively takes exactly the path the built-in button takes.
1716
1984
  const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
1717
- history.addEventListener("click", () => {
1718
- void this.#refreshDrawer();
1719
- this.#drawer.open();
1720
- });
1985
+ history.addEventListener("click", () => this.openThreads());
1721
1986
 
1722
1987
  const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
1723
- checkpoints.addEventListener("click", () => {
1724
- void this.#refreshCheckpoints();
1725
- this.#checkpoints.open();
1726
- });
1988
+ checkpoints.addEventListener("click", () => this.openCheckpoints());
1727
1989
 
1728
1990
  const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
1729
1991
  newChat.addEventListener("click", () => this.newChat());
@@ -1776,18 +2038,35 @@ export class AgUiChat extends HTMLElement {
1776
2038
  inputRow.className = "input-row";
1777
2039
  inputRow.setAttribute("part", "composer");
1778
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
+
1779
2051
  this.#input.className = "input";
1780
2052
  this.#input.setAttribute("part", "input");
1781
2053
  this.#input.setAttribute("aria-label", this.#strings.message);
1782
- this.#input.rows = 2;
2054
+ this.#input.rows = 1;
1783
2055
  this.#input.placeholder = this.#strings.inputPlaceholder;
1784
2056
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
1785
2057
  this.#input.addEventListener("input", () => this.#onInput());
1786
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.
1787
2062
  this.#send.className = "send";
1788
2063
  this.#send.type = "button";
1789
2064
  this.#send.setAttribute("part", "send");
1790
- 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;
1791
2070
  this.#send.setAttribute("aria-label", this.#strings.send);
1792
2071
  this.#send.dataset["state"] = "idle";
1793
2072
  this.#send.addEventListener("click", () => {
@@ -1804,13 +2083,13 @@ export class AgUiChat extends HTMLElement {
1804
2083
  this.#skillHint.setAttribute("part", "skill-hint");
1805
2084
  this.#skillHint.hidden = true;
1806
2085
 
1807
- // File-upload affordance: a 📎 button (hidden until `data-attachments-url`
1808
- // is wired) opening a hidden multi-file input. Drag-and-drop covers the
1809
- // 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).
1810
2089
  this.#attachButton.className = "attach-btn";
1811
2090
  this.#attachButton.type = "button";
1812
2091
  this.#attachButton.setAttribute("part", "attach-button");
1813
- this.#attachButton.textContent = "📎";
2092
+ this.#attachButton.append(this.#glyphSlot("icon-attach", "attach-glyph", ICON_ATTACH));
1814
2093
  this.#attachButton.title = this.#strings.attachFiles;
1815
2094
  this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
1816
2095
  this.#attachButton.hidden = true;
@@ -1831,7 +2110,9 @@ export class AgUiChat extends HTMLElement {
1831
2110
  const footer = document.createElement("slot");
1832
2111
  footer.name = "footer";
1833
2112
 
1834
- 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);
1835
2116
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
1836
2117
  // the missing-placeholder hint, and the pending-attachments tray.
1837
2118
  this.#chat.append(
@@ -1847,15 +2128,24 @@ export class AgUiChat extends HTMLElement {
1847
2128
  this.#checkpoints.element,
1848
2129
  );
1849
2130
 
1850
- // The collapsed-sidebar rail: a slim edge strip (the expand affordance),
1851
- // sibling of the panel so it survives the panel being hidden. CSS shows it
1852
- // only for `placement="sidebar"` + `collapsed`.
1853
- this.#rail.className = "rail";
1854
- this.#rail.type = "button";
1855
- this.#rail.setAttribute("part", "launcher");
1856
- this.#rail.setAttribute("aria-label", this.#strings.expand);
1857
- this.#rail.append(this.#iconElement("launcher", "launcher-icon", "💬"));
1858
- 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));
1859
2149
 
1860
2150
  this.#chat.append(
1861
2151
  createResizeHandle({
@@ -1873,7 +2163,7 @@ export class AgUiChat extends HTMLElement {
1873
2163
  label: this.#strings.resizePanel,
1874
2164
  }),
1875
2165
  );
1876
- this.#root.append(style, this.#chat, this.#rail);
2166
+ this.#root.append(style, this.#chat, this.#launcher);
1877
2167
  }
1878
2168
 
1879
2169
  /**
@@ -1902,16 +2192,44 @@ export class AgUiChat extends HTMLElement {
1902
2192
  }
1903
2193
 
1904
2194
  /**
1905
- * An icon holder wrapping a `<slot>` so a host can project custom markup; with
1906
- * 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.
1907
2221
  */
1908
- #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 {
1909
2228
  const holder = document.createElement("span");
1910
2229
  holder.className = "icon-holder";
1911
2230
  holder.setAttribute("part", part);
1912
2231
  const slot = document.createElement("slot");
1913
2232
  slot.name = slotName;
1914
- const iconUrl = this.getAttribute("data-icon-url");
1915
2233
  if (iconUrl !== null) {
1916
2234
  const img = document.createElement("img");
1917
2235
  img.className = "icon-img";
@@ -1919,15 +2237,71 @@ export class AgUiChat extends HTMLElement {
1919
2237
  img.alt = "";
1920
2238
  slot.append(img);
1921
2239
  } else if (fallbackGlyph !== null) {
1922
- slot.append(document.createTextNode(fallbackGlyph));
2240
+ slot.innerHTML = fallbackGlyph;
1923
2241
  }
1924
2242
  holder.append(slot);
1925
2243
  return holder;
1926
2244
  }
1927
2245
 
1928
- /** Reflect the collapsed state on the rail's `aria-expanded`. */
1929
- #syncRail(): void {
1930
- 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);
1931
2305
  }
1932
2306
 
1933
2307
  /** Hide the empty-state region once the message list holds anything else. */
@@ -1939,6 +2313,7 @@ export class AgUiChat extends HTMLElement {
1939
2313
  #onInput(): void {
1940
2314
  this.#skillsMenu.onInput(this.#input.value);
1941
2315
  this.#skillHint.hidden = true;
2316
+ this.#autoGrow();
1942
2317
  }
1943
2318
 
1944
2319
  #onKeydown(event: KeyboardEvent): void {
@@ -1971,15 +2346,34 @@ export class AgUiChat extends HTMLElement {
1971
2346
  this.#client?.cancel();
1972
2347
  }
1973
2348
 
1974
- /** 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
+ */
1975
2356
  #setRunning(running: boolean): void {
1976
2357
  this.#running = running;
1977
2358
  const label = running ? this.#strings.stop : this.#strings.send;
1978
- this.#send.textContent = label;
2359
+ this.#send.title = label;
1979
2360
  this.#send.setAttribute("aria-label", label);
1980
2361
  this.#send.dataset["state"] = running ? "running" : "idle";
1981
2362
  }
1982
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
+
1983
2377
  async #submit(): Promise<void> {
1984
2378
  // Ignore a submit while a run is in flight — the single choke point for
1985
2379
  // both Enter and the Send button. The button already turns into Stop, but
@@ -1996,6 +2390,7 @@ export class AgUiChat extends HTMLElement {
1996
2390
  return;
1997
2391
  }
1998
2392
  this.#input.value = "";
2393
+ this.#autoGrow();
1999
2394
  // A file still uploading does not ride along — `readyRefs()` returns only
2000
2395
  // settled ones, and `clearReady()` deliberately keeps the rest for a
2001
2396
  // follow-up. Nothing said so, which is the whole defect: attachments are
@@ -2098,11 +2493,12 @@ export class AgUiChat extends HTMLElement {
2098
2493
  if (this.#client === null) {
2099
2494
  const agent = this.agentFactory({
2100
2495
  endpoint: this.endpoint,
2101
- headers: this.headers,
2496
+ headers: this.#requestHeaders(),
2102
2497
  // Live getter: the client is built once and cached, but a rotated
2103
2498
  // token must still reach every request — the factory's fetch wrapper
2104
2499
  // re-reads this on each call.
2105
- getHeaders: () => this.headers,
2500
+ getHeaders: () => this.#requestHeaders(),
2501
+ ...this.#credentialsOption(),
2106
2502
  threadId: this.#threadId,
2107
2503
  initialMessages: this.#initialMessages,
2108
2504
  initialState: this.#sharedState,
@@ -2349,6 +2745,7 @@ export class AgUiChat extends HTMLElement {
2349
2745
  }
2350
2746
  attachCopyButtons(bubble, this.#strings);
2351
2747
  this.#streamingBubble = null;
2748
+ this.#noteUnread();
2352
2749
  },
2353
2750
  onToolCall: (call) => {
2354
2751
  this.#hidePending();