@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
@@ -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
  }
@@ -1,3 +1,5 @@
1
+ import { withCredentials } from "./utils.js";
2
+
1
3
  /**
2
4
  * The composer's voice-transcription contract: take a recorded audio `Blob` and
3
5
  * resolve to the transcript text. The built-in handler is {@link transcribeAudio}
@@ -13,6 +15,13 @@ export interface TranscribeOptions {
13
15
  readonly url: string;
14
16
  /** Extra HTTP headers (CSRF / auth), read fresh per request. */
15
17
  readonly headers?: Record<string, string>;
18
+ /**
19
+ * Cookie policy, as `fetch`'s own `credentials` mode. Unset leaves the
20
+ * browser default (`same-origin`), which sends no cookies to an endpoint on
21
+ * another origin; a cookie-authenticated cross-origin deployment needs
22
+ * `"include"`.
23
+ */
24
+ readonly credentials?: RequestCredentials;
16
25
  }
17
26
 
18
27
  /**
@@ -29,11 +38,17 @@ export async function transcribeAudio(audio: Blob, options: TranscribeOptions):
29
38
  // reads the blob's content type).
30
39
  form.append("audio", audio, "recording.webm");
31
40
 
32
- const response = await fetch(options.url, {
33
- method: "POST",
34
- headers: { ...(options.headers ?? {}) },
35
- body: form,
36
- });
41
+ const response = await fetch(
42
+ options.url,
43
+ withCredentials(
44
+ {
45
+ method: "POST",
46
+ headers: { ...(options.headers ?? {}) },
47
+ body: form,
48
+ },
49
+ options.credentials,
50
+ ),
51
+ );
37
52
  if (!response.ok) {
38
53
  throw new Error(await errorMessage(response));
39
54
  }
@@ -26,6 +26,15 @@ export interface UploadOptions {
26
26
  readonly url: string;
27
27
  /** Extra HTTP headers (CSRF / auth), read fresh per upload. */
28
28
  readonly headers?: Record<string, string>;
29
+ /**
30
+ * Cookie policy, spelled as `fetch`'s `credentials` mode for consistency with
31
+ * the component's other endpoints — but carried by `XMLHttpRequest`, which
32
+ * only has the two-state `withCredentials`. `"include"` sets it; every other
33
+ * value leaves it off, which matches `"same-origin"`. `"omit"` therefore
34
+ * **cannot** be honoured for a same-origin upload (XHR always sends cookies
35
+ * there); use a custom {@link UploadHandler} if that matters.
36
+ */
37
+ readonly credentials?: RequestCredentials;
29
38
  /** Progress callback, `0..1`, fired as the body uploads. */
30
39
  readonly onProgress?: (fraction: number) => void;
31
40
  /** Abort signal to cancel the in-flight upload. */
@@ -48,6 +57,10 @@ export function uploadAttachment(file: File, options: UploadOptions): Promise<At
48
57
 
49
58
  const xhr = new XMLHttpRequest();
50
59
  xhr.open("POST", options.url);
60
+ // Cross-origin cookies ride only when asked for, exactly as with the fetch
61
+ // sites; without this an upload to another subdomain is anonymous and 401s
62
+ // while the run itself succeeds.
63
+ xhr.withCredentials = options.credentials === "include";
51
64
  for (const [key, value] of Object.entries(options.headers ?? {})) {
52
65
  xhr.setRequestHeader(key, value);
53
66
  }
@@ -0,0 +1,18 @@
1
+ // Non-exported-from-index helpers shared by the core transport modules.
2
+
3
+ /**
4
+ * Overlay a `credentials` mode onto a fetch `init`, or hand the `init` back
5
+ * untouched when the caller has none configured.
6
+ *
7
+ * Absent and `undefined` are not the same thing here. `exactOptionalPropertyTypes`
8
+ * rejects an explicit `credentials: undefined`, and writing one anyway would
9
+ * state a mode where the point is to leave the browser's own default in place.
10
+ * Every fetch this component makes has to make that choice, so it is made in one
11
+ * place rather than repeated (and eventually forgotten) at each call site.
12
+ */
13
+ export function withCredentials(
14
+ init: RequestInit | undefined,
15
+ credentials: RequestCredentials | undefined,
16
+ ): RequestInit | undefined {
17
+ return credentials === undefined ? init : { ...init, credentials };
18
+ }
@@ -8,14 +8,61 @@
8
8
 
9
9
  import { setNativeChecked, setNativeValue } from "./native_setter.js";
10
10
 
11
+ /** Ring colour when the host page has not themed `--ag-ui-accent`. */
11
12
  const ACCENT = "#4f46e5";
12
13
 
14
+ /**
15
+ * Fallback for the box-shadow rings, which read as a soft halo rather than a
16
+ * line and so carry their own alpha.
17
+ */
18
+ const ACCENT_HALO = "rgba(79, 70, 229, 0.4)";
19
+
20
+ const ACCENT_PROPERTY = "--ag-ui-accent";
21
+
13
22
  function delay(ms: number): Promise<void> {
14
23
  return new Promise<void>((resolve) => {
15
24
  setTimeout(resolve, ms);
16
25
  });
17
26
  }
18
27
 
28
+ /**
29
+ * Whether the user has asked the OS/browser to minimise motion.
30
+ *
31
+ * Honoured by every primitive that *moves* something: the action animations
32
+ * skip their hold delays, `scrollIntoCenterView` jumps instead of gliding, and
33
+ * the focus flash drops its fade while still holding the ring long enough to be
34
+ * seen — reduced motion asks for no animation, not for no feedback. The
35
+ * character-typing and highlight primitives predate this and keep their
36
+ * explicit-duration contract.
37
+ */
38
+ export function prefersReducedMotion(): boolean {
39
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
40
+ }
41
+
42
+ /** Like {@link delay}, but resolves immediately under reduced motion. */
43
+ function motionDelay(ms: number): Promise<void> {
44
+ if (ms <= 0 || prefersReducedMotion()) {
45
+ return Promise.resolve();
46
+ }
47
+ return delay(ms);
48
+ }
49
+
50
+ /**
51
+ * Resolve the accent colour from the **target's** computed style.
52
+ *
53
+ * `--ag-ui-accent` inherits, so reading it off the element the agent is about
54
+ * to touch picks up whatever the host's cascade produced there — a themed page
55
+ * gets flashed in its own colour instead of this package's indigo.
56
+ */
57
+ function accentColor(el: HTMLElement, fallback: string): string {
58
+ const themed = window.getComputedStyle(el).getPropertyValue(ACCENT_PROPERTY).trim();
59
+ return themed === "" ? fallback : themed;
60
+ }
61
+
62
+ function ringShadow(el: HTMLElement): string {
63
+ return `0 0 0 3px ${accentColor(el, ACCENT_HALO)}`;
64
+ }
65
+
19
66
  /** An element whose `value` can be typed into. */
20
67
  export type TextLikeElement = HTMLInputElement | HTMLTextAreaElement;
21
68
 
@@ -59,7 +106,7 @@ export async function highlightThenClick(
59
106
  const highlightMs = options.highlightMs ?? 280;
60
107
  const previousOutline = el.style.outline;
61
108
  const previousOffset = el.style.outlineOffset;
62
- el.style.outline = `2px solid ${ACCENT}`;
109
+ el.style.outline = `2px solid ${accentColor(el, ACCENT)}`;
63
110
  el.style.outlineOffset = "2px";
64
111
  await delay(highlightMs);
65
112
  el.style.outline = previousOutline;
@@ -67,46 +114,143 @@ export async function highlightThenClick(
67
114
  el.click();
68
115
  }
69
116
 
70
- /** Scroll ``el`` to the vertical centre of the viewport. */
71
- export function scrollIntoCenterView(el: HTMLElement): void {
72
- el.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
117
+ export interface ScrollOptions {
118
+ /**
119
+ * How long to wait for a moving scroll to settle before giving up.
120
+ * Default 600.
121
+ */
122
+ settleMs?: number;
73
123
  }
74
124
 
125
+ const SCROLL_SETTLE_MS = 600;
126
+
127
+ /** How long to give a smooth scroll to start moving before assuming it will not. */
128
+ const SCROLL_START_MS = 100;
129
+
130
+ /**
131
+ * Scroll ``el`` to the vertical centre of the viewport.
132
+ *
133
+ * The returned promise resolves once the scroll has settled, so a caller can
134
+ * hold its animation until the element has stopped moving — a ring drawn
135
+ * mid-glide lands somewhere the user is not looking yet. Awaiting is optional;
136
+ * the scroll itself is requested synchronously.
137
+ */
138
+ export function scrollIntoCenterView(el: HTMLElement, options: ScrollOptions = {}): Promise<void> {
139
+ const reduced = prefersReducedMotion();
140
+ el.scrollIntoView({
141
+ block: "center",
142
+ inline: "nearest",
143
+ behavior: reduced ? "auto" : "smooth",
144
+ });
145
+ if (reduced) {
146
+ return Promise.resolve();
147
+ }
148
+ return new Promise<void>((resolve) => {
149
+ let timer: ReturnType<typeof setTimeout>;
150
+ const settle = (): void => {
151
+ clearTimeout(timer);
152
+ document.removeEventListener("scroll", started, true);
153
+ document.removeEventListener("scrollend", settle, true);
154
+ resolve();
155
+ };
156
+ const started = (): void => {
157
+ document.removeEventListener("scroll", started, true);
158
+ clearTimeout(timer);
159
+ timer = setTimeout(settle, options.settleMs ?? SCROLL_SETTLE_MS);
160
+ };
161
+ // Neither event bubbles, and both fire on whichever ancestor actually
162
+ // scrolled, so listen on the document in the capture phase.
163
+ //
164
+ // Two timeouts, because two things can go wrong. An element already in
165
+ // view never scrolls and so never fires scrollend — waiting the full
166
+ // budget for it would add dead time to every action, hence the short
167
+ // did-anything-move probe. Once something is moving, the longer budget
168
+ // covers browsers that do not implement scrollend at all (Safari gained it
169
+ // only recently).
170
+ timer = setTimeout(settle, SCROLL_START_MS);
171
+ document.addEventListener("scroll", started, true);
172
+ document.addEventListener("scrollend", settle, true);
173
+ });
174
+ }
175
+
176
+ /** Total on-screen life of the flash ring, hold plus fade. */
177
+ const FLASH_MS = 1200;
178
+
179
+ /** Share of {@link FLASH_MS} spent fading out rather than holding. */
180
+ const FLASH_FADE_RATIO = 1 / 3;
181
+
75
182
  export interface FlashOptions {
76
- /** Milliseconds to hold the focus flash. Default 200. */
183
+ /**
184
+ * Total milliseconds the ring is on screen, hold plus fade. Default 1200 —
185
+ * long enough for someone who does not yet know where to look to find it.
186
+ */
77
187
  flashMs?: number;
188
+ /** Ring colour. Defaults to the target's `--ag-ui-accent`, else the package accent. */
189
+ color?: string;
190
+ /**
191
+ * Move keyboard focus to the element as well. Defaults to false for
192
+ * {@link flash} and true for {@link focusWithFlash}.
193
+ */
194
+ focus?: boolean;
78
195
  }
79
196
 
80
- /** Focus ``el`` and briefly flash a ring around it. */
81
- export async function focusWithFlash(el: HTMLElement, options: FlashOptions = {}): Promise<void> {
82
- const flashMs = options.flashMs ?? 200;
83
- el.focus();
84
- const previousShadow = el.style.boxShadow;
85
- el.style.boxShadow = `0 0 0 3px rgba(79, 70, 229, 0.4)`;
86
- await delay(flashMs);
87
- el.style.boxShadow = previousShadow;
197
+ async function flashRing(
198
+ el: HTMLElement,
199
+ options: FlashOptions,
200
+ focusByDefault: boolean,
201
+ ): Promise<void> {
202
+ if (options.focus ?? focusByDefault) {
203
+ // preventScroll: a caller's smooth scroll is typically still in flight, and
204
+ // the instant scroll focus() would otherwise do fights it.
205
+ el.focus({ preventScroll: true });
206
+ }
207
+ const flashMs = options.flashMs ?? FLASH_MS;
208
+ if (flashMs <= 0) {
209
+ return;
210
+ }
211
+ const previousOutline = el.style.outline;
212
+ const previousOffset = el.style.outlineOffset;
213
+ const previousTransition = el.style.transition;
214
+ const color = options.color ?? accentColor(el, ACCENT);
215
+ // outline, not box-shadow: a shadow paints outside the border box, so any
216
+ // overflow:hidden ancestor sharing the target's box (a card, a table cell)
217
+ // clips the whole ring away while the helper still reports success.
218
+ el.style.outline = `3px solid ${color}`;
219
+ el.style.outlineOffset = "2px";
220
+ const fadeMs = prefersReducedMotion() ? 0 : Math.round(flashMs * FLASH_FADE_RATIO);
221
+ await delay(flashMs - fadeMs);
222
+ if (fadeMs > 0) {
223
+ // Setting the transition and the new colour in the same task is fine: the
224
+ // browser starts transitions from the after-change style.
225
+ el.style.transition = `outline-color ${fadeMs}ms ease-out`;
226
+ el.style.outline = "3px solid transparent";
227
+ await delay(fadeMs);
228
+ }
229
+ el.style.outline = previousOutline;
230
+ el.style.outlineOffset = previousOffset;
231
+ el.style.transition = previousTransition;
88
232
  }
89
233
 
90
- const RING = "0 0 0 3px rgba(79, 70, 229, 0.4)";
91
-
92
234
  /**
93
- * Whether the user has asked the OS/browser to minimise motion.
235
+ * Flash a ring around ``el`` without touching focus.
94
236
  *
95
- * The richer action animations below check this and skip their hold delays so
96
- * the agent's edits still happen (events fire, values set) but without the
97
- * visible pause. The character-typing / highlight primitives above predate this
98
- * and keep their explicit-duration contract.
237
+ * Prefer this over {@link focusWithFlash} when the point is to *show* the user
238
+ * something: moving focus steals it from the composer, can fire blur validation
239
+ * on whatever they were mid-edit in, and can close an open menu.
99
240
  */
100
- export function prefersReducedMotion(): boolean {
101
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
241
+ export function flash(el: HTMLElement, options: FlashOptions = {}): Promise<void> {
242
+ return flashRing(el, options, false);
102
243
  }
103
244
 
104
- /** Like {@link delay}, but resolves immediately under reduced motion. */
105
- function motionDelay(ms: number): Promise<void> {
106
- if (ms <= 0 || prefersReducedMotion()) {
107
- return Promise.resolve();
108
- }
109
- return delay(ms);
245
+ /**
246
+ * Focus ``el`` and flash a ring around it.
247
+ *
248
+ * Focus moves unless ``options.focus`` says otherwise — that is the documented
249
+ * behaviour of this name. Use {@link flash} for a highlight that leaves focus
250
+ * where the user put it.
251
+ */
252
+ export function focusWithFlash(el: HTMLElement, options: FlashOptions = {}): Promise<void> {
253
+ return flashRing(el, options, true);
110
254
  }
111
255
 
112
256
  export interface PressOptions {
@@ -126,7 +270,7 @@ export async function pressThenClick(el: HTMLElement, options: PressOptions = {}
126
270
  const previousShadow = el.style.boxShadow;
127
271
  el.style.transition = "transform 80ms ease";
128
272
  el.style.transform = "scale(0.96)";
129
- el.style.boxShadow = RING;
273
+ el.style.boxShadow = ringShadow(el);
130
274
  await motionDelay(pressMs);
131
275
  el.style.transform = previousTransform;
132
276
  el.style.transition = previousTransition;
@@ -166,7 +310,7 @@ export async function selectOption(
166
310
  const highlightMs = options.highlightMs ?? 220;
167
311
  const previousOutline = el.style.outline;
168
312
  const previousOffset = el.style.outlineOffset;
169
- el.style.outline = `2px solid ${ACCENT}`;
313
+ el.style.outline = `2px solid ${accentColor(el, ACCENT)}`;
170
314
  el.style.outlineOffset = "2px";
171
315
  await motionDelay(highlightMs);
172
316
  setNativeValue(el, option.value);
@@ -192,7 +336,7 @@ export async function toggleControl(
192
336
  ): Promise<void> {
193
337
  const flashMs = options.flashMs ?? 200;
194
338
  const previousShadow = el.style.boxShadow;
195
- el.style.boxShadow = RING;
339
+ el.style.boxShadow = ringShadow(el);
196
340
  await motionDelay(flashMs);
197
341
  setNativeChecked(el, checked);
198
342
  el.dispatchEvent(new Event("input", { bubbles: true }));
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type FlashOptions,
2
3
  focusWithFlash,
3
4
  type HighlightClickOptions,
4
5
  highlightThenClick,
@@ -23,20 +24,25 @@ import { setNativeChecked, setNativeValue } from "./native_setter.js";
23
24
  * `fill_field(name, value)` finds `#id_<name>` then calls {@link fillField}.
24
25
  */
25
26
 
26
- export interface FillFieldOptions extends TypeOptions, FlashOptionsLike {}
27
-
28
- interface FlashOptionsLike {
29
- flashMs?: number;
30
- }
27
+ /**
28
+ * A field has to hold focus to be typed into, so ``focus`` is not on offer
29
+ * here — everything else the flash takes is.
30
+ */
31
+ export interface FillFieldOptions extends TypeOptions, Omit<FlashOptions, "focus"> {}
31
32
 
32
- /** Scroll to, focus (with a flash), and type ``value`` into a text field. */
33
+ /**
34
+ * Scroll to, focus (with a flash), and type ``value`` into a text field.
35
+ *
36
+ * The scroll is awaited: typing into an element that is still gliding past
37
+ * shows the user nothing.
38
+ */
33
39
  export async function fillField(
34
40
  el: TextLikeElement,
35
41
  value: string,
36
42
  options: FillFieldOptions = {},
37
43
  ): Promise<void> {
38
- scrollIntoCenterView(el);
39
- await focusWithFlash(el, { flashMs: options.flashMs ?? 0 });
44
+ await scrollIntoCenterView(el);
45
+ await focusWithFlash(el, { ...options, flashMs: options.flashMs ?? 0 });
40
46
  await typeInto(el, value, options);
41
47
  }
42
48
 
@@ -45,13 +51,13 @@ export async function clickElement(
45
51
  el: HTMLElement,
46
52
  options: HighlightClickOptions = {},
47
53
  ): Promise<void> {
48
- scrollIntoCenterView(el);
54
+ await scrollIntoCenterView(el);
49
55
  await highlightThenClick(el, options);
50
56
  }
51
57
 
52
58
  /** Scroll to a button/control and click it with a visible "press" animation. */
53
59
  export async function pressButton(el: HTMLElement, options: PressOptions = {}): Promise<void> {
54
- scrollIntoCenterView(el);
60
+ await scrollIntoCenterView(el);
55
61
  await pressThenClick(el, options);
56
62
  }
57
63
 
@@ -61,7 +67,7 @@ export async function selectControl(
61
67
  value: string,
62
68
  options: SelectOptions = {},
63
69
  ): Promise<void> {
64
- scrollIntoCenterView(el);
70
+ await scrollIntoCenterView(el);
65
71
  await selectOption(el, value, options);
66
72
  }
67
73
 
@@ -71,7 +77,7 @@ export async function toggleCheckbox(
71
77
  checked: boolean,
72
78
  options: ToggleOptions = {},
73
79
  ): Promise<void> {
74
- scrollIntoCenterView(el);
80
+ await scrollIntoCenterView(el);
75
81
  await toggleControl(el, checked, options);
76
82
  }
77
83
 
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,
@@ -64,12 +66,14 @@ export {
64
66
  } from "./core/upload_attachment.js";
65
67
  export {
66
68
  type FlashOptions,
69
+ flash,
67
70
  focusWithFlash,
68
71
  type HighlightClickOptions,
69
72
  highlightThenClick,
70
73
  type PressOptions,
71
74
  prefersReducedMotion,
72
75
  pressThenClick,
76
+ type ScrollOptions,
73
77
  type SelectOptions,
74
78
  scrollIntoCenterView,
75
79
  selectOption,