@artooi/ag-ui-web-component 0.9.0 → 0.11.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 +104 -7
- package/README.md +89 -7
- package/dist/ag-ui-web-component.bundle.js +168 -47
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +39 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +5 -0
- package/dist/core/attachment.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +8 -0
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/upload_attachment.d.ts +8 -2
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +887 -128
- package/dist/index.js.map +4 -4
- package/dist/ui/approval_card.d.ts +51 -0
- package/dist/ui/approval_card.d.ts.map +1 -0
- package/dist/ui/attachment_chips.d.ts.map +1 -1
- package/dist/ui/attachment_tray.d.ts +7 -1
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/question_card.d.ts +52 -0
- package/dist/ui/question_card.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +5 -3
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/skills_menu.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thoughts_block.d.ts +2 -2
- package/dist/ui/thoughts_block.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +16 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts +9 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/ag_ui_chat.ts +251 -14
- package/src/core/agui_client.ts +95 -9
- package/src/core/attachment.ts +21 -1
- package/src/core/conversation_store.ts +84 -18
- package/src/core/remote_conversation_store.ts +24 -3
- package/src/core/upload_attachment.ts +8 -1
- package/src/index.ts +14 -0
- package/src/ui/approval_card.ts +119 -0
- package/src/ui/attachment_chips.ts +5 -0
- package/src/ui/attachment_tray.ts +50 -5
- package/src/ui/question_card.ts +216 -0
- package/src/ui/relative_time.ts +8 -3
- package/src/ui/skills_menu.ts +6 -0
- package/src/ui/styles.ts +130 -9
- package/src/ui/thoughts_block.ts +3 -2
- package/src/ui/thread_drawer.ts +94 -9
- package/src/ui/tool_call_card.ts +6 -0
- package/src/ui/ui_strings.ts +30 -0
- package/src/ui/voice_input.ts +21 -1
- package/src/version.ts +1 -1
|
@@ -64,10 +64,11 @@ export interface ClientConversationStore {
|
|
|
64
64
|
renameThread(threadId: string, title: string): void;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
67
|
+
const KEY_ROOT = "ag-ui-chat";
|
|
68
|
+
const THREAD_SUFFIX = "thread";
|
|
69
|
+
const THREADS_SUFFIX = "threads";
|
|
70
|
+
const MESSAGES_SUFFIX = "messages:";
|
|
71
|
+
const CHECKPOINT_SUFFIX = "checkpoint:";
|
|
71
72
|
|
|
72
73
|
const TITLE_LIMIT = 60;
|
|
73
74
|
const PREVIEW_LIMIT = 100;
|
|
@@ -90,33 +91,50 @@ interface StoredThread {
|
|
|
90
91
|
* Tracks multiple threads per tab: the active id lives under one key, the
|
|
91
92
|
* message history / checkpoint are namespaced by id, and a small index feeds
|
|
92
93
|
* the drawer so it works with no server.
|
|
94
|
+
*
|
|
95
|
+
* An optional `namespace` scopes every key to one element (its `id`, else its
|
|
96
|
+
* endpoint), so two `<ag-ui-chat>` instances — or two apps — on the same origin
|
|
97
|
+
* keep separate active-thread pointers and drawer indexes instead of clobbering
|
|
98
|
+
* each other. Constructing with a namespace migrates any pre-namespacing
|
|
99
|
+
* (`ag-ui-chat:*`) keys into it once, so an existing conversation survives the
|
|
100
|
+
* upgrade; the default empty namespace keeps the legacy origin-global keys.
|
|
93
101
|
*/
|
|
94
102
|
export class SessionStorageStore implements ClientConversationStore {
|
|
103
|
+
readonly #root: string;
|
|
104
|
+
|
|
105
|
+
constructor(namespace = "") {
|
|
106
|
+
this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
|
|
107
|
+
if (namespace !== "") {
|
|
108
|
+
this.#migrateLegacyKeys();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
threadId(): string {
|
|
96
|
-
const
|
|
113
|
+
const key = this.#key(THREAD_SUFFIX);
|
|
114
|
+
const existing = sessionStorage.getItem(key);
|
|
97
115
|
if (existing !== null) {
|
|
98
116
|
return existing;
|
|
99
117
|
}
|
|
100
118
|
const id = randomUUID();
|
|
101
|
-
sessionStorage.setItem(
|
|
119
|
+
sessionStorage.setItem(key, id);
|
|
102
120
|
return id;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
106
|
-
return Promise.resolve(this.#readJson<Message[]>(
|
|
124
|
+
return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
110
|
-
sessionStorage.setItem(
|
|
128
|
+
sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
111
129
|
this.#touchThread(threadId, messages);
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
115
|
-
return this.#readJson<NavigationCheckpoint>(
|
|
133
|
+
return this.#readJson<NavigationCheckpoint>(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void {
|
|
119
|
-
const key =
|
|
137
|
+
const key = this.#key(CHECKPOINT_SUFFIX + threadId);
|
|
120
138
|
if (checkpoint === null) {
|
|
121
139
|
sessionStorage.removeItem(key);
|
|
122
140
|
return;
|
|
@@ -125,14 +143,14 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
clear(threadId: string): void {
|
|
128
|
-
sessionStorage.removeItem(
|
|
129
|
-
sessionStorage.removeItem(
|
|
146
|
+
sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
|
|
147
|
+
sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
130
148
|
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
131
149
|
// Only drop the active pointer when the active thread itself is cleared, so
|
|
132
150
|
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
133
151
|
// drawer must not disturb the conversation on screen.
|
|
134
|
-
if (sessionStorage.getItem(
|
|
135
|
-
sessionStorage.removeItem(
|
|
152
|
+
if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
|
|
153
|
+
sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
|
|
@@ -144,7 +162,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
setActiveThread(threadId: string): void {
|
|
147
|
-
sessionStorage.setItem(
|
|
165
|
+
sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
renameThread(threadId: string, title: string): void {
|
|
@@ -183,15 +201,53 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
183
201
|
}
|
|
184
202
|
|
|
185
203
|
#readThreads(): StoredThread[] {
|
|
186
|
-
return this.#readJson<StoredThread[]>(
|
|
204
|
+
return this.#readJson<StoredThread[]>(this.#key(THREADS_SUFFIX)) ?? [];
|
|
187
205
|
}
|
|
188
206
|
|
|
189
207
|
#writeThreads(threads: readonly StoredThread[]): void {
|
|
208
|
+
const key = this.#key(THREADS_SUFFIX);
|
|
190
209
|
if (threads.length === 0) {
|
|
191
|
-
sessionStorage.removeItem(
|
|
210
|
+
sessionStorage.removeItem(key);
|
|
192
211
|
return;
|
|
193
212
|
}
|
|
194
|
-
sessionStorage.setItem(
|
|
213
|
+
sessionStorage.setItem(key, JSON.stringify(threads));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** This store's fully-qualified key for a suffix (namespaced when set). */
|
|
217
|
+
#key(suffix: string): string {
|
|
218
|
+
return `${this.#root}:${suffix}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One-time move of pre-namespacing (`ag-ui-chat:*`) keys into this instance's
|
|
223
|
+
* namespace, so an existing conversation isn't orphaned by the upgrade. Only
|
|
224
|
+
* this store's own keys move (thread pointer, drawer index, per-thread
|
|
225
|
+
* messages/checkpoints) — the element's `collapsed`/`theme` keys are left
|
|
226
|
+
* alone. The first namespaced instance to mount adopts the legacy data; a
|
|
227
|
+
* second namespace finds it gone and starts fresh.
|
|
228
|
+
*/
|
|
229
|
+
#migrateLegacyKeys(): void {
|
|
230
|
+
const legacyRoot = `${KEY_ROOT}:`;
|
|
231
|
+
const moves: Array<readonly [string, string]> = [];
|
|
232
|
+
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
233
|
+
const key = sessionStorage.key(i);
|
|
234
|
+
if (key === null || !key.startsWith(legacyRoot)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const suffix = key.slice(legacyRoot.length);
|
|
238
|
+
if (isOwnedSuffix(suffix)) {
|
|
239
|
+
moves.push([key, this.#key(suffix)]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Collected first, mutated second — writing while iterating by index skips
|
|
243
|
+
// entries as the key list shifts.
|
|
244
|
+
for (const [from, to] of moves) {
|
|
245
|
+
const value = sessionStorage.getItem(from);
|
|
246
|
+
if (value !== null && sessionStorage.getItem(to) === null) {
|
|
247
|
+
sessionStorage.setItem(to, value);
|
|
248
|
+
}
|
|
249
|
+
sessionStorage.removeItem(from);
|
|
250
|
+
}
|
|
195
251
|
}
|
|
196
252
|
|
|
197
253
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
@@ -208,6 +264,16 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
208
264
|
}
|
|
209
265
|
}
|
|
210
266
|
|
|
267
|
+
/** Whether a legacy key suffix belongs to the store (vs the element's own keys). */
|
|
268
|
+
function isOwnedSuffix(suffix: string): boolean {
|
|
269
|
+
return (
|
|
270
|
+
suffix === THREAD_SUFFIX ||
|
|
271
|
+
suffix === THREADS_SUFFIX ||
|
|
272
|
+
suffix.startsWith(MESSAGES_SUFFIX) ||
|
|
273
|
+
suffix.startsWith(CHECKPOINT_SUFFIX)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
211
277
|
/** The thread title: the first user message, collapsed + truncated. */
|
|
212
278
|
function deriveTitle(messages: readonly Message[]): string {
|
|
213
279
|
for (const message of messages) {
|
|
@@ -96,7 +96,13 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
96
96
|
if (response === null || !response.ok) {
|
|
97
97
|
return this.#local.loadMessages(threadId);
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
// A 200 whose body isn't the expected JSON (a proxy's HTML error page, a
|
|
100
|
+
// truncated stream) must not throw an unhandled rejection that the caller's
|
|
101
|
+
// `void #rehydrate()` swallows — fall back to the local cache instead.
|
|
102
|
+
const body = await this.#readJson<{ messages?: readonly Message[] }>(response);
|
|
103
|
+
if (body === null) {
|
|
104
|
+
return this.#local.loadMessages(threadId);
|
|
105
|
+
}
|
|
100
106
|
return body.messages ?? null;
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -105,15 +111,30 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
105
111
|
if (response === null || !response.ok) {
|
|
106
112
|
return null;
|
|
107
113
|
}
|
|
108
|
-
const body =
|
|
114
|
+
const body = await this.#readJson<{ threads?: readonly ServerThreadRow[] }>(response);
|
|
115
|
+
if (body === null) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
109
118
|
return body.threads ?? [];
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
/** Parse a `Response` body as JSON, or `null` when it isn't valid JSON. */
|
|
122
|
+
async #readJson<T>(response: Response): Promise<T | null> {
|
|
123
|
+
try {
|
|
124
|
+
return (await response.json()) as T;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
112
130
|
#toMeta(row: ServerThreadRow): ThreadMeta {
|
|
113
131
|
return {
|
|
114
132
|
threadId: row.thread_id,
|
|
115
133
|
title: this.#renamed.get(row.thread_id) ?? row.title,
|
|
116
|
-
|
|
134
|
+
// `null` or an unparseable date both become `NaN` (Date.parse's own
|
|
135
|
+
// signal), which `relativeTime` renders as a neutral label rather than
|
|
136
|
+
// "~2950w ago" (epoch 0) or "NaNw ago".
|
|
137
|
+
updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
|
|
117
138
|
preview: row.preview,
|
|
118
139
|
};
|
|
119
140
|
}
|
|
@@ -7,10 +7,17 @@ import type { AttachmentRef } from "./attachment.js";
|
|
|
7
7
|
* `tus-js-client` or direct-to-S3 adapter — via `AgUiChat.uploadHandler`,
|
|
8
8
|
* **without** touching the tray, the chips, or the AG-UI wire (refs are
|
|
9
9
|
* transport-agnostic).
|
|
10
|
+
*
|
|
11
|
+
* The optional third `signal` argument lets the tray abort an in-flight upload
|
|
12
|
+
* when its chip is removed (or the element is torn down), so a cancelled upload
|
|
13
|
+
* doesn't orphan a server-side file. It is non-breaking: existing two-argument
|
|
14
|
+
* handlers keep working (the extra argument is simply ignored), and a custom
|
|
15
|
+
* handler that honours it should abort its own transport when the signal fires.
|
|
10
16
|
*/
|
|
11
17
|
export type UploadHandler = (
|
|
12
18
|
file: File,
|
|
13
19
|
onProgress: (fraction: number) => void,
|
|
20
|
+
signal?: AbortSignal,
|
|
14
21
|
) => Promise<AttachmentRef>;
|
|
15
22
|
|
|
16
23
|
/** Options for {@link uploadAttachment}. */
|
|
@@ -22,7 +29,7 @@ export interface UploadOptions {
|
|
|
22
29
|
/** Progress callback, `0..1`, fired as the body uploads. */
|
|
23
30
|
readonly onProgress?: (fraction: number) => void;
|
|
24
31
|
/** Abort signal to cancel the in-flight upload. */
|
|
25
|
-
readonly signal?: AbortSignal;
|
|
32
|
+
readonly signal?: AbortSignal | undefined;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export {
|
|
|
27
27
|
type AgUiToolCall,
|
|
28
28
|
ConnectionLostError,
|
|
29
29
|
type ExecuteTool,
|
|
30
|
+
type InterruptResponse,
|
|
31
|
+
type ResolveInterrupts,
|
|
30
32
|
type ToolExecution,
|
|
31
33
|
} from "./core/agui_client.js";
|
|
32
34
|
export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
|
|
@@ -98,12 +100,24 @@ export {
|
|
|
98
100
|
type RouteWithParams,
|
|
99
101
|
} from "./tools/route_map.js";
|
|
100
102
|
export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
|
|
103
|
+
export {
|
|
104
|
+
type ApprovalOptions,
|
|
105
|
+
type ApprovalRenderer,
|
|
106
|
+
type ApprovalRequest,
|
|
107
|
+
requestApproval,
|
|
108
|
+
} from "./ui/approval_card.js";
|
|
101
109
|
export {
|
|
102
110
|
type ConfirmationOptions,
|
|
103
111
|
type ConfirmationRequest,
|
|
104
112
|
requestConfirmation,
|
|
105
113
|
} from "./ui/confirmation_card.js";
|
|
106
114
|
export { prettifyToolName } from "./ui/prettify_tool_name.js";
|
|
115
|
+
export {
|
|
116
|
+
type QuestionOptions,
|
|
117
|
+
type QuestionRenderer,
|
|
118
|
+
type QuestionRequest,
|
|
119
|
+
requestQuestion,
|
|
120
|
+
} from "./ui/question_card.js";
|
|
107
121
|
export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
|
|
108
122
|
export {
|
|
109
123
|
type SettledStatus,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/** What the inline approval card displays for one server-side-tool interrupt. */
|
|
4
|
+
export interface ApprovalRequest {
|
|
5
|
+
/**
|
|
6
|
+
* Human-readable prompt from the AG-UI interrupt (e.g.
|
|
7
|
+
* `Approve delete_thing({…})?`). Falls back to the generic
|
|
8
|
+
* {@link UiStrings.approvalPrompt} when the interrupt carries no message.
|
|
9
|
+
*/
|
|
10
|
+
message?: string;
|
|
11
|
+
/** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
|
|
12
|
+
toolName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Build a labelled action button. */
|
|
16
|
+
function actionButton(modifier: string, label: string): HTMLButtonElement {
|
|
17
|
+
const button = document.createElement("button");
|
|
18
|
+
button.type = "button";
|
|
19
|
+
button.className = `approval-btn approval-btn--${modifier}`;
|
|
20
|
+
button.setAttribute("part", `approval-button approval-${modifier}`);
|
|
21
|
+
button.textContent = label;
|
|
22
|
+
return button;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options for {@link requestApproval}. */
|
|
26
|
+
export interface ApprovalOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Aborting this signal resolves the card as **denied** (buttons disabled,
|
|
29
|
+
* `data-resolved="denied"`) — the hook a Stop control uses to dismiss a
|
|
30
|
+
* pending approval when the user cancels the whole run.
|
|
31
|
+
*/
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
34
|
+
strings?: UiStrings;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A fully custom renderer for a server-side-tool approval, set via
|
|
39
|
+
* `AgUiChat.approvalRenderer`. Receives the {@link ApprovalRequest} (the
|
|
40
|
+
* interrupt's message + tool name) and an `AbortSignal` that fires when the run
|
|
41
|
+
* is stopped, and resolves `true` to approve or `false` to deny. When provided
|
|
42
|
+
* it **replaces** the built-in {@link requestApproval} card entirely — the host
|
|
43
|
+
* owns the DOM, so it can render a native modal, a framework component, or
|
|
44
|
+
* anything else. See the `strings` / `::part()` seams for styling the built-in
|
|
45
|
+
* card instead of replacing it.
|
|
46
|
+
*/
|
|
47
|
+
export type ApprovalRenderer = (
|
|
48
|
+
request: ApprovalRequest,
|
|
49
|
+
options: { signal: AbortSignal },
|
|
50
|
+
) => Promise<boolean>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Append an inline **approval** card to ``host`` and resolve when the user
|
|
54
|
+
* decides whether a gated *server-side* tool may run.
|
|
55
|
+
*
|
|
56
|
+
* The server-side approval gate is distinct from the client-tool confirmation
|
|
57
|
+
* card ({@link requestConfirmation}): a destructive server tool defers instead
|
|
58
|
+
* of executing, and the run finishes on an AG-UI *interrupt* the client answers
|
|
59
|
+
* with `resume[]`. This card is the browser half of that loop — it reads
|
|
60
|
+
* naturally after the tool-call card whose execution it gates, and resolves
|
|
61
|
+
* ``true`` to approve (run the tool) or ``false`` to deny. The card stays in the
|
|
62
|
+
* transcript as a resolved record (buttons disabled, `data-resolved` set)
|
|
63
|
+
* rather than vanishing.
|
|
64
|
+
*/
|
|
65
|
+
export function requestApproval(
|
|
66
|
+
host: Node & ParentNode,
|
|
67
|
+
request: ApprovalRequest,
|
|
68
|
+
options: ApprovalOptions = {},
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
const strings = options.strings ?? DEFAULT_UI_STRINGS;
|
|
71
|
+
return new Promise<boolean>((resolve) => {
|
|
72
|
+
const card = document.createElement("div");
|
|
73
|
+
card.className = "approval";
|
|
74
|
+
card.setAttribute("part", "approval");
|
|
75
|
+
if (request.toolName !== undefined) {
|
|
76
|
+
card.setAttribute("data-tool-name", request.toolName);
|
|
77
|
+
}
|
|
78
|
+
card.setAttribute("role", "group");
|
|
79
|
+
card.setAttribute("aria-label", strings.approveAction);
|
|
80
|
+
|
|
81
|
+
const body = document.createElement("div");
|
|
82
|
+
body.className = "approval-body";
|
|
83
|
+
body.setAttribute("part", "approval-body");
|
|
84
|
+
body.textContent = request.message ?? strings.approvalPrompt;
|
|
85
|
+
|
|
86
|
+
const actions = document.createElement("div");
|
|
87
|
+
actions.className = "approval-actions";
|
|
88
|
+
actions.setAttribute("part", "approval-actions");
|
|
89
|
+
|
|
90
|
+
const deny = actionButton("deny", strings.deny);
|
|
91
|
+
const approve = actionButton("approve", strings.approve);
|
|
92
|
+
|
|
93
|
+
let settled = false;
|
|
94
|
+
const close = (approved: boolean): void => {
|
|
95
|
+
if (settled) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
settled = true;
|
|
99
|
+
deny.disabled = true;
|
|
100
|
+
approve.disabled = true;
|
|
101
|
+
card.setAttribute("data-resolved", approved ? "approved" : "denied");
|
|
102
|
+
resolve(approved);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
deny.addEventListener("click", () => close(false));
|
|
106
|
+
approve.addEventListener("click", () => close(true));
|
|
107
|
+
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
108
|
+
|
|
109
|
+
actions.append(deny, approve);
|
|
110
|
+
card.append(body, actions);
|
|
111
|
+
host.appendChild(card);
|
|
112
|
+
if (options.signal?.aborted === true) {
|
|
113
|
+
// The run was cancelled before the card could ask; record the denial.
|
|
114
|
+
close(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
approve.focus();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { AttachmentRef } from "../core/attachment.js";
|
|
|
9
9
|
export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
|
|
10
10
|
const list = document.createElement("div");
|
|
11
11
|
list.className = "attachment-chips";
|
|
12
|
+
list.setAttribute("part", "attachment-chips");
|
|
12
13
|
for (const ref of refs) {
|
|
13
14
|
list.appendChild(renderChip(ref));
|
|
14
15
|
}
|
|
@@ -18,19 +19,23 @@ export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivEl
|
|
|
18
19
|
function renderChip(ref: AttachmentRef): HTMLDivElement {
|
|
19
20
|
const chip = document.createElement("div");
|
|
20
21
|
chip.className = "attachment-chip attachment-chip--ready";
|
|
22
|
+
chip.setAttribute("part", "attachment-chip");
|
|
21
23
|
|
|
22
24
|
const icon = document.createElement("span");
|
|
23
25
|
icon.className = "attachment-chip-icon";
|
|
26
|
+
icon.setAttribute("part", "attachment-chip-icon");
|
|
24
27
|
icon.textContent = iconFor(ref.mime);
|
|
25
28
|
icon.setAttribute("aria-hidden", "true");
|
|
26
29
|
|
|
27
30
|
const name = document.createElement("span");
|
|
28
31
|
name.className = "attachment-chip-name";
|
|
32
|
+
name.setAttribute("part", "attachment-chip-name");
|
|
29
33
|
name.textContent = ref.name;
|
|
30
34
|
name.title = ref.name;
|
|
31
35
|
|
|
32
36
|
const size = document.createElement("span");
|
|
33
37
|
size.className = "attachment-chip-size";
|
|
38
|
+
size.setAttribute("part", "attachment-chip-size");
|
|
34
39
|
size.textContent = formatBytes(ref.size);
|
|
35
40
|
|
|
36
41
|
chip.append(icon, name, size);
|
|
@@ -30,6 +30,8 @@ interface TrayItem {
|
|
|
30
30
|
progress: number;
|
|
31
31
|
ref: AttachmentRef | null;
|
|
32
32
|
error: string;
|
|
33
|
+
/** Aborts the in-flight upload when the chip is removed / the tray cleared. */
|
|
34
|
+
controller: AbortController | null;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -68,6 +70,7 @@ export class AttachmentTray {
|
|
|
68
70
|
progress: 0,
|
|
69
71
|
ref: null,
|
|
70
72
|
error: "",
|
|
73
|
+
controller: null,
|
|
71
74
|
};
|
|
72
75
|
this.#items.push(item);
|
|
73
76
|
const rejection = this.#reject(file);
|
|
@@ -110,12 +113,26 @@ export class AttachmentTray {
|
|
|
110
113
|
this.#render();
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
/** Drop every chip (a reset / new-chat). */
|
|
116
|
+
/** Drop every chip (a reset / new-chat), aborting any in-flight upload. */
|
|
114
117
|
clear(): void {
|
|
118
|
+
for (const item of this.#items) {
|
|
119
|
+
item.controller?.abort();
|
|
120
|
+
}
|
|
115
121
|
this.#items = [];
|
|
116
122
|
this.#render();
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Abort every in-flight upload without touching the rendered chips — the
|
|
127
|
+
* teardown path when the host element is removed mid-upload, so a cancelled
|
|
128
|
+
* transfer doesn't orphan a server-side file.
|
|
129
|
+
*/
|
|
130
|
+
dispose(): void {
|
|
131
|
+
for (const item of this.#items) {
|
|
132
|
+
item.controller?.abort();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/** The size/type rejection reason for a file, or `null` when accepted. */
|
|
120
137
|
#reject(file: File): string | null {
|
|
121
138
|
if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
|
|
@@ -128,15 +145,32 @@ export class AttachmentTray {
|
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
#upload(item: TrayItem): void {
|
|
148
|
+
// Re-apply the client guard on every attempt, not just the first `add` — an
|
|
149
|
+
// ERROR chip always renders retry, so without this a size/type-rejected file
|
|
150
|
+
// would upload in full on ↻.
|
|
151
|
+
const rejection = this.#reject(item.file);
|
|
152
|
+
if (rejection !== null) {
|
|
153
|
+
item.status = ATTACHMENT_STATUS.ERROR;
|
|
154
|
+
item.error = rejection;
|
|
155
|
+
this.#render();
|
|
156
|
+
this.#config.onChange?.();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
131
159
|
item.status = ATTACHMENT_STATUS.UPLOADING;
|
|
132
160
|
item.progress = 0;
|
|
133
161
|
item.error = "";
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
item.controller = controller;
|
|
134
164
|
this.#render();
|
|
135
165
|
this.#config
|
|
136
|
-
.upload(
|
|
137
|
-
item.
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
.upload(
|
|
167
|
+
item.file,
|
|
168
|
+
(fraction) => {
|
|
169
|
+
item.progress = fraction;
|
|
170
|
+
this.#render();
|
|
171
|
+
},
|
|
172
|
+
controller.signal,
|
|
173
|
+
)
|
|
140
174
|
.then((ref) => {
|
|
141
175
|
item.status = ATTACHMENT_STATUS.READY;
|
|
142
176
|
item.ref = ref;
|
|
@@ -146,12 +180,15 @@ export class AttachmentTray {
|
|
|
146
180
|
item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
|
|
147
181
|
})
|
|
148
182
|
.finally(() => {
|
|
183
|
+
item.controller = null;
|
|
149
184
|
this.#render();
|
|
150
185
|
this.#config.onChange?.();
|
|
151
186
|
});
|
|
152
187
|
}
|
|
153
188
|
|
|
154
189
|
#remove(item: TrayItem): void {
|
|
190
|
+
// Abort an in-flight upload so removing its chip doesn't orphan the file.
|
|
191
|
+
item.controller?.abort();
|
|
155
192
|
this.#items = this.#items.filter((other) => other !== item);
|
|
156
193
|
this.#render();
|
|
157
194
|
this.#config.onChange?.();
|
|
@@ -168,19 +205,23 @@ export class AttachmentTray {
|
|
|
168
205
|
#renderChip(item: TrayItem): HTMLDivElement {
|
|
169
206
|
const chip = document.createElement("div");
|
|
170
207
|
chip.className = `attachment-chip attachment-chip--${item.status}`;
|
|
208
|
+
chip.setAttribute("part", "attachment-chip");
|
|
171
209
|
|
|
172
210
|
const icon = document.createElement("span");
|
|
173
211
|
icon.className = "attachment-chip-icon";
|
|
212
|
+
icon.setAttribute("part", "attachment-chip-icon");
|
|
174
213
|
icon.textContent = iconFor(item.file.type);
|
|
175
214
|
icon.setAttribute("aria-hidden", "true");
|
|
176
215
|
|
|
177
216
|
const name = document.createElement("span");
|
|
178
217
|
name.className = "attachment-chip-name";
|
|
218
|
+
name.setAttribute("part", "attachment-chip-name");
|
|
179
219
|
name.textContent = item.file.name;
|
|
180
220
|
name.title = item.file.name;
|
|
181
221
|
|
|
182
222
|
const meta = document.createElement("span");
|
|
183
223
|
meta.className = "attachment-chip-size";
|
|
224
|
+
meta.setAttribute("part", "attachment-chip-size");
|
|
184
225
|
meta.textContent =
|
|
185
226
|
item.status === ATTACHMENT_STATUS.ERROR ? item.error : formatBytes(item.file.size);
|
|
186
227
|
|
|
@@ -189,8 +230,10 @@ export class AttachmentTray {
|
|
|
189
230
|
if (item.status === ATTACHMENT_STATUS.UPLOADING) {
|
|
190
231
|
const bar = document.createElement("div");
|
|
191
232
|
bar.className = "attachment-chip-bar";
|
|
233
|
+
bar.setAttribute("part", "attachment-chip-bar");
|
|
192
234
|
const fill = document.createElement("div");
|
|
193
235
|
fill.className = "attachment-chip-bar-fill";
|
|
236
|
+
fill.setAttribute("part", "attachment-chip-bar-fill");
|
|
194
237
|
fill.style.width = `${Math.round(item.progress * 100)}%`;
|
|
195
238
|
bar.appendChild(fill);
|
|
196
239
|
chip.appendChild(bar);
|
|
@@ -200,6 +243,7 @@ export class AttachmentTray {
|
|
|
200
243
|
const retry = document.createElement("button");
|
|
201
244
|
retry.type = "button";
|
|
202
245
|
retry.className = "attachment-chip-retry";
|
|
246
|
+
retry.setAttribute("part", "attachment-chip-retry");
|
|
203
247
|
retry.title = this.#strings.retry;
|
|
204
248
|
retry.setAttribute("aria-label", this.#strings.retryUpload);
|
|
205
249
|
retry.textContent = "↻";
|
|
@@ -210,6 +254,7 @@ export class AttachmentTray {
|
|
|
210
254
|
const remove = document.createElement("button");
|
|
211
255
|
remove.type = "button";
|
|
212
256
|
remove.className = "attachment-chip-remove";
|
|
257
|
+
remove.setAttribute("part", "attachment-chip-remove");
|
|
213
258
|
remove.title = this.#strings.remove;
|
|
214
259
|
remove.setAttribute("aria-label", this.#strings.removeAttachment);
|
|
215
260
|
remove.textContent = "✕";
|