@artooi/ag-ui-web-component 0.20.0 → 0.21.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 (41) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/README.md +286 -20
  3. package/dist/ag-ui-web-component.bundle.js +407 -382
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +82 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/create_http_agent.d.ts +9 -0
  8. package/dist/core/create_http_agent.d.ts.map +1 -1
  9. package/dist/core/remote_conversation_store.d.ts +8 -1
  10. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  11. package/dist/core/run_index.d.ts +8 -1
  12. package/dist/core/run_index.d.ts.map +1 -1
  13. package/dist/core/transcribe_audio.d.ts +7 -0
  14. package/dist/core/transcribe_audio.d.ts.map +1 -1
  15. package/dist/core/upload_attachment.d.ts +9 -0
  16. package/dist/core/upload_attachment.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +12 -0
  18. package/dist/core/utils.d.ts.map +1 -0
  19. package/dist/dom/animations.d.ts +51 -11
  20. package/dist/dom/animations.d.ts.map +1 -1
  21. package/dist/dom/dom_driver.d.ts +12 -7
  22. package/dist/dom/dom_driver.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +766 -430
  26. package/dist/index.js.map +3 -3
  27. package/dist/ui/styles.d.ts +1 -1
  28. package/dist/ui/styles.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/core/ag_ui_chat.ts +292 -31
  31. package/src/core/create_http_agent.ts +14 -3
  32. package/src/core/remote_conversation_store.ts +25 -6
  33. package/src/core/run_index.ts +27 -5
  34. package/src/core/transcribe_audio.ts +20 -5
  35. package/src/core/upload_attachment.ts +13 -0
  36. package/src/core/utils.ts +18 -0
  37. package/src/dom/animations.ts +175 -31
  38. package/src/dom/dom_driver.ts +18 -12
  39. package/src/index.ts +2 -0
  40. package/src/ui/styles.ts +377 -352
  41. package/src/version.ts +1 -1
@@ -75,6 +75,7 @@ import { RemoteConversationStore } from "./remote_conversation_store.js";
75
75
  import { RunIndex } from "./run_index.js";
76
76
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
77
77
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
78
+ import { withCredentials } from "./utils.js";
78
79
 
79
80
  /** The role a rendered chat message takes. */
80
81
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -133,6 +134,18 @@ const CONNECT_TIME_ATTRIBUTES = [
133
134
  "data-icon-url",
134
135
  ] as const;
135
136
 
137
+ /**
138
+ * The cookie policies `fetch` accepts. Anything else is a configuration
139
+ * mistake, and one that would otherwise surface as an unexplained 401 from a
140
+ * request the browser silently sent anonymously.
141
+ */
142
+ const CREDENTIALS_MODES: readonly string[] = ["omit", "same-origin", "include"];
143
+
144
+ /** Whether `value` is one of the three modes `fetch` understands. */
145
+ function isCredentialsMode(value: string): value is RequestCredentials {
146
+ return CREDENTIALS_MODES.includes(value);
147
+ }
148
+
136
149
  /** Per-tab persistence key for the collapsed state (survives MPA reloads). */
137
150
  const COLLAPSED_KEY = "ag-ui-chat:collapsed";
138
151
 
@@ -158,9 +171,34 @@ export class AgUiChat extends HTMLElement {
158
171
  /** Agent factory; override to inject a custom or fake agent (tests). */
159
172
  agentFactory: AgentFactory = createHttpAgent;
160
173
 
161
- /** Extra HTTP headers for the AG-UI endpoint (e.g. CSRF). */
174
+ /**
175
+ * Static extra HTTP headers, sent with **every** request this element makes —
176
+ * the agent run, the thread index and its messages, the tool and skill
177
+ * catalogs, the run index, uploads and transcription.
178
+ *
179
+ * Right for values fixed for the element's lifetime. A credential that
180
+ * rotates (a short-lived JWT, a re-issued CSRF token) belongs in
181
+ * {@link getHeaders} instead: this is read at request time, but only a
182
+ * re-assignment updates it, so a token captured here is pinned until the host
183
+ * remembers to assign again.
184
+ */
162
185
  headers: Record<string, string> = {};
163
186
 
187
+ /**
188
+ * Live header source, consulted immediately before every request — the way to
189
+ * supply rotating credentials.
190
+ *
191
+ * Set it to a function and each request calls it afresh: a token refreshed by
192
+ * the host between two requests reaches the second one, with nothing to
193
+ * re-assign and nothing to keep in sync.
194
+ *
195
+ * Composes with {@link headers} rather than replacing it: the two are merged
196
+ * per key with `getHeaders()` winning, so a static `X-Client` and a rotating
197
+ * `Authorization` can be configured independently and neither silently drops
198
+ * the other.
199
+ */
200
+ getHeaders: (() => Record<string, string>) | null = null;
201
+
164
202
  /**
165
203
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
166
204
  * model-controlled image URL is fetched with no user interaction, which
@@ -474,7 +512,11 @@ export class AgUiChat extends HTMLElement {
474
512
  return null;
475
513
  }
476
514
  if (this.#runIndex === null) {
477
- this.#runIndex = new RunIndex(url, () => this.headers);
515
+ this.#runIndex = new RunIndex(
516
+ url,
517
+ () => this.#requestHeaders(),
518
+ () => this.#requestCredentials(),
519
+ );
478
520
  }
479
521
  return this.#runIndex;
480
522
  }
@@ -507,8 +549,9 @@ export class AgUiChat extends HTMLElement {
507
549
  const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
508
550
  const agent = this.agentFactory({
509
551
  endpoint,
510
- headers: this.headers,
511
- getHeaders: () => this.headers,
552
+ headers: this.#requestHeaders(),
553
+ getHeaders: () => this.#requestHeaders(),
554
+ ...this.#credentialsOption(),
512
555
  threadId: this.#threadId,
513
556
  // The seed the endpoints assume: nothing. The snapshot is the history.
514
557
  initialMessages: [],
@@ -533,13 +576,31 @@ export class AgUiChat extends HTMLElement {
533
576
 
534
577
  /** Attributes the element reacts to after it has been connected. */
535
578
  static get observedAttributes(): string[] {
536
- return ["title-text", "placement", ...CONNECT_TIME_ATTRIBUTES];
579
+ return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
537
580
  }
538
581
 
539
582
  attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
583
+ if (name === "credentials") {
584
+ // Reported the moment the attribute is written — before connect, and
585
+ // whether it came from markup or the property setter. An unrecognised
586
+ // mode is otherwise inert, and the request it was meant to authorise
587
+ // goes out anonymously with nothing to show for it.
588
+ if (value !== null && !isCredentialsMode(value)) {
589
+ console.error(
590
+ `<ag-ui-chat>: credentials="${value}" is not a fetch credentials mode ` +
591
+ `(${CREDENTIALS_MODES.join(" / ")}) — it is being ignored, so requests use ` +
592
+ "the browser default and cross-origin cookies will not be sent.",
593
+ );
594
+ }
595
+ return;
596
+ }
540
597
  if (name === "placement") {
541
- // Placement moves the panel, so the edges its layout holds still change
542
- // with it. Deferred a frame so the new rules have applied.
598
+ // A placement owns the axes it fixes, so hand those back before anything
599
+ // else: a size dragged under the previous placement would otherwise sit
600
+ // inline and outrank the new one.
601
+ this.#releaseOwnedAxes();
602
+ // Placement also moves the panel, so the edges its layout holds still
603
+ // change with it. Deferred a frame so the new rules have applied.
543
604
  requestAnimationFrame(() => this.#syncResizeAnchor());
544
605
  return;
545
606
  }
@@ -773,6 +834,82 @@ export class AgUiChat extends HTMLElement {
773
834
  this.setAttribute("endpoint", value);
774
835
  }
775
836
 
837
+ /**
838
+ * Cookie policy for **every** request this element makes, as `fetch`'s own
839
+ * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
840
+ * the `credentials` attribute, so markup embeds can set it without script.
841
+ *
842
+ * `null` (the default) leaves the browser's default of `same-origin` in
843
+ * place. That default sends **no cookies at all** when the endpoints live on
844
+ * a different origin from the page — app.example.com calling
845
+ * api.example.com is cross-origin — and the request goes out anonymously
846
+ * rather than failing, so the symptom is a 401 from a server that looks
847
+ * correctly configured. A cookie-authenticated cross-origin deployment wants
848
+ * `"include"`, plus `Access-Control-Allow-Credentials: true` and a concrete
849
+ * (non-wildcard) `Access-Control-Allow-Origin` on the server.
850
+ *
851
+ * Read per request, so a late assignment applies to everything after it.
852
+ * `"omit"` cannot be honoured by the built-in **upload** transport, which is
853
+ * an `XMLHttpRequest` and only has a two-state cookie switch; every other
854
+ * endpoint honours all three modes.
855
+ */
856
+ get credentials(): RequestCredentials | null {
857
+ const attr = this.getAttribute("credentials");
858
+ return attr !== null && isCredentialsMode(attr) ? attr : null;
859
+ }
860
+
861
+ set credentials(value: RequestCredentials | null) {
862
+ if (value === null) {
863
+ this.removeAttribute("credentials");
864
+ return;
865
+ }
866
+ // Thrown, not warned: an unrecognised mode is inert at request time, and
867
+ // the whole failure this option exists to fix is a request that goes out
868
+ // wrong without saying so. Fail where the mistake was made instead.
869
+ if (!isCredentialsMode(value)) {
870
+ throw new TypeError(
871
+ `<ag-ui-chat>: credentials must be one of ${CREDENTIALS_MODES.map((mode) => `"${mode}"`).join(", ")} ` +
872
+ `(got ${JSON.stringify(value)}).`,
873
+ );
874
+ }
875
+ this.setAttribute("credentials", value);
876
+ }
877
+
878
+ /**
879
+ * The headers for the request about to go out: the static {@link headers}
880
+ * with {@link getHeaders}'s live values overlaid, per key.
881
+ *
882
+ * Every request site goes through here, so "how this element authenticates"
883
+ * is one answer rather than one per endpoint.
884
+ */
885
+ #requestHeaders(): Record<string, string> {
886
+ return { ...this.headers, ...this.getHeaders?.() };
887
+ }
888
+
889
+ /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
890
+ #requestCredentials(): RequestCredentials | undefined {
891
+ return this.credentials ?? undefined;
892
+ }
893
+
894
+ /**
895
+ * The `credentials` entry for an {@link AgentFactory} call, or nothing at all.
896
+ *
897
+ * Spread rather than assigned: `exactOptionalPropertyTypes` rejects an
898
+ * explicit `credentials: undefined`, and a factory should see the field
899
+ * absent — not present-and-empty — when no policy is configured. The agent
900
+ * reads it when it is built (first send, thread switch, continuation), by
901
+ * which time any host configuration has landed.
902
+ */
903
+ #credentialsOption(): { credentials?: RequestCredentials } {
904
+ const credentials = this.#requestCredentials();
905
+ return credentials === undefined ? {} : { credentials };
906
+ }
907
+
908
+ /** The `fetch` init for the element's own plain GETs (catalogs). */
909
+ #fetchInit(): RequestInit | undefined {
910
+ return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
911
+ }
912
+
776
913
  /**
777
914
  * How much detail tool-call cards show, from the `data-tool-display`
778
915
  * attribute (`minimal` / `inline` / `compact` / `full`). Defaults to `full`.
@@ -829,7 +966,6 @@ export class AgUiChat extends HTMLElement {
829
966
  }
830
967
  this.#syncRail();
831
968
  this.#initSkills();
832
- void this.#fetchToolCatalog();
833
969
  // Namespace the built-in default store too (a host-injected store is used
834
970
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
835
971
  if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
@@ -839,12 +975,72 @@ export class AgUiChat extends HTMLElement {
839
975
  this.#wireAttachments();
840
976
  this.#wireVoice();
841
977
  this.#threadId = this.conversationStore.threadId();
978
+ // The catalog requests go out a microtask later, so a host configuring
979
+ // through a framework ref still has a chance to be heard — see #startup.
980
+ queueMicrotask(() => this.#startup());
842
981
  void this.#rehydrate();
843
982
  // Last: everything above reads (and some of it sets) attributes, and none
844
983
  // of that should trip the connect-time warning.
845
984
  this.#connected = true;
846
985
  }
847
986
 
987
+ /**
988
+ * The catalog requests the element issues on startup: the tool labels
989
+ * (`data-tools-url`) and the backend skills (`data-skills-url`).
990
+ *
991
+ * Deliberately one microtask behind `connectedCallback`. A host that
992
+ * configures the element through a framework ref necessarily does so *after*
993
+ * inserting the node — React attaches refs and runs layout effects in the
994
+ * same commit as the insertion, but strictly afterwards — so a request issued
995
+ * from `connectedCallback` itself goes out before `headers`,
996
+ * {@link getHeaders} or {@link credentials} exist, and comes back 401 in a
997
+ * way that reads as a server fault rather than a mis-timed assignment. A
998
+ * microtask lands after that commit and still before paint.
999
+ *
1000
+ * Two things it is **not**. It is not a fix for configuration that arrives
1001
+ * later than the commit (a passive `useEffect`, an awaited token fetch):
1002
+ * configure before insertion (`createElement` → configure → `append`) or call
1003
+ * {@link reload} once configured, because a longer timer would hide that race
1004
+ * rather than close it. And it deliberately excludes the *history* replay,
1005
+ * which stays in `connectedCallback`: the replay renders into the transcript,
1006
+ * so deferring it lets a `sendMessage()` issued in the same task land first
1007
+ * and the replay then duplicate it. The thread history is therefore the one
1008
+ * request that can still go out before a ref is attached — {@link reload}
1009
+ * covers it.
1010
+ */
1011
+ #startup(): void {
1012
+ // An element can be inserted and removed inside one task (a discarded
1013
+ // render, a double-mount); nothing should go out for a node that has
1014
+ // already left the document.
1015
+ if (!this.#connected) {
1016
+ return;
1017
+ }
1018
+ void this.#fetchToolCatalog();
1019
+ void this.#fetchSkills();
1020
+ }
1021
+
1022
+ /**
1023
+ * Re-run everything the element loads on startup — the tool-label catalog,
1024
+ * the backend skill catalog and the thread's history — with the transport
1025
+ * configuration as it stands now.
1026
+ *
1027
+ * This is the answer for a host that can only configure the element after the
1028
+ * fact (a token fetched in a passive effect, an async auth handshake): the
1029
+ * startup requests already went out with whatever was set then, and this says
1030
+ * "try again, properly authenticated" without removing and re-inserting the
1031
+ * node.
1032
+ *
1033
+ * A reload, not a merge — the in-flight run is cancelled and the transcript
1034
+ * is rebuilt from the persisted history, so anything streamed since is
1035
+ * dropped. Call it once, when configuration lands; not between turns.
1036
+ */
1037
+ async reload(): Promise<void> {
1038
+ this.#cancelRun();
1039
+ this.#resetState();
1040
+ this.#setRunning(false);
1041
+ await Promise.all([this.#fetchToolCatalog(), this.#fetchSkills(), this.#rehydrate()]);
1042
+ }
1043
+
848
1044
  /**
849
1045
  * Tear down live resources when the element leaves the DOM (a removed node, a
850
1046
  * client-side route swap): cancel the in-flight run so its SSE stream closes,
@@ -936,7 +1132,13 @@ export class AgUiChat extends HTMLElement {
936
1132
  // Forward the tray's abort signal so removing a chip (or tearing the
937
1133
  // element down) cancels the XHR.
938
1134
  return (file, onProgress, signal) =>
939
- uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
1135
+ uploadAttachment(file, {
1136
+ url,
1137
+ headers: this.#requestHeaders(),
1138
+ ...this.#credentialsOption(),
1139
+ onProgress,
1140
+ signal,
1141
+ });
940
1142
  }
941
1143
 
942
1144
  /**
@@ -965,7 +1167,12 @@ export class AgUiChat extends HTMLElement {
965
1167
  if (url === null) {
966
1168
  return null;
967
1169
  }
968
- return (audio) => transcribeAudio(audio, { url, headers: this.headers });
1170
+ return (audio) =>
1171
+ transcribeAudio(audio, {
1172
+ url,
1173
+ headers: this.#requestHeaders(),
1174
+ ...this.#credentialsOption(),
1175
+ });
969
1176
  }
970
1177
 
971
1178
  /** Drop a voice transcript into the composer (appended to any typed text). */
@@ -1046,8 +1253,9 @@ export class AgUiChat extends HTMLElement {
1046
1253
  if (url !== null) {
1047
1254
  this.conversationStore = new RemoteConversationStore(
1048
1255
  url,
1049
- () => this.headers,
1256
+ () => this.#requestHeaders(),
1050
1257
  this.conversationStore,
1258
+ () => this.#requestCredentials(),
1051
1259
  );
1052
1260
  }
1053
1261
  }
@@ -1059,7 +1267,7 @@ export class AgUiChat extends HTMLElement {
1059
1267
  return;
1060
1268
  }
1061
1269
  try {
1062
- const response = await fetch(url, { headers: this.headers });
1270
+ const response = await fetch(url, this.#fetchInit());
1063
1271
  this.#toolCatalog = parseToolCatalog(await response.json());
1064
1272
  } catch {
1065
1273
  // Network/parse failure: cards fall back to toolSummaries / raw names.
@@ -1075,13 +1283,16 @@ export class AgUiChat extends HTMLElement {
1075
1283
  this.#recomputeSkills();
1076
1284
  }
1077
1285
 
1078
- /** Wire the skill surfaces: opt-in flags, embedded catalog, optional fetch. */
1286
+ /**
1287
+ * Wire the skill surfaces: opt-in flags and the embedded catalog. The backend
1288
+ * catalog is fetched from `#startup`, a microtask later, so it carries the
1289
+ * host's transport configuration.
1290
+ */
1079
1291
  #initSkills(): void {
1080
1292
  this.#skillsMenu.enableChips(this.#flag("data-prompt-chips"));
1081
1293
  this.#skillsMenu.enableSlash(this.#flag("data-slash-commands"));
1082
1294
  this.#embedSkills = this.#readEmbeddedSkills();
1083
1295
  this.#recomputeSkills();
1084
- void this.#fetchSkills();
1085
1296
  }
1086
1297
 
1087
1298
  /** Parse the inline `data-skills` JSON catalog (empty when absent/malformed). */
@@ -1104,7 +1315,7 @@ export class AgUiChat extends HTMLElement {
1104
1315
  return;
1105
1316
  }
1106
1317
  try {
1107
- const response = await fetch(url, { headers: this.headers });
1318
+ const response = await fetch(url, this.#fetchInit());
1108
1319
  this.#backendSkills = parseSkills(await response.json());
1109
1320
  this.#recomputeSkills();
1110
1321
  } catch {
@@ -1306,22 +1517,47 @@ export class AgUiChat extends HTMLElement {
1306
1517
  }
1307
1518
 
1308
1519
  /**
1309
- * Write a dragged size onto the host as custom properties.
1520
+ * Write a dragged size onto the host, on the axes this placement leaves free.
1521
+ *
1522
+ * ⚠ Writing the custom property rather than inline `width` / `height` does
1523
+ * **not** by itself leave placement in charge — an inline custom property
1524
+ * still outranks a `:host([placement=…])` rule setting the same property, so
1525
+ * a height dragged while floating capped a docked sidebar that had asked for
1526
+ * `100vh`. The cascade cannot arbitrate this; the axis check has to.
1310
1527
  *
1311
- * Properties rather than inline `width` / `height`: the placement rules set
1312
- * those same properties, so an inline dimension would outrank them and a
1313
- * panel dragged while floating would keep that width after switching to
1314
- * fullscreen.
1528
+ * So the rule is explicit: a placement owns the axes it fixes, and a
1529
+ * persisted size is only ever applied to the ones it does not.
1315
1530
  */
1316
1531
  #applySize(size: ResizeSize): void {
1532
+ const axis = this.#resizeAxis();
1533
+ if (axis === "none") {
1534
+ return;
1535
+ }
1317
1536
  if (size.width !== undefined) {
1318
1537
  this.style.setProperty("--ag-ui-width", `${size.width}px`);
1319
1538
  }
1320
- if (size.height !== undefined) {
1539
+ if (size.height !== undefined && axis === "both") {
1321
1540
  this.style.setProperty("--ag-ui-height", `${size.height}px`);
1322
1541
  }
1323
1542
  }
1324
1543
 
1544
+ /**
1545
+ * Drop any dragged size the new placement has taken ownership of.
1546
+ *
1547
+ * Without this a size survives the switch as an inline property and silently
1548
+ * overrides the placement it moved to — the panel keeps a floating height
1549
+ * while docked, and reads as a component that cannot do full height.
1550
+ */
1551
+ #releaseOwnedAxes(): void {
1552
+ const axis = this.#resizeAxis();
1553
+ if (axis !== "both") {
1554
+ this.style.removeProperty("--ag-ui-height");
1555
+ }
1556
+ if (axis === "none") {
1557
+ this.style.removeProperty("--ag-ui-width");
1558
+ }
1559
+ }
1560
+
1325
1561
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
1326
1562
  #persistSize(size: ResizeSize): void {
1327
1563
  const stored = { ...this.#readSize(), ...size };
@@ -1368,6 +1604,34 @@ export class AgUiChat extends HTMLElement {
1368
1604
  this.#themeToggle.textContent = dark ? "☀️" : "🌙";
1369
1605
  }
1370
1606
 
1607
+ /**
1608
+ * Open the thread-history drawer: the imperative route to the control that
1609
+ * renders as `::part(history-button)`.
1610
+ *
1611
+ * A host that hides `::part(header)` to render its own title bar hides the
1612
+ * history, new-chat and collapse buttons with it — and thread switching then
1613
+ * has no route at all, because those controls live inside the header. Each of
1614
+ * them has a method, so a host chrome can rebuild the set: this one,
1615
+ * {@link openCheckpoints}, {@link newChat}, {@link toggleCollapsed} and
1616
+ * {@link toggleTheme}.
1617
+ */
1618
+ openThreads(): void {
1619
+ void this.#refreshDrawer();
1620
+ this.#drawer.open();
1621
+ }
1622
+
1623
+ /**
1624
+ * Open the checkpoints panel (the `::part(checkpoints-button)` route).
1625
+ *
1626
+ * It lists the runs the `data-runs-url` server reports as continuable;
1627
+ * without that attribute the built-in button is never rendered and this opens
1628
+ * an empty panel.
1629
+ */
1630
+ openCheckpoints(): void {
1631
+ void this.#refreshCheckpoints();
1632
+ this.#checkpoints.open();
1633
+ }
1634
+
1371
1635
  /**
1372
1636
  * Start a fresh conversation: forget the persisted history, drop the
1373
1637
  * in-memory run state, clear the transcript, and mint a new thread id.
@@ -1684,17 +1948,13 @@ export class AgUiChat extends HTMLElement {
1684
1948
  controls.className = "header-controls";
1685
1949
  controls.setAttribute("part", "header-controls");
1686
1950
 
1951
+ // Both controls delegate to the public methods, so a host chrome driving
1952
+ // them imperatively takes exactly the path the built-in button takes.
1687
1953
  const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
1688
- history.addEventListener("click", () => {
1689
- void this.#refreshDrawer();
1690
- this.#drawer.open();
1691
- });
1954
+ history.addEventListener("click", () => this.openThreads());
1692
1955
 
1693
1956
  const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
1694
- checkpoints.addEventListener("click", () => {
1695
- void this.#refreshCheckpoints();
1696
- this.#checkpoints.open();
1697
- });
1957
+ checkpoints.addEventListener("click", () => this.openCheckpoints());
1698
1958
 
1699
1959
  const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
1700
1960
  newChat.addEventListener("click", () => this.newChat());
@@ -2069,11 +2329,12 @@ export class AgUiChat extends HTMLElement {
2069
2329
  if (this.#client === null) {
2070
2330
  const agent = this.agentFactory({
2071
2331
  endpoint: this.endpoint,
2072
- headers: this.headers,
2332
+ headers: this.#requestHeaders(),
2073
2333
  // Live getter: the client is built once and cached, but a rotated
2074
2334
  // token must still reach every request — the factory's fetch wrapper
2075
2335
  // re-reads this on each call.
2076
- getHeaders: () => this.headers,
2336
+ getHeaders: () => this.#requestHeaders(),
2337
+ ...this.#credentialsOption(),
2077
2338
  threadId: this.#threadId,
2078
2339
  initialMessages: this.#initialMessages,
2079
2340
  initialState: this.#sharedState,
@@ -1,10 +1,20 @@
1
1
  import { type AbstractAgent, HttpAgent } from "@ag-ui/client";
2
2
  import type { Message } from "@ag-ui/core";
3
+ import { withCredentials } from "./utils.js";
3
4
 
4
5
  /** Config for {@link createHttpAgent}. */
5
6
  export interface HttpAgentOptions {
6
7
  endpoint: string;
7
8
  headers?: Record<string, string>;
9
+ /**
10
+ * Cookie policy for the run request, as `fetch`'s own `credentials` mode.
11
+ * Unset leaves the browser default (`same-origin`) alone — which sends **no**
12
+ * cookies when the agent endpoint is on a different origin (or a different
13
+ * subdomain) from the page. A cookie-authenticated cross-origin deployment
14
+ * needs `"include"` here, and a server that answers it with
15
+ * `Access-Control-Allow-Credentials: true` and a concrete origin.
16
+ */
17
+ credentials?: RequestCredentials;
8
18
  /**
9
19
  * Live header source, re-read on **every** request. `HttpAgent` bakes the
10
20
  * static `headers` into its constructor and the element caches the agent
@@ -43,17 +53,18 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
43
53
  // "Illegal invocation" in browsers. Wrap it so `fetch` is always called as
44
54
  // a free function with the correct receiver. The wrapper also overlays
45
55
  // `getHeaders()` per request, so header rotation (CSRF, short-lived JWT)
46
- // reaches the stream even though the agent instance is cached.
56
+ // reaches the stream even though the agent instance is cached, and applies
57
+ // the configured cookie policy — the agent's own config has no seam for it.
47
58
  fetch: (url, init) => {
48
59
  const fresh = options.getHeaders?.();
49
60
  if (fresh === undefined) {
50
- return fetch(url, init);
61
+ return fetch(url, withCredentials(init, options.credentials));
51
62
  }
52
63
  const headers = new Headers(init?.headers);
53
64
  for (const [name, value] of Object.entries(fresh)) {
54
65
  headers.set(name, value);
55
66
  }
56
- return fetch(url, { ...init, headers });
67
+ return fetch(url, withCredentials({ ...init, headers }, options.credentials));
57
68
  },
58
69
  // Spread conditionally: under `exactOptionalPropertyTypes` an explicit
59
70
  // `undefined` is not assignable to these optional config fields.
@@ -5,6 +5,7 @@ import {
5
5
  SessionStorageStore,
6
6
  type ThreadMeta,
7
7
  } from "./conversation_store.js";
8
+ import { withCredentials } from "./utils.js";
8
9
 
9
10
  /** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
10
11
  interface ServerThreadRow {
@@ -17,6 +18,14 @@ interface ServerThreadRow {
17
18
  /** Live header source, read per request so rotated tokens / CSRF reach the server. */
18
19
  type HeadersProvider = () => Record<string, string>;
19
20
 
21
+ /**
22
+ * Live cookie policy, read per request. A provider rather than a value because
23
+ * the store is built once (on connect) and kept, while a host may configure the
24
+ * element after inserting it — a captured value would pin whatever was set
25
+ * during that first frame.
26
+ */
27
+ type CredentialsProvider = () => RequestCredentials | undefined;
28
+
20
29
  /**
21
30
  * A {@link ClientConversationStore} backed by a server thread-index endpoint —
22
31
  * django-ag-ui's owner-scoped `ThreadsView`, the URL passed to `<ag-ui-chat>`
@@ -37,6 +46,7 @@ export class RemoteConversationStore implements ClientConversationStore {
37
46
  readonly #url: string;
38
47
  readonly #headers: HeadersProvider;
39
48
  readonly #local: ClientConversationStore;
49
+ readonly #credentials: CredentialsProvider;
40
50
  readonly #dropped = new Set<string>();
41
51
  readonly #renamed = new Map<string, string>();
42
52
 
@@ -44,10 +54,12 @@ export class RemoteConversationStore implements ClientConversationStore {
44
54
  url: string,
45
55
  headers: HeadersProvider = () => ({}),
46
56
  local: ClientConversationStore = new SessionStorageStore(),
57
+ credentials: CredentialsProvider = () => undefined,
47
58
  ) {
48
59
  this.#url = url.endsWith("/") ? url : `${url}/`;
49
60
  this.#headers = headers;
50
61
  this.#local = local;
62
+ this.#credentials = credentials;
51
63
  }
52
64
 
53
65
  threadId(): string {
@@ -142,7 +154,7 @@ export class RemoteConversationStore implements ClientConversationStore {
142
154
  /** GET that resolves to the `Response`, or `null` on a network error. */
143
155
  async #get(url: string): Promise<Response | null> {
144
156
  try {
145
- return await fetch(url, { headers: this.#headers() });
157
+ return await fetch(url, withCredentials({ headers: this.#headers() }, this.#credentials()));
146
158
  } catch {
147
159
  return null;
148
160
  }
@@ -156,11 +168,18 @@ export class RemoteConversationStore implements ClientConversationStore {
156
168
  ): Promise<void> {
157
169
  const headers = this.#headers();
158
170
  try {
159
- await fetch(`${this.#url}${encodeURIComponent(threadId)}/`, {
160
- method,
161
- headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
162
- body: body === undefined ? null : JSON.stringify(body),
163
- });
171
+ await fetch(
172
+ `${this.#url}${encodeURIComponent(threadId)}/`,
173
+ withCredentials(
174
+ {
175
+ method,
176
+ headers:
177
+ body === undefined ? headers : { ...headers, "content-type": "application/json" },
178
+ body: body === undefined ? null : JSON.stringify(body),
179
+ },
180
+ this.#credentials(),
181
+ ),
182
+ );
164
183
  } catch {
165
184
  // Best-effort; the optimistic overlay keeps the drawer consistent.
166
185
  }
@@ -1,3 +1,5 @@
1
+ import { withCredentials } from "./utils.js";
2
+
1
3
  /** One row of the server run index (django-ag-ui's `RunsView` wire shape). */
2
4
  export interface RunRow {
3
5
  readonly run_id: string;
@@ -11,6 +13,14 @@ export interface RunRow {
11
13
  /** Live header source, read per request so rotated tokens / CSRF reach the server. */
12
14
  type HeadersProvider = () => Record<string, string>;
13
15
 
16
+ /**
17
+ * Live cookie policy, read per request. A provider rather than a value because
18
+ * the index is built once (on connect) and kept, while a host may configure the
19
+ * element after inserting it — a captured value would pin whatever was set
20
+ * during that first frame.
21
+ */
22
+ type CredentialsProvider = () => RequestCredentials | undefined;
23
+
14
24
  /**
15
25
  * Reads the server's run index and derives the resume / fork URLs beside it.
16
26
  *
@@ -34,10 +44,16 @@ type HeadersProvider = () => Record<string, string>;
34
44
  export class RunIndex {
35
45
  readonly #url: string;
36
46
  readonly #headers: HeadersProvider;
47
+ readonly #credentials: CredentialsProvider;
37
48
 
38
- constructor(url: string, headers: HeadersProvider = () => ({})) {
49
+ constructor(
50
+ url: string,
51
+ headers: HeadersProvider = () => ({}),
52
+ credentials: CredentialsProvider = () => undefined,
53
+ ) {
39
54
  this.#url = url.endsWith("/") ? url : `${url}/`;
40
55
  this.#headers = headers;
56
+ this.#credentials = credentials;
41
57
  }
42
58
 
43
59
  /**
@@ -48,10 +64,16 @@ export class RunIndex {
48
64
  */
49
65
  async list(): Promise<readonly RunRow[]> {
50
66
  try {
51
- const response = await fetch(this.#url, {
52
- method: "GET",
53
- headers: { Accept: "application/json", ...this.#headers() },
54
- });
67
+ const response = await fetch(
68
+ this.#url,
69
+ withCredentials(
70
+ {
71
+ method: "GET",
72
+ headers: { Accept: "application/json", ...this.#headers() },
73
+ },
74
+ this.#credentials(),
75
+ ),
76
+ );
55
77
  if (!response.ok) {
56
78
  return [];
57
79
  }