@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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a text selection out of the shadow tree, and shaping it for the
|
|
3
|
+
* composer.
|
|
4
|
+
*
|
|
5
|
+
* Selection is the one DOM API shadow encapsulation genuinely broke, and it
|
|
6
|
+
* broke it in two different directions. WebKit rescopes a selection made inside
|
|
7
|
+
* a shadow tree to the **host** element, so `document.getSelection()` reports
|
|
8
|
+
* the whole widget and none of the words; Chromium exposes the shadow nodes
|
|
9
|
+
* directly, so the same call reports exactly the words. `getComposedRanges` is
|
|
10
|
+
* the settled answer to both -- it hands back real endpoints for each shadow
|
|
11
|
+
* root it is *given permission to see* -- but it is recent enough that the
|
|
12
|
+
* direct read has to stay behind it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The longest quotation put into the composer, in characters.
|
|
17
|
+
*
|
|
18
|
+
* A cap rather than no cap because the point of a quotation is to say *which*
|
|
19
|
+
* part of an answer the next question is about. Select-all-then-quote is a
|
|
20
|
+
* gesture the transcript already answers -- it is the whole conversation, which
|
|
21
|
+
* the model has -- and pasting it back costs the user tokens to say nothing.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_QUOTE_CHARS = 500;
|
|
24
|
+
|
|
25
|
+
/** A selection worth offering to quote. */
|
|
26
|
+
export interface QuotableSelection {
|
|
27
|
+
/** The selected text, trimmed. Never empty. */
|
|
28
|
+
readonly text: string;
|
|
29
|
+
/**
|
|
30
|
+
* One **line** of the selection, for placing the affordance beside it.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately not the selection's bounding box. See {@link lineToHangFrom}:
|
|
33
|
+
* the union of a selection spanning several elements has a centre with no
|
|
34
|
+
* selected text anywhere near it.
|
|
35
|
+
*/
|
|
36
|
+
readonly rect: DOMRect;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where a gesture ended, in viewport coordinates. */
|
|
40
|
+
export interface Point {
|
|
41
|
+
readonly x: number;
|
|
42
|
+
readonly y: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A `Selection` on an engine new enough to reach into a shadow tree. */
|
|
46
|
+
type ComposedSelection = Selection & {
|
|
47
|
+
getComposedRanges?: (...args: readonly unknown[]) => readonly AbstractRange[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The current selection, when it lies wholly inside `container`.
|
|
52
|
+
*
|
|
53
|
+
* `roots` are the shadow roots the read is allowed to look inside, in the shape
|
|
54
|
+
* `getComposedRanges` itself takes. Pass the one holding `container` for a
|
|
55
|
+
* selection made in a shadow tree, or nothing at all for one made in the page.
|
|
56
|
+
*
|
|
57
|
+
* `near` is where the gesture ended, when a pointer made it -- it decides which
|
|
58
|
+
* line of a multi-line selection the offer is hung from.
|
|
59
|
+
*
|
|
60
|
+
* `null` for no selection, a collapsed one, whitespace only, or one that
|
|
61
|
+
* starts or ends outside `container` -- a drag that ran off the transcript and
|
|
62
|
+
* into the page is not a quotation from the transcript.
|
|
63
|
+
*/
|
|
64
|
+
export function quotableSelection(
|
|
65
|
+
container: HTMLElement,
|
|
66
|
+
roots: readonly ShadowRoot[] = [],
|
|
67
|
+
near?: Point,
|
|
68
|
+
): QuotableSelection | null {
|
|
69
|
+
for (const endpoints of endpointCandidates(roots)) {
|
|
70
|
+
if (!container.contains(endpoints.startContainer)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!container.contains(endpoints.endContainer)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// A live `Range` regardless of what the engine handed back: `getComposedRanges`
|
|
77
|
+
// returns `StaticRange`s, which carry endpoints and nothing else -- no text,
|
|
78
|
+
// no geometry. Both are what this is for.
|
|
79
|
+
const range = document.createRange();
|
|
80
|
+
range.setStart(endpoints.startContainer, endpoints.startOffset);
|
|
81
|
+
range.setEnd(endpoints.endContainer, endpoints.endOffset);
|
|
82
|
+
const text = renderedText(range).trim();
|
|
83
|
+
if (text === "") {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
return { text, rect: lineToHangFrom(range, near) };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Wrap `text` as a markdown blockquote, ready to be followed by a question.
|
|
93
|
+
*
|
|
94
|
+
* Markdown rather than a bespoke fence because the transcript renders markdown
|
|
95
|
+
* and the server reads markdown: a quotation that survives both ends without a
|
|
96
|
+
* convention to agree on first. The trailing blank line is what leaves the
|
|
97
|
+
* caret on a fresh paragraph, which is the whole point of quoting into a
|
|
98
|
+
* composer rather than sending straight away.
|
|
99
|
+
*/
|
|
100
|
+
export function asQuote(text: string): string {
|
|
101
|
+
const lines = tidy(text);
|
|
102
|
+
if (lines.length === 0) {
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
const capped = cap(lines.join("\n"));
|
|
106
|
+
const quoted = capped
|
|
107
|
+
.split("\n")
|
|
108
|
+
// `trimEnd` so a blank line inside the selection becomes a bare ">" rather
|
|
109
|
+
// than "> " -- trailing whitespace markdown treats as a line break.
|
|
110
|
+
.map((line) => `> ${line}`.trimEnd())
|
|
111
|
+
.join("\n");
|
|
112
|
+
return `${quoted}\n\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The selection's lines, with the *markup's* whitespace taken back out.
|
|
117
|
+
*
|
|
118
|
+
* A selection crossing block elements carries the source's own layout with it:
|
|
119
|
+
* every newline and every run of indentation between one element and the next
|
|
120
|
+
* is a text node like any other. Quoting a form as marked up produced twenty-
|
|
121
|
+
* four lines of which twelve were a bare ">", the rest indented by wherever
|
|
122
|
+
* they happened to sit in the HTML. That is not a quotation of anything.
|
|
123
|
+
*
|
|
124
|
+
* Two rules, and deliberately only two. Runs of blank lines collapse to one,
|
|
125
|
+
* because the gap between two blocks is one gap however it was written. And the
|
|
126
|
+
* indentation every line shares is removed, not each line's own -- a quotation
|
|
127
|
+
* from a code block keeps its shape, which trimming each line would flatten.
|
|
128
|
+
*/
|
|
129
|
+
function tidy(text: string): readonly string[] {
|
|
130
|
+
const lines = text.split(/\r\n?|\n/).map((line) => line.trimEnd());
|
|
131
|
+
const indents = lines.filter((line) => line !== "").map(indentOf);
|
|
132
|
+
const shared = indents.length === 0 ? 0 : Math.min(...indents);
|
|
133
|
+
const kept: string[] = [];
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const dedented = line.slice(shared);
|
|
136
|
+
// No leading blank lines, and never two in a row.
|
|
137
|
+
if (dedented === "" && (kept.length === 0 || kept[kept.length - 1] === "")) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
kept.push(dedented);
|
|
141
|
+
}
|
|
142
|
+
while (kept[kept.length - 1] === "") {
|
|
143
|
+
kept.pop();
|
|
144
|
+
}
|
|
145
|
+
return kept;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** How many spaces of indentation `line` opens with. */
|
|
149
|
+
function indentOf(line: string): number {
|
|
150
|
+
return line.length - line.trimStart().length;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `text`, cut to {@link MAX_QUOTE_CHARS} if it runs past it. */
|
|
154
|
+
function cap(text: string): string {
|
|
155
|
+
return text.length > MAX_QUOTE_CHARS ? `${text.slice(0, MAX_QUOTE_CHARS).trimEnd()}...` : text;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The selected text **as rendered**, which is not what `Range.toString()` says.
|
|
160
|
+
*
|
|
161
|
+
* `toString()` concatenates the text nodes in the range and asks no questions
|
|
162
|
+
* about CSS, so a drag across an ordinary form quotes back the values of every
|
|
163
|
+
* `<option>` in a closed `<select>` -- words the user has never seen, presented
|
|
164
|
+
* to the model as something they pointed at. `checkVisibility()` is the
|
|
165
|
+
* platform's own answer to "is this actually rendered", and it reports exactly
|
|
166
|
+
* those options as hidden.
|
|
167
|
+
*
|
|
168
|
+
* Older engines without it keep the previous behaviour rather than a guess:
|
|
169
|
+
* quoting a few invisible words is a smaller failure than dropping visible ones
|
|
170
|
+
* because a hand-rolled visibility test was wrong.
|
|
171
|
+
*/
|
|
172
|
+
function renderedText(range: Range): string {
|
|
173
|
+
let text = "";
|
|
174
|
+
for (const node of textNodesIn(range)) {
|
|
175
|
+
// The element carries the styles; the text node has none of its own.
|
|
176
|
+
const parent = node.parentElement as HTMLElement;
|
|
177
|
+
if (!isRendered(parent)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// The end nodes are only partly inside the range; everything between them
|
|
181
|
+
// is wholly inside it.
|
|
182
|
+
const from = node === range.startContainer ? range.startOffset : 0;
|
|
183
|
+
const to = node === range.endContainer ? range.endOffset : node.data.length;
|
|
184
|
+
text += collapse(node.data.slice(from, to), parent);
|
|
185
|
+
}
|
|
186
|
+
return text;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Squeeze the runs of spaces the renderer squeezes, and keep the ones it keeps.
|
|
191
|
+
*
|
|
192
|
+
* Indentation between block elements is markup, not content: it is in the DOM
|
|
193
|
+
* as text nodes and it is on screen as nothing at all, because a collapsing
|
|
194
|
+
* `white-space` reduces it. Carrying it into a quotation is not merely untidy --
|
|
195
|
+
* four leading spaces inside a blockquote is a markdown **code block**, so a
|
|
196
|
+
* form quoted as marked up renders as source listing.
|
|
197
|
+
*
|
|
198
|
+
* Newlines survive on purpose, where CSS would collapse those too. They are the
|
|
199
|
+
* only record left of where one block ended and the next began, and a quotation
|
|
200
|
+
* of six form rows run together on one line is worse than one that keeps them
|
|
201
|
+
* apart. Preformatted text is passed through untouched: there the indentation
|
|
202
|
+
* *is* the content.
|
|
203
|
+
*/
|
|
204
|
+
function collapse(text: string, parent: HTMLElement): string {
|
|
205
|
+
if (PREFORMATTED.has(whiteSpaceOf(parent))) {
|
|
206
|
+
return text;
|
|
207
|
+
}
|
|
208
|
+
return (
|
|
209
|
+
text
|
|
210
|
+
// Whitespace *around* a newline goes with it. Leaving a space behind
|
|
211
|
+
// would indent every line of the quotation by one, for nothing.
|
|
212
|
+
.replace(/[^\S\n]*\n[^\S\n]*/g, "\n")
|
|
213
|
+
.replace(/[^\S\n]+/g, " ")
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The `white-space` values under which every space is content. */
|
|
218
|
+
const PREFORMATTED = new Set(["pre", "pre-wrap", "break-spaces"]);
|
|
219
|
+
|
|
220
|
+
/** The computed `white-space` of `element`, or `""` where it cannot be read. */
|
|
221
|
+
function whiteSpaceOf(element: HTMLElement): string {
|
|
222
|
+
return window.getComputedStyle(element).whiteSpace;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Every text node the range touches, in document order. */
|
|
226
|
+
function textNodesIn(range: Range): readonly Text[] {
|
|
227
|
+
const root = range.commonAncestorContainer;
|
|
228
|
+
// A selection inside a single text node has that node as its own common
|
|
229
|
+
// ancestor, and a `TreeWalker` never visits its root.
|
|
230
|
+
if (root.nodeType === Node.TEXT_NODE) {
|
|
231
|
+
return [root as Text];
|
|
232
|
+
}
|
|
233
|
+
const found: Text[] = [];
|
|
234
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
235
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
236
|
+
// The walker covers the whole subtree, which reaches past both ends of the
|
|
237
|
+
// range -- a sibling paragraph above the selection is in it too.
|
|
238
|
+
if (range.intersectsNode(node)) {
|
|
239
|
+
found.push(node as Text);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return found;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Whether `element` is actually drawn, where the engine will say. */
|
|
246
|
+
function isRendered(element: HTMLElement): boolean {
|
|
247
|
+
if (typeof element.checkVisibility !== "function") {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
return element.checkVisibility({
|
|
251
|
+
contentVisibilityAuto: true,
|
|
252
|
+
opacityProperty: true,
|
|
253
|
+
visibilityProperty: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The line of the selection to hang the offer from.
|
|
259
|
+
*
|
|
260
|
+
* **Not the bounding box.** A drag from a narrow column of a form down to a
|
|
261
|
+
* full-width line running under the chat panel beside it produced a union
|
|
262
|
+
* reaching from the column's left edge to the far end of that line -- and its
|
|
263
|
+
* centre landed most of the way across the page, behind the panel, on a line
|
|
264
|
+
* the user had not been looking at, while the pointer had let go by the column.
|
|
265
|
+
*
|
|
266
|
+
* Note what the fix is *not*: that centre was over selected text. It was over
|
|
267
|
+
* selected text nobody could see. A union is a shape the selection does not
|
|
268
|
+
* have, so no point derived from it belongs to any particular line.
|
|
269
|
+
*
|
|
270
|
+
* `near` is where the pointer let go. The line under it is where the user is
|
|
271
|
+
* looking, and it needs no engine-specific way to ask which end of the
|
|
272
|
+
* selection is its focus -- which the shadow-aware read does not carry anyway.
|
|
273
|
+
* Without a pointer -- a keyboard selection -- the first line is the one that
|
|
274
|
+
* exists whichever way the selection was made.
|
|
275
|
+
*
|
|
276
|
+
* The bounding box remains the fallback for a range that reports no line boxes
|
|
277
|
+
* at all, which is what a DOM with no layout does.
|
|
278
|
+
*/
|
|
279
|
+
function lineToHangFrom(range: Range, near: Point | undefined): DOMRect {
|
|
280
|
+
const lines = [...range.getClientRects()];
|
|
281
|
+
if (lines.length === 0) {
|
|
282
|
+
return range.getBoundingClientRect();
|
|
283
|
+
}
|
|
284
|
+
if (near === undefined) {
|
|
285
|
+
return lines[0] as DOMRect;
|
|
286
|
+
}
|
|
287
|
+
let closest = lines[0] as DOMRect;
|
|
288
|
+
let shortest = distanceTo(closest, near);
|
|
289
|
+
for (const line of lines.slice(1)) {
|
|
290
|
+
const distance = distanceTo(line, near);
|
|
291
|
+
if (distance < shortest) {
|
|
292
|
+
shortest = distance;
|
|
293
|
+
closest = line;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return closest;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** How far `point` is from the nearest edge of `rect`, or 0 inside it. */
|
|
300
|
+
function distanceTo(rect: DOMRect, point: Point): number {
|
|
301
|
+
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
|
|
302
|
+
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
|
|
303
|
+
return Math.hypot(dx, dy);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Endpoint pairs to consider, best first.
|
|
308
|
+
*
|
|
309
|
+
* Both reads are offered rather than one being chosen, because which is correct
|
|
310
|
+
* is a property of the engine *and* of where the user dragged: an engine that
|
|
311
|
+
* rescopes to the host returns endpoints outside the transcript, and the caller
|
|
312
|
+
* rejects those on the same test it uses for a selection that genuinely ran off
|
|
313
|
+
* the transcript. One rule, no engine sniffing.
|
|
314
|
+
*/
|
|
315
|
+
function endpointCandidates(roots: readonly ShadowRoot[]): readonly AbstractRange[] {
|
|
316
|
+
const selection = window.getSelection();
|
|
317
|
+
if (selection === null) {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
const candidates = [...composedRanges(selection, roots)];
|
|
321
|
+
if (selection.rangeCount > 0) {
|
|
322
|
+
candidates.push(selection.getRangeAt(0));
|
|
323
|
+
}
|
|
324
|
+
return candidates;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The shadow-aware read, or nothing on an engine that does not have it. */
|
|
328
|
+
function composedRanges(
|
|
329
|
+
selection: Selection,
|
|
330
|
+
roots: readonly ShadowRoot[],
|
|
331
|
+
): readonly AbstractRange[] {
|
|
332
|
+
const composed = (selection as ComposedSelection).getComposedRanges;
|
|
333
|
+
if (composed === undefined) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
return composed.call(selection, { shadowRoots: roots });
|
|
338
|
+
} catch {
|
|
339
|
+
// The method shipped first with the shadow roots as rest parameters and
|
|
340
|
+
// only later as a dictionary member, and the earlier form rejects the
|
|
341
|
+
// options object outright rather than ignoring it. Lexical resolves the
|
|
342
|
+
// same split the same way, by trying both shapes at runtime.
|
|
343
|
+
return composed.call(selection, ...roots);
|
|
344
|
+
}
|
|
345
|
+
}
|
package/src/ui/relative_time.ts
CHANGED
|
@@ -36,3 +36,14 @@ export function relativeTime(
|
|
|
36
36
|
}
|
|
37
37
|
return strings.weeksAgo.replace("{n}", String(Math.round(days / 7)));
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A host's replacement for {@link relativeTime}.
|
|
42
|
+
*
|
|
43
|
+
* Takes an epoch-milliseconds timestamp and returns the text a row shows.
|
|
44
|
+
* `Intl.RelativeTimeFormat` is the obvious implementation and is deliberately
|
|
45
|
+
* *not* the default: a component that guessed a locale would disagree with the
|
|
46
|
+
* host page's own formatting, and being wrong in a second language is worse
|
|
47
|
+
* than being neutral in one.
|
|
48
|
+
*/
|
|
49
|
+
export type RelativeTimeFormatter = (timestamp: number) => string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// happy-dom-only suite goes green while this module strips nothing at all.
|
|
5
5
|
// Moving those assertions back under happy-dom for speed removes the only check
|
|
6
6
|
// that this module does anything. CLAUDE.md records the upstream root cause.
|
|
7
|
-
import DOMPurify from "dompurify";
|
|
7
|
+
import DOMPurify, { type Config, type DOMPurify as Purifier } from "dompurify";
|
|
8
8
|
import { Marked } from "marked";
|
|
9
9
|
|
|
10
10
|
// A local parser instance, so configuration never leaks into the shared
|
|
@@ -54,11 +54,108 @@ const ALLOWED_TAGS = [
|
|
|
54
54
|
"td",
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// `target` and `rel` are on the list because {@link harden} writes them onto
|
|
58
|
+
// every link. An attribute the returned markup carries but the config does not
|
|
59
|
+
// name would be markup the sanitiser never approved — which is the whole defect
|
|
60
|
+
// the hook exists to avoid.
|
|
61
|
+
//
|
|
62
|
+
// `class` is here only for marked's `language-*` code-fence hint, and `harden`
|
|
63
|
+
// narrows it to exactly that. Left wide, it is a chrome-forgery channel: the
|
|
64
|
+
// shadow stylesheet's component classes are unscoped selectors, so a `<span
|
|
65
|
+
// class="approval-btn approval-btn--approve">` in model output resolves to the
|
|
66
|
+
// same pixels as the real human-in-the-loop approval button, inside the surface
|
|
67
|
+
// where the user decides whether to approve.
|
|
68
|
+
const ALLOWED_ATTR = ["href", "title", "class", "target", "rel"];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The sanitiser's declared allowlist — and, because {@link harden} runs inside
|
|
72
|
+
* the sanitiser rather than after it, its *effective* one too.
|
|
73
|
+
*
|
|
74
|
+
* `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` default to `true`, which would admit
|
|
75
|
+
* every `data-*` and `aria-*` attribute on top of the five named above. Both
|
|
76
|
+
* matter here: the cards drive their resolved / expanded / status appearance off
|
|
77
|
+
* `[data-resolved]`, `[data-status]` and `[data-expanded]`, and an `aria-label`
|
|
78
|
+
* on model output makes a screen reader announce something other than what is on
|
|
79
|
+
* screen. Turning them off is what makes the declared list the real one.
|
|
80
|
+
*
|
|
81
|
+
* Exported so a test can assert the returned markup is a fixed point of it.
|
|
82
|
+
*/
|
|
83
|
+
export const SANITIZE_CONFIG: Config = {
|
|
84
|
+
ALLOWED_TAGS,
|
|
85
|
+
ALLOWED_ATTR,
|
|
86
|
+
ALLOW_DATA_ATTR: false,
|
|
87
|
+
ALLOW_ARIA_ATTR: false,
|
|
88
|
+
};
|
|
58
89
|
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
90
|
+
/** {@link SANITIZE_CONFIG} plus the images a host opts into. */
|
|
91
|
+
export const SANITIZE_CONFIG_WITH_IMAGES: Config = {
|
|
92
|
+
...SANITIZE_CONFIG,
|
|
93
|
+
ALLOWED_TAGS: [...ALLOWED_TAGS, "img"],
|
|
94
|
+
ALLOWED_ATTR: [...ALLOWED_ATTR, "src", "alt", "width", "height"],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The one class value markdown is allowed to carry, and where it may sit. */
|
|
98
|
+
const LANGUAGE_CLASS = /^language-[A-Za-z0-9_+#.-]+$/;
|
|
99
|
+
const LANGUAGE_HOSTS = new Set(["CODE", "PRE"]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Per-element hardening, run *inside* the sanitiser.
|
|
103
|
+
*
|
|
104
|
+
* It belongs here rather than in a pass over the finished string because
|
|
105
|
+
* DOMPurify must be the last thing to touch the markup: anything edited in
|
|
106
|
+
* afterwards is inserted into the document without the sanitiser ever having
|
|
107
|
+
* seen it, and re-parsing sanitiser output is the shape every mXSS bypass takes.
|
|
108
|
+
* Running as a hook means DOMPurify serialises the result of these edits, so
|
|
109
|
+
* what the caller inserts is exactly what it approved.
|
|
110
|
+
*
|
|
111
|
+
* Two jobs:
|
|
112
|
+
*
|
|
113
|
+
* - links open in a new tab and never hand over their opener. `target`/`rel` are
|
|
114
|
+
* this module's to write, so they are stripped from everything else rather
|
|
115
|
+
* than inherited from the model's own markup;
|
|
116
|
+
* - `class` is narrowed to the code-fence language hint on `code`/`pre`, the
|
|
117
|
+
* only thing it is allowed for, and dropped everywhere else so model output
|
|
118
|
+
* cannot adopt the component's own chrome.
|
|
119
|
+
*/
|
|
120
|
+
function harden(node: Element): void {
|
|
121
|
+
if (node.nodeName === "A" && node.hasAttribute("href")) {
|
|
122
|
+
node.setAttribute("target", "_blank");
|
|
123
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
124
|
+
} else {
|
|
125
|
+
node.removeAttribute("target");
|
|
126
|
+
node.removeAttribute("rel");
|
|
127
|
+
}
|
|
128
|
+
const classes = node.getAttribute("class");
|
|
129
|
+
if (classes === null) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const kept = LANGUAGE_HOSTS.has(node.nodeName)
|
|
133
|
+
? classes.split(/\s+/).filter((token) => LANGUAGE_CLASS.test(token))
|
|
134
|
+
: [];
|
|
135
|
+
if (kept.length === 0) {
|
|
136
|
+
node.removeAttribute("class");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
node.setAttribute("class", kept.join(" "));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// DOMPurify's default export is a singleton, and `addHook` mutates it for every
|
|
143
|
+
// caller sharing that copy — including the host app, if its bundler deduped to
|
|
144
|
+
// ours. So this module builds its own instance, for the same reason `parser`
|
|
145
|
+
// above is a local `Marked`.
|
|
146
|
+
//
|
|
147
|
+
// Built on first render rather than at module scope: a DOMPurify instance
|
|
148
|
+
// created without a DOM has no `addHook` at all, and doing this eagerly would
|
|
149
|
+
// turn a server-side `import` of the package into a throw.
|
|
150
|
+
let purifier: Purifier | null = null;
|
|
151
|
+
|
|
152
|
+
function sanitizer(): Purifier {
|
|
153
|
+
if (purifier === null) {
|
|
154
|
+
purifier = DOMPurify();
|
|
155
|
+
purifier.addHook("afterSanitizeAttributes", harden);
|
|
156
|
+
}
|
|
157
|
+
return purifier;
|
|
158
|
+
}
|
|
62
159
|
|
|
63
160
|
/** Options for {@link renderMarkdown}. */
|
|
64
161
|
export interface RenderMarkdownOptions {
|
|
@@ -74,25 +171,18 @@ export interface RenderMarkdownOptions {
|
|
|
74
171
|
* Render markdown (and any embedded raw HTML) to a sanitised HTML string.
|
|
75
172
|
*
|
|
76
173
|
* Markdown syntax and literal HTML share one path: `marked` emits HTML, then
|
|
77
|
-
* DOMPurify strips everything outside {@link
|
|
78
|
-
*
|
|
79
|
-
*
|
|
174
|
+
* DOMPurify strips everything outside {@link SANITIZE_CONFIG} — scripts, event
|
|
175
|
+
* handlers, `javascript:` URLs, `data-*`/`aria-*`, and every class but a code
|
|
176
|
+
* fence's `language-*` hint. Links come out with `target="_blank"` and
|
|
177
|
+
* `rel="noopener noreferrer"`.
|
|
80
178
|
*
|
|
81
|
-
* The
|
|
82
|
-
*
|
|
179
|
+
* The returned string is the sanitiser's own output, trimmed. Nothing edits it
|
|
180
|
+
* afterwards, so what a caller inserts is what DOMPurify approved.
|
|
83
181
|
*/
|
|
84
182
|
export function renderMarkdown(text: string, options?: RenderMarkdownOptions): string {
|
|
85
183
|
const allowImages = options?.allowImages === true;
|
|
86
184
|
const rendered = parser.parse(text, { async: false });
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
});
|
|
91
|
-
const template = document.createElement("template");
|
|
92
|
-
template.innerHTML = clean;
|
|
93
|
-
for (const anchor of template.content.querySelectorAll("a[href]")) {
|
|
94
|
-
anchor.setAttribute("target", "_blank");
|
|
95
|
-
anchor.setAttribute("rel", "noopener noreferrer");
|
|
96
|
-
}
|
|
97
|
-
return template.innerHTML.trim();
|
|
185
|
+
return sanitizer()
|
|
186
|
+
.sanitize(rendered, allowImages ? SANITIZE_CONFIG_WITH_IMAGES : SANITIZE_CONFIG)
|
|
187
|
+
.trim();
|
|
98
188
|
}
|
package/src/ui/resize_handle.ts
CHANGED
|
@@ -45,7 +45,11 @@ export interface ResizeOptions {
|
|
|
45
45
|
readonly rect: () => PanelRect;
|
|
46
46
|
/** Apply a size (the host writes the custom properties). */
|
|
47
47
|
readonly apply: (size: ResizeSize) => void;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Called once per completed resize, for persistence: on `pointerup` for a
|
|
50
|
+
* drag, and when the key comes up (or focus leaves the handle) for a key
|
|
51
|
+
* press. Never per pointer move, and never per key repeat.
|
|
52
|
+
*/
|
|
49
53
|
readonly commit: (size: ResizeSize) => void;
|
|
50
54
|
/** Accessible label. */
|
|
51
55
|
readonly label: string;
|
|
@@ -127,6 +131,21 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
|
|
|
127
131
|
event.preventDefault();
|
|
128
132
|
});
|
|
129
133
|
|
|
134
|
+
// The size the current key gesture has applied but not yet persisted. The
|
|
135
|
+
// pointer path can commit inline because a drag has one unambiguous end;
|
|
136
|
+
// a key press does not, so the gesture's result is held here until it does.
|
|
137
|
+
let pending: ResizeSize | null = null;
|
|
138
|
+
|
|
139
|
+
/** End a key gesture: persist what it applied, once. */
|
|
140
|
+
const settle = (): void => {
|
|
141
|
+
if (pending === null) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const size = pending;
|
|
145
|
+
pending = null;
|
|
146
|
+
options.commit(size);
|
|
147
|
+
};
|
|
148
|
+
|
|
130
149
|
// Keyboard parity: a pointer-only resize is unreachable without a mouse, and
|
|
131
150
|
// this control has no equivalent elsewhere in the UI.
|
|
132
151
|
handle.addEventListener("keydown", (event: KeyboardEvent) => {
|
|
@@ -155,9 +174,20 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
|
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
176
|
event.preventDefault();
|
|
177
|
+
// Live feedback per key event, persistence only when the gesture ends:
|
|
178
|
+
// `commit` promises one call per completed resize, and a held arrow key
|
|
179
|
+
// repeats at the OS rate (20-30 events a second), so committing here would
|
|
180
|
+
// put that many storage writes or PATCHes behind a single press — landing
|
|
181
|
+
// hardest on the keyboard users this path exists for.
|
|
158
182
|
options.apply(next);
|
|
159
|
-
|
|
183
|
+
pending = next;
|
|
160
184
|
});
|
|
161
185
|
|
|
186
|
+
// The key coming up ends the gesture, mirroring `pointerup`. `blur` closes one
|
|
187
|
+
// whose keyup never arrives here — focus moved on mid-press — because a size
|
|
188
|
+
// that was applied but never committed is a resize the host silently forgets.
|
|
189
|
+
handle.addEventListener("keyup", settle);
|
|
190
|
+
handle.addEventListener("blur", settle);
|
|
191
|
+
|
|
162
192
|
return handle;
|
|
163
193
|
}
|