@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +615 -1
  2. package/README.md +564 -35
  3. package/dist/ag-ui-web-component.bundle.js +491 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +129 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +232 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +56 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +8 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2081 -98
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +18 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -1
  17. package/dist/ui/checkpoint_menu.d.ts +10 -0
  18. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  19. package/dist/ui/confirmation_card.d.ts +16 -0
  20. package/dist/ui/confirmation_card.d.ts.map +1 -1
  21. package/dist/ui/message_actions.d.ts +56 -0
  22. package/dist/ui/message_actions.d.ts.map +1 -0
  23. package/dist/ui/page_quote_offer.d.ts +33 -0
  24. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  25. package/dist/ui/quote_selection.d.ts +66 -0
  26. package/dist/ui/quote_selection.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +10 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/stick_to_bottom.d.ts +55 -0
  30. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  31. package/dist/ui/styles.d.ts +1 -1
  32. package/dist/ui/styles.d.ts.map +1 -1
  33. package/dist/ui/subagent_panel.d.ts +92 -0
  34. package/dist/ui/subagent_panel.d.ts.map +1 -0
  35. package/dist/ui/subagent_update.d.ts +19 -0
  36. package/dist/ui/subagent_update.d.ts.map +1 -0
  37. package/dist/ui/suggestion_chips.d.ts +29 -0
  38. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  39. package/dist/ui/thread_drawer.d.ts +10 -0
  40. package/dist/ui/thread_drawer.d.ts.map +1 -1
  41. package/dist/ui/tool_call_card.d.ts +81 -1
  42. package/dist/ui/tool_call_card.d.ts.map +1 -1
  43. package/dist/ui/ui_strings.d.ts +50 -0
  44. package/dist/ui/ui_strings.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/constants.ts +138 -1
  47. package/src/core/ag_ui_chat.ts +1081 -73
  48. package/src/core/agui_client.ts +89 -2
  49. package/src/index.ts +43 -0
  50. package/src/ui/approval_card.ts +90 -2
  51. package/src/ui/checkpoint_menu.ts +22 -5
  52. package/src/ui/confirmation_card.ts +29 -1
  53. package/src/ui/message_actions.ts +170 -0
  54. package/src/ui/page_quote_offer.ts +215 -0
  55. package/src/ui/quote_selection.ts +345 -0
  56. package/src/ui/relative_time.ts +11 -0
  57. package/src/ui/stick_to_bottom.ts +126 -0
  58. package/src/ui/styles.ts +410 -0
  59. package/src/ui/subagent_panel.ts +213 -0
  60. package/src/ui/subagent_update.ts +80 -0
  61. package/src/ui/suggestion_chips.ts +73 -0
  62. package/src/ui/thread_drawer.ts +22 -2
  63. package/src/ui/tool_call_card.ts +138 -3
  64. package/src/ui/ui_strings.ts +75 -0
  65. 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
+ }
@@ -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
+ }