@artooi/ag-ui-web-component 0.28.0 → 0.30.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 +615 -1
- package/README.md +564 -35
- package/dist/ag-ui-web-component.bundle.js +491 -50
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +129 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +232 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +56 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2081 -98
- package/dist/index.js.map +4 -4
- 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 +56 -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/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/subagent_panel.d.ts +92 -0
- package/dist/ui/subagent_panel.d.ts.map +1 -0
- package/dist/ui/subagent_update.d.ts +19 -0
- package/dist/ui/subagent_update.d.ts.map +1 -0
- 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 +81 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +50 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +138 -1
- package/src/core/ag_ui_chat.ts +1081 -73
- package/src/core/agui_client.ts +89 -2
- package/src/index.ts +43 -0
- 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 +170 -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/stick_to_bottom.ts +126 -0
- package/src/ui/styles.ts +410 -0
- package/src/ui/subagent_panel.ts +213 -0
- package/src/ui/subagent_update.ts +80 -0
- package/src/ui/suggestion_chips.ts +73 -0
- package/src/ui/thread_drawer.ts +22 -2
- package/src/ui/tool_call_card.ts +138 -3
- package/src/ui/ui_strings.ts +75 -0
- package/src/version.ts +1 -1
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** A transcript that follows new content, unless the reader has other ideas. */
|
|
2
|
+
export interface StickToBottom {
|
|
3
|
+
/**
|
|
4
|
+
* New content arrived. Scrolls to the bottom only while following, so
|
|
5
|
+
* reading older messages during a run is no longer undone on the next token.
|
|
6
|
+
*/
|
|
7
|
+
readonly follow: () => void;
|
|
8
|
+
/** Go to the bottom and resume following, whatever the reader was doing. */
|
|
9
|
+
readonly jump: () => void;
|
|
10
|
+
/** Whether the transcript is currently following new content. */
|
|
11
|
+
readonly following: () => boolean;
|
|
12
|
+
readonly dispose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StickToBottomOptions {
|
|
16
|
+
/** The scrolling element -- the message list. */
|
|
17
|
+
readonly viewport: HTMLElement;
|
|
18
|
+
/**
|
|
19
|
+
* Called whenever the answer to "should a jump-to-latest affordance show?"
|
|
20
|
+
* changes. True means the reader has scrolled away *and* has since missed
|
|
21
|
+
* something; scrolling up through a settled transcript is not a reason to
|
|
22
|
+
* nag.
|
|
23
|
+
*/
|
|
24
|
+
readonly onMissedContent: (missed: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How close to the bottom still counts as the bottom, in CSS pixels.
|
|
29
|
+
*
|
|
30
|
+
* Not zero: `scrollHeight - scrollTop - clientHeight` lands on fractional
|
|
31
|
+
* values under a zoom level or a fractional device pixel ratio, so an exact
|
|
32
|
+
* comparison reports "scrolled away" for a transcript that is visibly pinned.
|
|
33
|
+
*/
|
|
34
|
+
const BOTTOM_SLACK_PX = 4;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Follow the foot of a scrolling transcript, and stop when the reader scrolls
|
|
38
|
+
* away.
|
|
39
|
+
*
|
|
40
|
+
* Before this, eleven separate sites assigned `scrollTop = scrollHeight`
|
|
41
|
+
* unconditionally and nothing anywhere listened for a `scroll` event -- so
|
|
42
|
+
* nothing knew the reader had scrolled up, and scrolling back through a run was
|
|
43
|
+
* undone by the next token. Stick-to-bottom with a jump-to-latest affordance is
|
|
44
|
+
* a named primitive elsewhere for exactly this reason: shadcn ships it as
|
|
45
|
+
* `MessageScroller`, AI Elements as `ConversationScrollButton`.
|
|
46
|
+
*
|
|
47
|
+
* **Telling a reader's scroll from our own is the whole problem**, and the
|
|
48
|
+
* answer here is that it does not have to be told. A programmatic scroll only
|
|
49
|
+
* ever happens while already following, and it lands at the bottom, so the
|
|
50
|
+
* `scroll` event it provokes recomputes "at the bottom" as true and changes
|
|
51
|
+
* nothing. A reader's scroll is the only kind that can move the answer.
|
|
52
|
+
*
|
|
53
|
+
* A `ResizeObserver` covers the case scroll events cannot see: the *viewport*
|
|
54
|
+
* changing size. Resizing the panel, or the keyboard opening on a phone, moves
|
|
55
|
+
* the foot without anything scrolling and without any content arriving, so a
|
|
56
|
+
* pinned transcript would silently come unpinned.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ It does **not** cover content that grows after insertion -- an image
|
|
59
|
+
* decoding, a chart laying out. A `ResizeObserver` on a scroll container does
|
|
60
|
+
* not fire when its `scrollHeight` changes, so catching that means observing
|
|
61
|
+
* every child, and the payoff is one late nudge in a case the reader can fix by
|
|
62
|
+
* scrolling. Insertion itself is covered: every site that adds to the
|
|
63
|
+
* transcript calls {@link StickToBottom.follow}.
|
|
64
|
+
*/
|
|
65
|
+
export function createStickToBottom({
|
|
66
|
+
viewport,
|
|
67
|
+
onMissedContent,
|
|
68
|
+
}: StickToBottomOptions): StickToBottom {
|
|
69
|
+
let isFollowing = true;
|
|
70
|
+
let missed = false;
|
|
71
|
+
|
|
72
|
+
const atBottom = (): boolean =>
|
|
73
|
+
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= BOTTOM_SLACK_PX;
|
|
74
|
+
|
|
75
|
+
const setMissed = (next: boolean): void => {
|
|
76
|
+
if (next === missed) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
missed = next;
|
|
80
|
+
onMissedContent(missed);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const toBottom = (): void => {
|
|
84
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onScroll = (): void => {
|
|
88
|
+
isFollowing = atBottom();
|
|
89
|
+
if (isFollowing) {
|
|
90
|
+
setMissed(false);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const follow = (): void => {
|
|
95
|
+
if (isFollowing) {
|
|
96
|
+
toBottom();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setMissed(true);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Passive: this listener never calls preventDefault, and saying so keeps it
|
|
103
|
+
// off the critical path of a scroll it has no intention of blocking.
|
|
104
|
+
viewport.addEventListener("scroll", onScroll, { passive: true });
|
|
105
|
+
|
|
106
|
+
const observer = new ResizeObserver(() => {
|
|
107
|
+
if (isFollowing) {
|
|
108
|
+
toBottom();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
observer.observe(viewport);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
follow,
|
|
115
|
+
jump: (): void => {
|
|
116
|
+
isFollowing = true;
|
|
117
|
+
setMissed(false);
|
|
118
|
+
toBottom();
|
|
119
|
+
},
|
|
120
|
+
following: (): boolean => isFollowing,
|
|
121
|
+
dispose: (): void => {
|
|
122
|
+
viewport.removeEventListener("scroll", onScroll);
|
|
123
|
+
observer.disconnect();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|