@artooi/ag-ui-web-component 0.27.0 → 0.29.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 +663 -1
- package/README.md +557 -11
- package/dist/ag-ui-web-component.bundle.js +294 -36
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +69 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +262 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +46 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +43 -1
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +13 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +23 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +28 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2104 -194
- package/dist/index.js.map +4 -4
- package/dist/tools/is_destructive.d.ts +8 -2
- package/dist/tools/is_destructive.d.ts.map +1 -1
- package/dist/tools/parse_tool_catalog.d.ts +11 -4
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
- package/dist/ui/approval_card.d.ts +18 -0
- package/dist/ui/approval_card.d.ts.map +1 -1
- package/dist/ui/checkpoint_menu.d.ts +10 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +16 -0
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/message_actions.d.ts +46 -0
- package/dist/ui/message_actions.d.ts.map +1 -0
- package/dist/ui/page_quote_offer.d.ts +33 -0
- package/dist/ui/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/quote_selection.d.ts +66 -0
- package/dist/ui/quote_selection.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +10 -0
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/render_markdown.d.ts +23 -5
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +5 -1
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/stick_to_bottom.d.ts +55 -0
- package/dist/ui/stick_to_bottom.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/suggestion_chips.d.ts +29 -0
- package/dist/ui/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts +10 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +8 -0
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +53 -7
- 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 +75 -0
- package/src/core/ag_ui_chat.ts +1357 -113
- package/src/core/agui_client.ts +81 -1
- package/src/core/conversation_store.ts +128 -42
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +35 -2
- package/src/core/utils.ts +58 -0
- package/src/index.ts +39 -0
- package/src/tools/is_destructive.ts +8 -2
- package/src/tools/parse_tool_catalog.ts +18 -6
- package/src/ui/approval_card.ts +90 -2
- package/src/ui/checkpoint_menu.ts +22 -5
- package/src/ui/confirmation_card.ts +29 -1
- package/src/ui/message_actions.ts +158 -0
- package/src/ui/page_quote_offer.ts +215 -0
- package/src/ui/quote_selection.ts +345 -0
- package/src/ui/relative_time.ts +11 -0
- package/src/ui/render_markdown.ts +111 -21
- package/src/ui/resize_handle.ts +32 -2
- package/src/ui/stick_to_bottom.ts +126 -0
- package/src/ui/styles.ts +227 -0
- package/src/ui/suggestion_chips.ts +73 -0
- package/src/ui/thread_drawer.ts +22 -2
- package/src/ui/tool_call_card.ts +9 -0
- package/src/ui/ui_strings.ts +79 -8
- package/src/ui/voice_input.ts +43 -0
- package/src/version.ts +1 -1
package/src/ui/approval_card.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface ApprovalRequest {
|
|
|
10
10
|
message?: string;
|
|
11
11
|
/** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
|
|
12
12
|
toolName?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The call's arguments, shown for editing when {@link ApprovalOptions.onEdit}
|
|
15
|
+
* is set. Omitted when the interrupt names no call whose arguments are known.
|
|
16
|
+
*/
|
|
17
|
+
args?: Record<string, unknown>;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/** Build a labelled action button. */
|
|
@@ -31,6 +36,19 @@ export interface ApprovalOptions {
|
|
|
31
36
|
signal?: AbortSignal;
|
|
32
37
|
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
33
38
|
strings?: UiStrings;
|
|
39
|
+
/**
|
|
40
|
+
* Offer the call's arguments for editing, and receive what the user approved.
|
|
41
|
+
*
|
|
42
|
+
* Called **only** on approval, and only when the text parses and differs from
|
|
43
|
+
* what was proposed -- an untouched call resolves as a plain approval, so a
|
|
44
|
+
* server sees `editedArgs` exactly when something was actually edited.
|
|
45
|
+
*
|
|
46
|
+
* Absent means no editor, which is deliberate: AG-UI gates this on the
|
|
47
|
+
* agent's own `approveWithEdits` capability, and a card that let a user
|
|
48
|
+
* rewrite arguments a server will discard is worse than one that does not
|
|
49
|
+
* offer to.
|
|
50
|
+
*/
|
|
51
|
+
onEdit?: (args: Record<string, unknown>) => void;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
/**
|
|
@@ -78,6 +96,8 @@ export function requestApproval(
|
|
|
78
96
|
body.setAttribute("part", "approval-body");
|
|
79
97
|
body.textContent = request.message ?? strings.approvalPrompt;
|
|
80
98
|
|
|
99
|
+
const editor = buildEditor(request, options, strings);
|
|
100
|
+
|
|
81
101
|
const actions = document.createElement("div");
|
|
82
102
|
actions.className = "approval-actions";
|
|
83
103
|
actions.setAttribute("part", "approval-actions");
|
|
@@ -98,11 +118,19 @@ export function requestApproval(
|
|
|
98
118
|
};
|
|
99
119
|
|
|
100
120
|
deny.addEventListener("click", () => close(false));
|
|
101
|
-
approve.addEventListener("click", () =>
|
|
121
|
+
approve.addEventListener("click", () => {
|
|
122
|
+
if (editor !== null && !editor.commit()) {
|
|
123
|
+
// Unparseable JSON: say so on the card and stay open. Approving what
|
|
124
|
+
// the user did not write -- the original arguments -- would be the one
|
|
125
|
+
// outcome they cannot see coming.
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
close(true);
|
|
129
|
+
});
|
|
102
130
|
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
103
131
|
|
|
104
132
|
actions.append(deny, approve);
|
|
105
|
-
card.append(body, actions);
|
|
133
|
+
card.append(body, ...(editor === null ? [] : [editor.root]), actions);
|
|
106
134
|
host.appendChild(card);
|
|
107
135
|
if (options.signal?.aborted === true) {
|
|
108
136
|
// The run was cancelled before the card could ask; record the denial.
|
|
@@ -112,3 +140,63 @@ export function requestApproval(
|
|
|
112
140
|
approve.focus();
|
|
113
141
|
});
|
|
114
142
|
}
|
|
143
|
+
|
|
144
|
+
/** The editable-arguments region, or `null` when this card does not offer one. */
|
|
145
|
+
function buildEditor(
|
|
146
|
+
request: ApprovalRequest,
|
|
147
|
+
options: ApprovalOptions,
|
|
148
|
+
strings: UiStrings,
|
|
149
|
+
): { root: HTMLElement; commit: () => boolean } | null {
|
|
150
|
+
const { onEdit } = options;
|
|
151
|
+
if (onEdit === undefined || request.args === undefined) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const original = JSON.stringify(request.args, null, 2);
|
|
155
|
+
const root = document.createElement("div");
|
|
156
|
+
root.className = "approval-edit";
|
|
157
|
+
root.setAttribute("part", "approval-edit");
|
|
158
|
+
|
|
159
|
+
const field = document.createElement("textarea");
|
|
160
|
+
field.className = "approval-args";
|
|
161
|
+
field.setAttribute("part", "approval-args");
|
|
162
|
+
field.setAttribute("aria-label", strings.approvalEditArgs);
|
|
163
|
+
field.rows = Math.min(10, original.split("\n").length);
|
|
164
|
+
field.value = original;
|
|
165
|
+
|
|
166
|
+
const error = document.createElement("div");
|
|
167
|
+
error.className = "approval-error";
|
|
168
|
+
error.setAttribute("part", "approval-error");
|
|
169
|
+
// A live region: it appears in response to pressing Approve, and a message
|
|
170
|
+
// that only exists visually leaves a screen-reader user with a button that
|
|
171
|
+
// silently did nothing.
|
|
172
|
+
error.setAttribute("role", "alert");
|
|
173
|
+
error.hidden = true;
|
|
174
|
+
|
|
175
|
+
root.append(field, error);
|
|
176
|
+
return {
|
|
177
|
+
root,
|
|
178
|
+
commit: () => {
|
|
179
|
+
if (field.value === original) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
let parsed: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(field.value);
|
|
185
|
+
} catch {
|
|
186
|
+
error.textContent = strings.approvalArgsInvalid;
|
|
187
|
+
error.hidden = false;
|
|
188
|
+
field.focus();
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
192
|
+
error.textContent = strings.approvalArgsNotAnObject;
|
|
193
|
+
error.hidden = false;
|
|
194
|
+
field.focus();
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
error.hidden = true;
|
|
198
|
+
onEdit(parsed as Record<string, unknown>);
|
|
199
|
+
return true;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RunRow } from "../core/run_index.js";
|
|
2
|
-
import { relativeTime } from "./relative_time.js";
|
|
2
|
+
import { type RelativeTimeFormatter, relativeTime } from "./relative_time.js";
|
|
3
3
|
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
4
4
|
|
|
5
5
|
/** How the host continues a picked run. */
|
|
@@ -46,6 +46,7 @@ export class CheckpointMenu {
|
|
|
46
46
|
readonly #heading: HTMLSpanElement;
|
|
47
47
|
/** What had focus before the panel opened, restored on close. */
|
|
48
48
|
#lastFocused: HTMLElement | null = null;
|
|
49
|
+
#formatRelativeTime: RelativeTimeFormatter | null = null;
|
|
49
50
|
#strings: UiStrings;
|
|
50
51
|
#runs: readonly RunRow[] = [];
|
|
51
52
|
|
|
@@ -91,6 +92,25 @@ export class CheckpointMenu {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Re-localize a panel built before the host's strings resolved. */
|
|
95
|
+
/**
|
|
96
|
+
* Replace the timestamp formatter, or restore the built-in with `null`.
|
|
97
|
+
*
|
|
98
|
+
* The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
|
|
99
|
+
* in this component, so it never disagrees with a host's own formatting by
|
|
100
|
+
* guessing a locale. That is a defensible default and a poor requirement, so
|
|
101
|
+
* this is the way out.
|
|
102
|
+
*/
|
|
103
|
+
setRelativeTimeFormatter(format: RelativeTimeFormatter | null): void {
|
|
104
|
+
this.#formatRelativeTime = format;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** This row's timestamp, through the host's formatter when it set one. */
|
|
108
|
+
#formatTime(timestamp: number): string {
|
|
109
|
+
return this.#formatRelativeTime !== null
|
|
110
|
+
? this.#formatRelativeTime(timestamp)
|
|
111
|
+
: relativeTime(timestamp, Date.now(), this.#strings);
|
|
112
|
+
}
|
|
113
|
+
|
|
94
114
|
setStrings(strings: UiStrings): void {
|
|
95
115
|
this.#strings = strings;
|
|
96
116
|
this.element.setAttribute("aria-label", strings.checkpoints);
|
|
@@ -207,10 +227,7 @@ export class CheckpointMenu {
|
|
|
207
227
|
row.setAttribute("part", "checkpoint-row");
|
|
208
228
|
|
|
209
229
|
const preview = previewOf(run);
|
|
210
|
-
const time =
|
|
211
|
-
run.started_at === null
|
|
212
|
-
? null
|
|
213
|
-
: relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
|
|
230
|
+
const time = run.started_at === null ? null : this.#formatTime(Date.parse(run.started_at));
|
|
214
231
|
|
|
215
232
|
const label = document.createElement("span");
|
|
216
233
|
label.className = "checkpoint-label";
|
|
@@ -31,6 +31,16 @@ export interface ConfirmationOptions {
|
|
|
31
31
|
signal?: AbortSignal;
|
|
32
32
|
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
33
33
|
strings?: UiStrings;
|
|
34
|
+
/**
|
|
35
|
+
* Offer a third button — "always allow, this session" — and call this when
|
|
36
|
+
* the user picks it. The card still resolves `true`: the extra decision is
|
|
37
|
+
* *in addition to* approving this call, not instead of it.
|
|
38
|
+
*
|
|
39
|
+
* Absent means no button, which is deliberate: presence of the handler is
|
|
40
|
+
* what enables it, so the affordance can never be rendered with nothing
|
|
41
|
+
* listening. A caller that cannot honour the waiver simply does not pass one.
|
|
42
|
+
*/
|
|
43
|
+
onAlwaysAllow?: () => void;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -43,6 +53,12 @@ export interface ConfirmationOptions {
|
|
|
43
53
|
* Answering it removes it: the record of the decision belongs to the tool card
|
|
44
54
|
* this gates, which settles to `done` or `declined` and carries it. A spent
|
|
45
55
|
* form left in place reads as still outstanding.
|
|
56
|
+
*
|
|
57
|
+
* With `onAlwaysAllow` set the card offers a third button. A prompt that is
|
|
58
|
+
* approved nearly every time is not a decision, it is a speed bump — and the
|
|
59
|
+
* reflex it trains is what makes the rare refusal easy to miss. Letting the
|
|
60
|
+
* user say "not this one again, this session" is the affordance that keeps the
|
|
61
|
+
* remaining prompts meaningful.
|
|
46
62
|
*/
|
|
47
63
|
export function requestConfirmation(
|
|
48
64
|
host: Node & ParentNode,
|
|
@@ -75,6 +91,10 @@ export function requestConfirmation(
|
|
|
75
91
|
actions.setAttribute("part", "confirm-actions");
|
|
76
92
|
|
|
77
93
|
const cancel = actionButton("cancel", strings.cancel);
|
|
94
|
+
const always =
|
|
95
|
+
options.onAlwaysAllow === undefined
|
|
96
|
+
? null
|
|
97
|
+
: actionButton("always", strings.confirmAlways.replace("{tool}", request.toolName));
|
|
78
98
|
const confirm = actionButton("confirm", strings.confirm);
|
|
79
99
|
|
|
80
100
|
let settled = false;
|
|
@@ -93,9 +113,17 @@ export function requestConfirmation(
|
|
|
93
113
|
|
|
94
114
|
cancel.addEventListener("click", () => close(false));
|
|
95
115
|
confirm.addEventListener("click", () => close(true));
|
|
116
|
+
always?.addEventListener("click", () => {
|
|
117
|
+
// Recorded before resolving, so a caller that reads its own allowlist
|
|
118
|
+
// synchronously on the next call already sees this one.
|
|
119
|
+
options.onAlwaysAllow?.();
|
|
120
|
+
close(true);
|
|
121
|
+
});
|
|
96
122
|
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
// Confirm stays last, and rightmost: the waiver is the wider decision, and
|
|
125
|
+
// putting it where the eye lands for "yes" is how it gets taken by accident.
|
|
126
|
+
actions.append(cancel, ...(always === null ? [] : [always]), confirm);
|
|
99
127
|
card.append(body, args, actions);
|
|
100
128
|
host.appendChild(card);
|
|
101
129
|
if (options.signal?.aborted === true) {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/** How long a button shows its confirmation before reverting. */
|
|
4
|
+
const CONFIRM_MS = 1500;
|
|
5
|
+
|
|
6
|
+
/** What an action bar can do, beyond copying. */
|
|
7
|
+
export interface MessageActionsOptions {
|
|
8
|
+
/** Localized strings. */
|
|
9
|
+
strings: UiStrings;
|
|
10
|
+
/**
|
|
11
|
+
* The text Copy puts on the clipboard. A function rather than a string
|
|
12
|
+
* because a bubble's content is rewritten while it streams, and the bar is
|
|
13
|
+
* attached to the element rather than to a snapshot of it.
|
|
14
|
+
*/
|
|
15
|
+
text: () => string;
|
|
16
|
+
/**
|
|
17
|
+
* Report a rating for this message. Absent means no feedback buttons.
|
|
18
|
+
*
|
|
19
|
+
* The component stores nothing: a rating is the host's to keep, and a
|
|
20
|
+
* write-only table nobody reads is not worth a schema.
|
|
21
|
+
*/
|
|
22
|
+
onFeedback?: (rating: "up" | "down") => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Give one finished message bubble its row of actions.
|
|
27
|
+
*
|
|
28
|
+
* **Finished** is load-bearing. A streaming bubble reassigns its `innerHTML` on
|
|
29
|
+
* every delta, so anything attached mid-stream is discarded and rebuilt for
|
|
30
|
+
* each one -- the same constraint `attachCopyButtons` records, one level up.
|
|
31
|
+
*
|
|
32
|
+
* Retry is deliberately **not** here: it belongs to the last turn only, so the
|
|
33
|
+
* element owns it and moves it as the transcript grows. Everything on this bar
|
|
34
|
+
* is safe to offer on any message, however old.
|
|
35
|
+
*
|
|
36
|
+
* Idempotent -- a bubble already given a bar is skipped, so a re-render or a
|
|
37
|
+
* second call cannot stack rows.
|
|
38
|
+
*/
|
|
39
|
+
export function attachMessageActions(bubble: HTMLElement, options: MessageActionsOptions): void {
|
|
40
|
+
if (existingBar(bubble) !== null) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const bar = messageActionBar(bubble, options.strings);
|
|
44
|
+
bar.appendChild(copyButton(options));
|
|
45
|
+
if (options.onFeedback !== undefined) {
|
|
46
|
+
bar.append(
|
|
47
|
+
feedbackButton("up", options.strings.feedbackUp, options.onFeedback),
|
|
48
|
+
feedbackButton("down", options.strings.feedbackDown, options.onFeedback),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The empty action row on `bubble`, created if it has none yet.
|
|
55
|
+
*
|
|
56
|
+
* Shared so a bubble that wants *only* Retry -- a failed run, which has nothing
|
|
57
|
+
* worth copying and nothing to rate -- gets the same row, the same part name
|
|
58
|
+
* and the same accessible grouping as every other message, rather than a
|
|
59
|
+
* second thing that looks like one.
|
|
60
|
+
*/
|
|
61
|
+
export function messageActionBar(bubble: HTMLElement, strings: UiStrings): HTMLElement {
|
|
62
|
+
const existing = existingBar(bubble);
|
|
63
|
+
if (existing !== null) {
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
const bar = document.createElement("div");
|
|
67
|
+
bar.className = "message-actions";
|
|
68
|
+
bar.setAttribute("part", "message-actions");
|
|
69
|
+
// A group rather than a toolbar: these are independent actions on the message
|
|
70
|
+
// above, not a set the user arrows between.
|
|
71
|
+
bar.setAttribute("role", "group");
|
|
72
|
+
bar.setAttribute("aria-label", strings.messageActions);
|
|
73
|
+
// A **sibling**, never a child. Inside the bubble the buttons join its
|
|
74
|
+
// `textContent`, which is what Copy reads, what history persists and what
|
|
75
|
+
// every existing assertion about a message's text compares against -- so an
|
|
76
|
+
// answer would be copied back with the glyphs of the buttons that copied it.
|
|
77
|
+
// The bubble must therefore already be in the tree when this is called.
|
|
78
|
+
bubble.after(bar);
|
|
79
|
+
return bar;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The action row belonging to `bubble`, if it has one. */
|
|
83
|
+
function existingBar(bubble: HTMLElement): HTMLElement | null {
|
|
84
|
+
const next = bubble.nextElementSibling;
|
|
85
|
+
return next?.classList.contains("message-actions") === true ? (next as HTMLElement) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build one action button, labelled for screen readers rather than by glyph. */
|
|
89
|
+
export function messageActionButton(
|
|
90
|
+
modifier: string,
|
|
91
|
+
label: string,
|
|
92
|
+
glyph: string,
|
|
93
|
+
): HTMLButtonElement {
|
|
94
|
+
const button = document.createElement("button");
|
|
95
|
+
button.type = "button";
|
|
96
|
+
button.className = `message-action message-action--${modifier}`;
|
|
97
|
+
button.setAttribute("part", `message-action message-action-${modifier}`);
|
|
98
|
+
button.title = label;
|
|
99
|
+
button.setAttribute("aria-label", label);
|
|
100
|
+
const icon = document.createElement("span");
|
|
101
|
+
icon.setAttribute("aria-hidden", "true");
|
|
102
|
+
icon.textContent = glyph;
|
|
103
|
+
button.appendChild(icon);
|
|
104
|
+
return button;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function copyButton(options: MessageActionsOptions): HTMLButtonElement {
|
|
108
|
+
const { strings } = options;
|
|
109
|
+
const button = messageActionButton("copy", strings.copyMessage, "⎘");
|
|
110
|
+
button.addEventListener("click", () => {
|
|
111
|
+
void navigator.clipboard.writeText(options.text()).then(
|
|
112
|
+
() => flash(button, strings.copied, strings.copyMessage),
|
|
113
|
+
// A denied clipboard permission is the common case, not an exception:
|
|
114
|
+
// say so on the button rather than throwing into an unhandled rejection.
|
|
115
|
+
() => flash(button, strings.copyFailed, strings.copyMessage),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
return button;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function feedbackButton(
|
|
122
|
+
rating: "up" | "down",
|
|
123
|
+
label: string,
|
|
124
|
+
report: (rating: "up" | "down") => void,
|
|
125
|
+
): HTMLButtonElement {
|
|
126
|
+
const button = messageActionButton(
|
|
127
|
+
rating === "up" ? "up" : "down",
|
|
128
|
+
label,
|
|
129
|
+
rating === "up" ? "\u{1F44D}" : "\u{1F44E}",
|
|
130
|
+
);
|
|
131
|
+
button.addEventListener("click", () => {
|
|
132
|
+
// Pressed rather than removed: the rating is a standing statement about the
|
|
133
|
+
// message, and a button that vanishes leaves no record of what was said.
|
|
134
|
+
const pressed = button.getAttribute("aria-pressed") === "true";
|
|
135
|
+
button.setAttribute("aria-pressed", pressed ? "false" : "true");
|
|
136
|
+
report(rating);
|
|
137
|
+
});
|
|
138
|
+
button.setAttribute("aria-pressed", "false");
|
|
139
|
+
return button;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Flash `message` on the button, then restore `label`.
|
|
144
|
+
*
|
|
145
|
+
* The label to restore is passed rather than read back off the element: the
|
|
146
|
+
* caller is the one that set it, and reading it would introduce a null arm that
|
|
147
|
+
* cannot happen and cannot be covered.
|
|
148
|
+
*/
|
|
149
|
+
function flash(button: HTMLButtonElement, message: string, label: string): void {
|
|
150
|
+
button.title = message;
|
|
151
|
+
button.setAttribute("aria-label", message);
|
|
152
|
+
button.classList.add("message-action--confirmed");
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
button.title = label;
|
|
155
|
+
button.setAttribute("aria-label", label);
|
|
156
|
+
button.classList.remove("message-action--confirmed");
|
|
157
|
+
}, CONFIRM_MS);
|
|
158
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { quotableSelection } from "./quote_selection.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The same select-then-offer gesture the transcript has, in the **host page**.
|
|
5
|
+
*
|
|
6
|
+
* This exists because the recipe version of it is a trap, and a specific one.
|
|
7
|
+
* Chrome reports the internal selection of an `<input>` or `<textarea>` through
|
|
8
|
+
* `document.getSelection()` as an ordinary `Range` whose endpoints are the
|
|
9
|
+
* field's *wrapper* -- not the field. So the text reads back perfectly and the
|
|
10
|
+
* range is indistinguishable from a selection over the surrounding prose: a
|
|
11
|
+
* host listening for a page selection quotes the user's own half-typed form
|
|
12
|
+
* field back at them, and nothing about the selection says why. The only signal
|
|
13
|
+
* is `document.activeElement`, which is not where anyone looks.
|
|
14
|
+
*
|
|
15
|
+
* That, plus "do not fire for the widget's own transcript, which already offers
|
|
16
|
+
* this", plus "a fixed-position affordance strands itself on the first scroll",
|
|
17
|
+
* is three non-obvious guards. Three guards is a component feature, not a
|
|
18
|
+
* documentation snippet.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Pixels between a selection and the offer to quote it. */
|
|
22
|
+
const GAP = 6;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The offer's own appearance.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately plain and deliberately overridable: this element lands in the
|
|
28
|
+
* host's page, not in our shadow tree, so it has no theme to inherit and no
|
|
29
|
+
* business imposing one. Everything here is a single class a host stylesheet
|
|
30
|
+
* outranks by adding one more selector.
|
|
31
|
+
*/
|
|
32
|
+
const OFFER_CSS = `
|
|
33
|
+
.ag-ui-quote-offer {
|
|
34
|
+
position: fixed;
|
|
35
|
+
z-index: 2147483000;
|
|
36
|
+
transform: translate(-50%, -100%);
|
|
37
|
+
margin: 0;
|
|
38
|
+
padding: 0.25em 0.7em;
|
|
39
|
+
border: 1px solid rgb(0 0 0 / 0.15);
|
|
40
|
+
border-radius: 999px;
|
|
41
|
+
background: Canvas;
|
|
42
|
+
color: CanvasText;
|
|
43
|
+
font: inherit;
|
|
44
|
+
font-size: 0.8rem;
|
|
45
|
+
line-height: 1.6;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ag-ui-quote-offer[data-below="true"] {
|
|
52
|
+
transform: translate(-50%, 0);
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
/** A live page-side offer. */
|
|
57
|
+
export interface PageQuoteOffer {
|
|
58
|
+
/** The button itself, for a host that wants to style or inspect it. */
|
|
59
|
+
readonly element: HTMLButtonElement;
|
|
60
|
+
/** Stop offering: every listener removed, the button and its styles gone. */
|
|
61
|
+
detach(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What {@link attachQuoteOffer} needs to know. */
|
|
65
|
+
export interface PageQuoteOfferOptions {
|
|
66
|
+
/** Where a selection is worth offering to quote. */
|
|
67
|
+
within: HTMLElement;
|
|
68
|
+
/** What the offer says. */
|
|
69
|
+
label: string;
|
|
70
|
+
/**
|
|
71
|
+
* A subtree to stay out of -- the chat widget itself.
|
|
72
|
+
*
|
|
73
|
+
* Its transcript runs this same gesture on the inside, so without this a
|
|
74
|
+
* selection there would be offered twice and quoted twice.
|
|
75
|
+
*/
|
|
76
|
+
exclude: Node;
|
|
77
|
+
/** Take the offer. */
|
|
78
|
+
onQuote: (text: string) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Offer to quote what the user selects inside `within`.
|
|
83
|
+
*
|
|
84
|
+
* Nothing is quoted until the offer is taken -- which is the whole point. An
|
|
85
|
+
* automatic version of this is easy to write and horrible to use: every drag
|
|
86
|
+
* made to read, to copy, or to fix a typo silently appends to whatever the user
|
|
87
|
+
* was in the middle of typing.
|
|
88
|
+
*/
|
|
89
|
+
export function attachQuoteOffer(options: PageQuoteOfferOptions): PageQuoteOffer {
|
|
90
|
+
const { within, exclude, onQuote } = options;
|
|
91
|
+
|
|
92
|
+
// A constructed sheet rather than an injected `<style>`: a host with a strict
|
|
93
|
+
// `style-src` drops the second one silently, leaving an unstyled pill in the
|
|
94
|
+
// middle of their page. Per attachment rather than at module scope, which
|
|
95
|
+
// this package forbids.
|
|
96
|
+
const sheet = new CSSStyleSheet();
|
|
97
|
+
sheet.replaceSync(OFFER_CSS);
|
|
98
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
99
|
+
|
|
100
|
+
const button = document.createElement("button");
|
|
101
|
+
button.type = "button";
|
|
102
|
+
button.className = "ag-ui-quote-offer";
|
|
103
|
+
button.textContent = options.label;
|
|
104
|
+
button.hidden = true;
|
|
105
|
+
document.body.append(button);
|
|
106
|
+
|
|
107
|
+
let quoting = "";
|
|
108
|
+
|
|
109
|
+
const hide = (): void => {
|
|
110
|
+
button.hidden = true;
|
|
111
|
+
quoting = "";
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const settled = (event: Event): void => {
|
|
115
|
+
// The widget's own gesture, tested on the **event path** rather than on the
|
|
116
|
+
// selection. The path crosses shadow boundaries and the selection does not:
|
|
117
|
+
// `Node.contains` is false for a node in a shadow tree, and the shadow-aware
|
|
118
|
+
// read, given no roots, hands back endpoints rescoped up into the page --
|
|
119
|
+
// so both selection-side tests would let a transcript drag through here and
|
|
120
|
+
// quote it a second time.
|
|
121
|
+
if (event.composedPath().includes(exclude)) {
|
|
122
|
+
hide();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (fieldHasFocus()) {
|
|
126
|
+
// The user is selecting inside their own form, to edit or to copy. See
|
|
127
|
+
// the module comment: nothing about the range itself says so.
|
|
128
|
+
hide();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// The pointer's own coordinates, where there was one: they decide which
|
|
132
|
+
// line of a multi-line selection the offer hangs from.
|
|
133
|
+
const near = event instanceof MouseEvent ? { x: event.clientX, y: event.clientY } : undefined;
|
|
134
|
+
const selected = quotableSelection(within, [], near);
|
|
135
|
+
if (selected === null) {
|
|
136
|
+
hide();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
quoting = selected.text;
|
|
140
|
+
place(button, selected.rect);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const onMouseDown = (event: MouseEvent): void => {
|
|
144
|
+
if (!button.contains(event.target as Node)) {
|
|
145
|
+
hide();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
within.addEventListener("mouseup", settled);
|
|
150
|
+
within.addEventListener("keyup", settled);
|
|
151
|
+
within.addEventListener("mousedown", onMouseDown);
|
|
152
|
+
// Capture, so a scrolling pane counts and not only the window: the offer is
|
|
153
|
+
// positioned in viewport coordinates, so anything that moves the words out
|
|
154
|
+
// from under it leaves it pointing at the wrong thing.
|
|
155
|
+
document.addEventListener("scroll", hide, true);
|
|
156
|
+
window.addEventListener("resize", hide);
|
|
157
|
+
|
|
158
|
+
// Without this the press collapses the selection before the click reads it.
|
|
159
|
+
button.addEventListener("mousedown", (event) => {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
});
|
|
162
|
+
button.addEventListener("click", () => {
|
|
163
|
+
const text = quoting;
|
|
164
|
+
window.getSelection()?.removeAllRanges();
|
|
165
|
+
hide();
|
|
166
|
+
onQuote(text);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
element: button,
|
|
171
|
+
detach(): void {
|
|
172
|
+
within.removeEventListener("mouseup", settled);
|
|
173
|
+
within.removeEventListener("keyup", settled);
|
|
174
|
+
within.removeEventListener("mousedown", onMouseDown);
|
|
175
|
+
document.removeEventListener("scroll", hide, true);
|
|
176
|
+
window.removeEventListener("resize", hide);
|
|
177
|
+
button.remove();
|
|
178
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((each) => each !== sheet);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether focus is in something the user types into.
|
|
185
|
+
*
|
|
186
|
+
* `activeElement` rather than the selection, because the selection does not
|
|
187
|
+
* say. A focused shadow host reports as the host, which is why the widget's own
|
|
188
|
+
* composer is caught by the `exclude` subtree test instead -- the two guards
|
|
189
|
+
* each cover the other's blind spot.
|
|
190
|
+
*/
|
|
191
|
+
function fieldHasFocus(): boolean {
|
|
192
|
+
const active = document.activeElement;
|
|
193
|
+
if (active === null) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return (
|
|
197
|
+
active.tagName === "INPUT" ||
|
|
198
|
+
active.tagName === "TEXTAREA" ||
|
|
199
|
+
(active as HTMLElement).isContentEditable === true
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Float the offer beside `rect`, kept inside the viewport. */
|
|
204
|
+
function place(button: HTMLButtonElement, rect: DOMRect): void {
|
|
205
|
+
// Unhidden first: a hidden element measures zero, and its own size decides
|
|
206
|
+
// both whether it fits above the selection and how far to pull it left.
|
|
207
|
+
button.hidden = false;
|
|
208
|
+
const below = rect.top < GAP + button.offsetHeight;
|
|
209
|
+
button.dataset["below"] = String(below);
|
|
210
|
+
button.style.top = `${below ? rect.bottom + GAP : rect.top - GAP}px`;
|
|
211
|
+
const half = button.offsetWidth / 2;
|
|
212
|
+
const centre = rect.left + rect.width / 2;
|
|
213
|
+
const width = document.documentElement.clientWidth;
|
|
214
|
+
button.style.left = `${Math.min(Math.max(centre, half), width - half)}px`;
|
|
215
|
+
}
|