@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.
- package/CHANGELOG.md +192 -1
- package/README.md +404 -28
- package/dist/ag-ui-web-component.bundle.js +781 -451
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +29 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +93 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +9 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +8 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/run_index.d.ts +8 -1
- package/dist/core/run_index.d.ts.map +1 -1
- package/dist/core/transcribe_audio.d.ts +7 -0
- package/dist/core/transcribe_audio.d.ts.map +1 -1
- package/dist/core/upload_attachment.d.ts +9 -0
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/core/utils.d.ts +12 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/dom/animations.d.ts +51 -11
- package/dist/dom/animations.d.ts.map +1 -1
- package/dist/dom/dom_driver.d.ts +12 -7
- package/dist/dom/dom_driver.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1275 -520
- package/dist/index.js.map +3 -3
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +8 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +35 -0
- package/src/core/ag_ui_chat.ts +452 -55
- package/src/core/create_http_agent.ts +14 -3
- package/src/core/remote_conversation_store.ts +25 -6
- package/src/core/run_index.ts +27 -5
- package/src/core/transcribe_audio.ts +20 -5
- package/src/core/upload_attachment.ts +13 -0
- package/src/core/utils.ts +18 -0
- package/src/dom/animations.ts +175 -31
- package/src/dom/dom_driver.ts +18 -12
- package/src/index.ts +4 -0
- package/src/ui/styles.ts +751 -421
- package/src/ui/ui_strings.ts +9 -1
- package/src/ui/voice_input.ts +7 -1
- 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(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/core/run_index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|
package/src/dom/animations.ts
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
*
|
|
235
|
+
* Flash a ring around ``el`` without touching focus.
|
|
94
236
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
101
|
-
return
|
|
241
|
+
export function flash(el: HTMLElement, options: FlashOptions = {}): Promise<void> {
|
|
242
|
+
return flashRing(el, options, false);
|
|
102
243
|
}
|
|
103
244
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 =
|
|
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 =
|
|
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 }));
|
package/src/dom/dom_driver.ts
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
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,
|