@artooi/ag-ui-web-component 0.32.0 → 0.33.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 +162 -1
- package/README.md +182 -23
- package/dist/ag-ui-web-component.bundle.js +341 -117
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/index.js +1259 -389
- package/dist/index.js.map +4 -4
- package/dist/ui/clamp_launcher.d.ts +15 -0
- package/dist/ui/clamp_launcher.d.ts.map +1 -0
- package/dist/ui/copy_payload.d.ts +30 -0
- package/dist/ui/copy_payload.d.ts.map +1 -0
- package/dist/ui/launcher_drag.d.ts +43 -0
- package/dist/ui/launcher_drag.d.ts.map +1 -0
- package/dist/ui/launcher_placement.d.ts +64 -0
- package/dist/ui/launcher_placement.d.ts.map +1 -0
- package/dist/ui/message_actions.d.ts +30 -2
- package/dist/ui/message_actions.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +32 -26
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +12 -0
- package/src/core/ag_ui_chat.ts +480 -24
- package/src/ui/clamp_launcher.ts +20 -0
- package/src/ui/copy_payload.ts +156 -0
- package/src/ui/launcher_drag.ts +182 -0
- package/src/ui/launcher_placement.ts +143 -0
- package/src/ui/message_actions.ts +93 -19
- package/src/ui/resize_handle.ts +109 -73
- package/src/ui/styles.ts +283 -81
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
FEEDBACK_EVENT,
|
|
11
11
|
ICON_ATTACH,
|
|
12
12
|
ICON_LAUNCHER,
|
|
13
|
+
ICON_RETRY,
|
|
13
14
|
ICON_SEND,
|
|
14
15
|
ICON_STOP,
|
|
15
16
|
INVALIDATE_CUSTOM_NAME,
|
|
@@ -56,7 +57,16 @@ import { renderChart } from "../ui/chart_block.js";
|
|
|
56
57
|
import { chartSpecFrom } from "../ui/chart_spec_from.js";
|
|
57
58
|
import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
|
|
58
59
|
import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
|
|
60
|
+
import { clampLauncher } from "../ui/clamp_launcher.js";
|
|
59
61
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
62
|
+
import { copyPayload } from "../ui/copy_payload.js";
|
|
63
|
+
import { enableLauncherDrag } from "../ui/launcher_drag.js";
|
|
64
|
+
import {
|
|
65
|
+
type ExpandCorner,
|
|
66
|
+
type Extent,
|
|
67
|
+
type LauncherBox,
|
|
68
|
+
launcherPlacement,
|
|
69
|
+
} from "../ui/launcher_placement.js";
|
|
60
70
|
import {
|
|
61
71
|
attachMessageActions,
|
|
62
72
|
messageActionBar,
|
|
@@ -74,8 +84,11 @@ import type { RelativeTimeFormatter } from "../ui/relative_time.js";
|
|
|
74
84
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
75
85
|
import {
|
|
76
86
|
createResizeHandle,
|
|
87
|
+
gripName,
|
|
88
|
+
type PanelRect,
|
|
77
89
|
type ResizeAnchor,
|
|
78
90
|
type ResizeAxis,
|
|
91
|
+
type ResizeGrip,
|
|
79
92
|
type ResizeSize,
|
|
80
93
|
} from "../ui/resize_handle.js";
|
|
81
94
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
@@ -300,6 +313,32 @@ const SIZE_KEY = "ag-ui-chat:size";
|
|
|
300
313
|
/** Per-tab persistence key for the built-in theme toggle. */
|
|
301
314
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
302
315
|
|
|
316
|
+
/** Per-tab persistence key for a dragged launcher position. */
|
|
317
|
+
const LAUNCHER_KEY = "ag-ui-chat:launcher";
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Placements whose launcher can be dragged. The rest have nowhere to put it:
|
|
321
|
+
* a sidebar collapses to a full-height edge rail, "embedded" and "page" hide
|
|
322
|
+
* the launcher entirely and keep their header bar, and a full-bleed panel
|
|
323
|
+
* covers the screen it would be opening into.
|
|
324
|
+
*/
|
|
325
|
+
const DRAGGABLE_PLACEMENTS = new Set([null, "", "floating", "bottom-left"]);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Every edge and corner the panel can be dragged by. Corners last, so they sit
|
|
329
|
+
* above the edge strips they overlap and win the pointer at the corners.
|
|
330
|
+
*/
|
|
331
|
+
const RESIZE_GRIPS: readonly ResizeGrip[] = [
|
|
332
|
+
{ y: "top" },
|
|
333
|
+
{ y: "bottom" },
|
|
334
|
+
{ x: "left" },
|
|
335
|
+
{ x: "right" },
|
|
336
|
+
{ x: "left", y: "top" },
|
|
337
|
+
{ x: "right", y: "top" },
|
|
338
|
+
{ x: "left", y: "bottom" },
|
|
339
|
+
{ x: "right", y: "bottom" },
|
|
340
|
+
];
|
|
341
|
+
|
|
303
342
|
/** Pixels between a selection and the offer to quote it. */
|
|
304
343
|
const QUOTE_GAP = 6;
|
|
305
344
|
|
|
@@ -771,6 +810,37 @@ export class AgUiChat extends HTMLElement {
|
|
|
771
810
|
readonly #emptyWrap: HTMLDivElement;
|
|
772
811
|
/** Upload tray; created on connect only when `data-attachments-url` is set. */
|
|
773
812
|
#attachTray: AttachmentTray | null = null;
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Where the user dragged the launcher, in viewport coordinates, or null
|
|
816
|
+
* while the host's own CSS still places it. Set means this element owns its
|
|
817
|
+
* position -- see #applyLauncherPlacement for what that costs the host.
|
|
818
|
+
*/
|
|
819
|
+
#launcherPos: { readonly left: number; readonly top: number } | null = null;
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The corner the panel opens away from, once this element is placing itself.
|
|
823
|
+
* Null means the host's layout still decides, and the anchor is measured.
|
|
824
|
+
*/
|
|
825
|
+
#expandCorner: ExpandCorner | null = null;
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The edges the layout is holding still, as last measured. Cached because a
|
|
829
|
+
* resize reads it per pointer move and measuring forces a reflow -- thirty a
|
|
830
|
+
* second while the panel is already being laid out on every one of them.
|
|
831
|
+
*/
|
|
832
|
+
#anchor: ResizeAnchor = { x: "right", y: "bottom" };
|
|
833
|
+
|
|
834
|
+
/** The eight grips, by name, so the keyboard-reachable one can be moved. */
|
|
835
|
+
readonly #resizeHandles = new Map<string, HTMLDivElement>();
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Re-clamp the dragged launcher when the window changes size. Bound once as
|
|
839
|
+
* a field so `removeEventListener` on disconnect gets the same reference.
|
|
840
|
+
*/
|
|
841
|
+
readonly #onViewportResize = (): void => {
|
|
842
|
+
this.#restoreLauncherPosition();
|
|
843
|
+
};
|
|
774
844
|
/** Mic button mount point (input row); the control mounts on connect when enabled. */
|
|
775
845
|
readonly #voiceSlot: HTMLSpanElement;
|
|
776
846
|
/** Voice-input control; created on connect when transcription is available. */
|
|
@@ -1007,6 +1077,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
1007
1077
|
// else: a size dragged under the previous placement would otherwise sit
|
|
1008
1078
|
// inline and outrank the new one.
|
|
1009
1079
|
this.#releaseOwnedAxes();
|
|
1080
|
+
// Position is owned the same way a size is: a placement that places
|
|
1081
|
+
// itself takes back a launcher the user had dragged somewhere else.
|
|
1082
|
+
this.#releaseLauncherPosition();
|
|
1010
1083
|
// Placement also moves the panel, so the edges its layout holds still
|
|
1011
1084
|
// change with it. Deferred a frame so the new rules have applied.
|
|
1012
1085
|
requestAnimationFrame(() => this.#syncResizeAnchor());
|
|
@@ -1433,7 +1506,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
1433
1506
|
// frame so the host's own stylesheet has applied; re-measured on every drag
|
|
1434
1507
|
// anyway, so a wrong first guess costs a grip in the wrong corner and never
|
|
1435
1508
|
// a wrong resize.
|
|
1436
|
-
requestAnimationFrame(() =>
|
|
1509
|
+
requestAnimationFrame(() => {
|
|
1510
|
+
// Before the anchor, which #applyLauncherPlacement stamps itself once a
|
|
1511
|
+
// dragged position exists.
|
|
1512
|
+
this.#restoreLauncherPosition();
|
|
1513
|
+
this.#syncResizeAnchor();
|
|
1514
|
+
});
|
|
1437
1515
|
// Resolve the string table before rendering any chrome (defaults are the
|
|
1438
1516
|
// floor; `data-strings` then the `strings` property layer over them).
|
|
1439
1517
|
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
@@ -1463,6 +1541,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1463
1541
|
namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
|
|
1464
1542
|
this.conversationStore = this.#builtinStore;
|
|
1465
1543
|
}
|
|
1544
|
+
window.addEventListener("resize", this.#onViewportResize);
|
|
1466
1545
|
this.#wireThreadStore();
|
|
1467
1546
|
this.#wireAttachments();
|
|
1468
1547
|
this.#wireVoice();
|
|
@@ -1535,6 +1614,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1535
1614
|
*/
|
|
1536
1615
|
disconnectedCallback(): void {
|
|
1537
1616
|
this.#connected = false;
|
|
1617
|
+
window.removeEventListener("resize", this.#onViewportResize);
|
|
1538
1618
|
// Give the namespace back. A disconnect is not necessarily a farewell — a
|
|
1539
1619
|
// DOM move and a framework re-render both look like one — and an element
|
|
1540
1620
|
// that could not reclaim its own namespace on the way back in would lose
|
|
@@ -1619,6 +1699,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1619
1699
|
this.#fileInput.accept = accept;
|
|
1620
1700
|
this.#attachButton.hidden = false;
|
|
1621
1701
|
this.#enableDragAndDrop();
|
|
1702
|
+
this.#enablePaste();
|
|
1622
1703
|
}
|
|
1623
1704
|
|
|
1624
1705
|
/** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
|
|
@@ -1886,6 +1967,41 @@ export class AgUiChat extends HTMLElement {
|
|
|
1886
1967
|
});
|
|
1887
1968
|
}
|
|
1888
1969
|
|
|
1970
|
+
/**
|
|
1971
|
+
* Accept files pasted into the composer.
|
|
1972
|
+
*
|
|
1973
|
+
* The whole tray already exists behind this: a paste is one more way to hand
|
|
1974
|
+
* it a `File`, alongside the picker and a drop.
|
|
1975
|
+
*
|
|
1976
|
+
* Two rules keep it from stealing a paste that was never about files.
|
|
1977
|
+
* `clipboardData.files` is empty for text, so ordinary pasting is untouched.
|
|
1978
|
+
* And the default is only prevented when the clipboard carries **no text**:
|
|
1979
|
+
* copying a rich selection that happens to contain an image puts both on the
|
|
1980
|
+
* clipboard, and swallowing the words someone meant to paste in order to
|
|
1981
|
+
* attach a picture they did not is the worse of the two failures.
|
|
1982
|
+
*/
|
|
1983
|
+
#enablePaste(): void {
|
|
1984
|
+
this.#chat.addEventListener("paste", (event: ClipboardEvent) => {
|
|
1985
|
+
// Nullish rather than a null check: the property is typed as nullable,
|
|
1986
|
+
// and an engine that fires a plain Event for a paste leaves it absent
|
|
1987
|
+
// instead, which is not the same value and is the same situation.
|
|
1988
|
+
const clipboard = event.clipboardData ?? null;
|
|
1989
|
+
if (clipboard === null) {
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const files = Array.from(clipboard.files);
|
|
1993
|
+
if (files.length === 0) {
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (clipboard.getData("text/plain") === "") {
|
|
1997
|
+
event.preventDefault();
|
|
1998
|
+
}
|
|
1999
|
+
for (const file of files) {
|
|
2000
|
+
this.#attachTray?.add(named(file));
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1889
2005
|
/**
|
|
1890
2006
|
* When `data-threads-url` is set, route thread enumeration / load / rename /
|
|
1891
2007
|
* delete through that server endpoint (wrapping the current store as the
|
|
@@ -2061,6 +2177,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2061
2177
|
* its own chrome.
|
|
2062
2178
|
*/
|
|
2063
2179
|
setCollapsed(collapsed: boolean): void {
|
|
2180
|
+
if (!collapsed) {
|
|
2181
|
+
// Re-decide which way to open before opening: the viewport may have
|
|
2182
|
+
// changed since the launcher was dropped, and this corner is what the
|
|
2183
|
+
// panel grows from.
|
|
2184
|
+
this.#restoreLauncherPosition();
|
|
2185
|
+
}
|
|
2064
2186
|
if (collapsed) {
|
|
2065
2187
|
this.setAttribute("collapsed", "");
|
|
2066
2188
|
} else {
|
|
@@ -2140,15 +2262,57 @@ export class AgUiChat extends HTMLElement {
|
|
|
2140
2262
|
const before = this.getBoundingClientRect();
|
|
2141
2263
|
const width = this.style.getPropertyValue("--ag-ui-width");
|
|
2142
2264
|
const height = this.style.getPropertyValue("--ag-ui-height");
|
|
2143
|
-
|
|
2144
|
-
|
|
2265
|
+
// Shrink first. Growing is the obvious probe and cannot answer the question
|
|
2266
|
+
// at a size already resting against max-width or max-height: the box does
|
|
2267
|
+
// not change, no edge moves, and every clamped axis then reads as pinned on
|
|
2268
|
+
// the side that did not move -- which is the side that is free. That is not
|
|
2269
|
+
// an edge case. The default panel is 380px wide against a max-width of
|
|
2270
|
+
// 100vw minus 48, so any viewport under 428px is born clamped, and the grip
|
|
2271
|
+
// rendered on the wrong corner with the drag inverted before anyone touched
|
|
2272
|
+
// it. Shrinking always moves an edge, because the shrink is measured from
|
|
2273
|
+
// the box's *used* width rather than from whatever was asked for.
|
|
2274
|
+
const shrunk = this.#probeAnchor(before, -1);
|
|
2275
|
+
// Unless a host rule sets a minimum, in which case that axis is asked the
|
|
2276
|
+
// opposite question rather than left to a guess.
|
|
2277
|
+
const grown = shrunk.x === null || shrunk.y === null ? this.#probeAnchor(before, 1) : shrunk;
|
|
2145
2278
|
// Restore exactly what was there, including "nothing" — leaving a probe
|
|
2146
2279
|
// value behind would pin a panel that had been sizing itself.
|
|
2147
2280
|
this.#restoreProperty("--ag-ui-width", width);
|
|
2148
2281
|
this.#restoreProperty("--ag-ui-height", height);
|
|
2149
2282
|
return {
|
|
2150
|
-
|
|
2151
|
-
|
|
2283
|
+
// Neither direction moved it: the axis cannot be resized at all, so the
|
|
2284
|
+
// floating default is the best answer available and is the one the
|
|
2285
|
+
// stylesheet would have used with no measurement at all.
|
|
2286
|
+
x: shrunk.x ?? grown.x ?? "right",
|
|
2287
|
+
y: shrunk.y ?? grown.y ?? "bottom",
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* Which edge each axis holds still when the panel changes size by `delta`.
|
|
2293
|
+
*
|
|
2294
|
+
* Null for an axis whose size did not change: nothing moved, so nothing was
|
|
2295
|
+
* learned, and reporting the unmoved edge as the pinned one would be exactly
|
|
2296
|
+
* backwards.
|
|
2297
|
+
*/
|
|
2298
|
+
#probeAnchor(
|
|
2299
|
+
before: DOMRect,
|
|
2300
|
+
delta: number,
|
|
2301
|
+
): { x: "left" | "right" | null; y: "top" | "bottom" | null } {
|
|
2302
|
+
this.#applySize({ width: before.width + delta, height: before.height + delta });
|
|
2303
|
+
const after = this.getBoundingClientRect();
|
|
2304
|
+
const moved = (a: number, b: number): boolean => Math.abs(a - b) >= 0.5;
|
|
2305
|
+
return {
|
|
2306
|
+
x: moved(after.width, before.width)
|
|
2307
|
+
? moved(after.left, before.left)
|
|
2308
|
+
? "right"
|
|
2309
|
+
: "left"
|
|
2310
|
+
: null,
|
|
2311
|
+
y: moved(after.height, before.height)
|
|
2312
|
+
? moved(after.top, before.top)
|
|
2313
|
+
? "bottom"
|
|
2314
|
+
: "top"
|
|
2315
|
+
: null,
|
|
2152
2316
|
};
|
|
2153
2317
|
}
|
|
2154
2318
|
|
|
@@ -2157,8 +2321,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
2157
2321
|
if (!this.#connected) {
|
|
2158
2322
|
return;
|
|
2159
2323
|
}
|
|
2160
|
-
|
|
2324
|
+
// When this element owns its position it knows which edges are pinned --
|
|
2325
|
+
// it just wrote them -- so there is nothing to probe. The probe is also
|
|
2326
|
+
// unreliable at a size resting against max-width or max-height, where a
|
|
2327
|
+
// nudge moves no edge and every axis reads as pinned on the wrong side.
|
|
2328
|
+
const anchor = this.#expandCorner ?? this.#measureAnchor();
|
|
2329
|
+
this.#anchor = anchor;
|
|
2161
2330
|
this.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
|
|
2331
|
+
this.#focusableGrip();
|
|
2162
2332
|
}
|
|
2163
2333
|
|
|
2164
2334
|
/** Put a custom property back to a previous value, or remove it if there was none. */
|
|
@@ -2211,6 +2381,236 @@ export class AgUiChat extends HTMLElement {
|
|
|
2211
2381
|
}
|
|
2212
2382
|
}
|
|
2213
2383
|
|
|
2384
|
+
/**
|
|
2385
|
+
* Whether this placement lets the launcher be dragged, read per interaction
|
|
2386
|
+
* because `placement` is a live attribute. `data-launcher-drag="false"` opts
|
|
2387
|
+
* a host out without giving up the launcher itself.
|
|
2388
|
+
*/
|
|
2389
|
+
#launcherDraggable(): boolean {
|
|
2390
|
+
return (
|
|
2391
|
+
this.getAttribute("data-launcher-drag") !== "false" &&
|
|
2392
|
+
DRAGGABLE_PLACEMENTS.has(this.getAttribute("placement"))
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** The viewport the launcher and the panel both have to fit inside. */
|
|
2397
|
+
#viewport(): Extent {
|
|
2398
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* The launcher's box in viewport coordinates, with its transform divided out.
|
|
2403
|
+
*
|
|
2404
|
+
* The launcher is scaled in four states -- the collapse animation, hover,
|
|
2405
|
+
* press, and the resting scale(0.4) it sits at while the panel is open -- and
|
|
2406
|
+
* `getBoundingClientRect` reports every one of them. A drag that started from
|
|
2407
|
+
* that rect would begin a couple of pixels off, because a press is one of
|
|
2408
|
+
* those states.
|
|
2409
|
+
*
|
|
2410
|
+
* So the size comes from `offsetWidth`/`offsetHeight`, which are layout
|
|
2411
|
+
* metrics no transform reaches, and the position from the rect's *centre*,
|
|
2412
|
+
* which a centred scale is the one point that cannot move.
|
|
2413
|
+
*/
|
|
2414
|
+
#launcherBox(): LauncherBox {
|
|
2415
|
+
const width = this.#launcher.offsetWidth;
|
|
2416
|
+
const height = this.#launcher.offsetHeight;
|
|
2417
|
+
const dragged = this.#launcherPos;
|
|
2418
|
+
if (dragged !== null) {
|
|
2419
|
+
return { left: dragged.left, top: dragged.top, width, height };
|
|
2420
|
+
}
|
|
2421
|
+
const rect = this.#launcher.getBoundingClientRect();
|
|
2422
|
+
return {
|
|
2423
|
+
left: rect.left + rect.width / 2 - width / 2,
|
|
2424
|
+
top: rect.top + rect.height / 2 - height / 2,
|
|
2425
|
+
width,
|
|
2426
|
+
height,
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Place the host box and the launcher for the position the user dragged to.
|
|
2432
|
+
*
|
|
2433
|
+
* This writes `--ag-ui-inset`, which is a host-facing property: an inline
|
|
2434
|
+
* value outranks the page's own rule for it, exactly as a dragged width
|
|
2435
|
+
* outranks a placement's. That is the intent -- the user moved it -- and it
|
|
2436
|
+
* is why switching to a placement that owns its position hands the property
|
|
2437
|
+
* back rather than leaving a stale inline one behind.
|
|
2438
|
+
*/
|
|
2439
|
+
#applyLauncherPlacement(at: { readonly left: number; readonly top: number }): void {
|
|
2440
|
+
// The single gate: callers hand over a position and this decides whether
|
|
2441
|
+
// it is this element's to honour. Checking in both places instead would
|
|
2442
|
+
// leave one of the two checks permanently unreachable.
|
|
2443
|
+
if (!this.#launcherDraggable()) {
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
this.#launcherPos = at;
|
|
2447
|
+
// The host box keeps its expanded size while collapsed, so its own rect is
|
|
2448
|
+
// the panel's size in either state and needs no separate bookkeeping.
|
|
2449
|
+
const panel = this.getBoundingClientRect();
|
|
2450
|
+
const placement = launcherPlacement(
|
|
2451
|
+
this.#launcherBox(),
|
|
2452
|
+
{ width: panel.width, height: panel.height },
|
|
2453
|
+
this.#viewport(),
|
|
2454
|
+
);
|
|
2455
|
+
this.style.setProperty("--ag-ui-inset", placement.hostInset);
|
|
2456
|
+
this.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
|
|
2457
|
+
this.#expandCorner = placement.corner;
|
|
2458
|
+
// The corner the panel grows from, for the open/close animation's origin.
|
|
2459
|
+
this.setAttribute("data-expand-corner", `${placement.corner.y}-${placement.corner.x}`);
|
|
2460
|
+
this.#syncResizeAnchor();
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** Move the launcher live during a drag, without persisting. */
|
|
2464
|
+
#moveLauncher(left: number, top: number): void {
|
|
2465
|
+
this.#applyLauncherPlacement({ left, top });
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/** Move the launcher and remember where, per tab, like the dragged size. */
|
|
2469
|
+
#commitLauncher(left: number, top: number): void {
|
|
2470
|
+
this.#moveLauncher(left, top);
|
|
2471
|
+
this.#storeLauncherPosition();
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** Write the current launcher position, if this element owns one. */
|
|
2475
|
+
#storeLauncherPosition(): void {
|
|
2476
|
+
const position = this.#launcherPos;
|
|
2477
|
+
if (position === null) {
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
writeStoredItem(this.#storageKey(LAUNCHER_KEY), JSON.stringify(position));
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/**
|
|
2484
|
+
* Re-apply the dragged position against the current viewport: on connect,
|
|
2485
|
+
* whenever the window resizes, and before an expand. The viewport that
|
|
2486
|
+
* stored a position may since have shrunk, and a launcher past the edge is
|
|
2487
|
+
* unreachable -- it is the only way back to a collapsed conversation.
|
|
2488
|
+
*/
|
|
2489
|
+
#restoreLauncherPosition(): void {
|
|
2490
|
+
const stored = this.#launcherPos ?? this.#readLauncherPosition();
|
|
2491
|
+
if (stored === null) {
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
const box = this.#launcherBox();
|
|
2495
|
+
this.#applyLauncherPlacement(
|
|
2496
|
+
clampLauncher({ ...box, left: stored.left, top: stored.top }, this.#viewport()),
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
/** The persisted launcher position for this instance, or null. */
|
|
2501
|
+
#readLauncherPosition(): { readonly left: number; readonly top: number } | null {
|
|
2502
|
+
const raw = this.#readScopedItem(LAUNCHER_KEY);
|
|
2503
|
+
if (raw === null) {
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
try {
|
|
2507
|
+
const parsed: unknown = JSON.parse(raw);
|
|
2508
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
const { left, top } = parsed as { left?: unknown; top?: unknown };
|
|
2512
|
+
return typeof left === "number" && typeof top === "number" ? { left, top } : null;
|
|
2513
|
+
} catch {
|
|
2514
|
+
// A corrupt entry is not worth failing a mount over; fall back to the
|
|
2515
|
+
// placement's own corner.
|
|
2516
|
+
return null;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* Give the position back to the host when the new placement owns it, the way
|
|
2522
|
+
* #releaseOwnedAxes gives back a size. Without this a dragged inset survives
|
|
2523
|
+
* the switch inline and pins a sidebar to wherever the floating launcher was.
|
|
2524
|
+
*/
|
|
2525
|
+
#releaseLauncherPosition(): void {
|
|
2526
|
+
if (this.#launcherDraggable()) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
this.#launcherPos = null;
|
|
2530
|
+
this.#expandCorner = null;
|
|
2531
|
+
this.style.removeProperty("--ag-ui-inset");
|
|
2532
|
+
this.style.removeProperty("--ag-ui-launcher-inset");
|
|
2533
|
+
this.removeAttribute("data-expand-corner");
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Apply the box a grip is asking for.
|
|
2538
|
+
*
|
|
2539
|
+
* The size is the easy half. The other half is that **dragging the edge the
|
|
2540
|
+
* layout is holding still moves the panel as well as resizing it**, and the
|
|
2541
|
+
* layout cannot express that on its own: a floating panel pinned bottom-right
|
|
2542
|
+
* cannot grow rightward, because its right edge is what the placement fixed.
|
|
2543
|
+
* So a grip on a pinned edge takes the position over -- which is the same
|
|
2544
|
+
* ownership the launcher drag takes, written the same way.
|
|
2545
|
+
*
|
|
2546
|
+
* A grip on a free edge writes nothing but the size, exactly as before, so a
|
|
2547
|
+
* host positioning the panel with its own rule keeps that rule until someone
|
|
2548
|
+
* drags the edge it was holding.
|
|
2549
|
+
*/
|
|
2550
|
+
#applyResize(grip: ResizeGrip, box: PanelRect): void {
|
|
2551
|
+
this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
|
|
2552
|
+
if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const viewport = this.#viewport();
|
|
2556
|
+
const anchor = this.#anchor;
|
|
2557
|
+
const side = (value: number): string => `${Math.round(value)}px`;
|
|
2558
|
+
this.style.setProperty(
|
|
2559
|
+
"--ag-ui-inset",
|
|
2560
|
+
[
|
|
2561
|
+
anchor.y === "top" ? side(box.top) : "auto",
|
|
2562
|
+
anchor.x === "right" ? side(viewport.width - box.right) : "auto",
|
|
2563
|
+
anchor.y === "bottom" ? side(viewport.height - box.bottom) : "auto",
|
|
2564
|
+
anchor.x === "left" ? side(box.left) : "auto",
|
|
2565
|
+
].join(" "),
|
|
2566
|
+
);
|
|
2567
|
+
// The launcher lives at this corner of the panel, so a corner that moved
|
|
2568
|
+
// takes it along. Without this the next expand would re-derive the panel's
|
|
2569
|
+
// position from a launcher still standing where the panel used to be, and
|
|
2570
|
+
// undo the move.
|
|
2571
|
+
if (this.#launcherPos !== null) {
|
|
2572
|
+
const size = this.#launcher.offsetWidth;
|
|
2573
|
+
this.#launcherPos = {
|
|
2574
|
+
left: anchor.x === "left" ? box.left : box.right - size,
|
|
2575
|
+
top: anchor.y === "top" ? box.top : box.bottom - size,
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
|
|
2581
|
+
#commitResize(grip: ResizeGrip, box: PanelRect): void {
|
|
2582
|
+
this.#applyResize(grip, box);
|
|
2583
|
+
this.#persistSize({ width: box.right - box.left, height: box.bottom - box.top });
|
|
2584
|
+
this.#storeLauncherPosition();
|
|
2585
|
+
// Re-stamp after the drag: a host whose layout changed underneath us would
|
|
2586
|
+
// otherwise keep the tab-reachable grip in the old corner, which reads as
|
|
2587
|
+
// the control being in the wrong place even though the drag was right.
|
|
2588
|
+
this.#syncResizeAnchor();
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Put the tab stop on the grip diagonally opposite the pinned corner.
|
|
2593
|
+
*
|
|
2594
|
+
* That is the corner a resize grows the panel from, so an arrow key there
|
|
2595
|
+
* changes the size and never the position -- the behaviour the single grip
|
|
2596
|
+
* this replaced had, kept for the one path that cannot simply grab a
|
|
2597
|
+
* different edge.
|
|
2598
|
+
*/
|
|
2599
|
+
#focusableGrip(): void {
|
|
2600
|
+
const free = `${this.#anchor.y === "top" ? "bottom" : "top"}-${
|
|
2601
|
+
this.#anchor.x === "left" ? "right" : "left"
|
|
2602
|
+
}`;
|
|
2603
|
+
for (const [name, handle] of this.#resizeHandles) {
|
|
2604
|
+
const reachable = name === free;
|
|
2605
|
+
handle.tabIndex = reachable ? 0 : -1;
|
|
2606
|
+
if (reachable) {
|
|
2607
|
+
handle.removeAttribute("aria-hidden");
|
|
2608
|
+
} else {
|
|
2609
|
+
handle.setAttribute("aria-hidden", "true");
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2214
2614
|
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
2215
2615
|
#persistSize(size: ResizeSize): void {
|
|
2216
2616
|
const stored = { ...this.#readSize(), ...size };
|
|
@@ -3098,23 +3498,36 @@ export class AgUiChat extends HTMLElement {
|
|
|
3098
3498
|
this.#badge,
|
|
3099
3499
|
);
|
|
3100
3500
|
this.#launcher.addEventListener("click", () => this.setCollapsed(false));
|
|
3501
|
+
// Only while collapsed: the launcher is scaled away and unclickable behind
|
|
3502
|
+
// the open panel, so a drag there would move something nobody can see.
|
|
3503
|
+
enableLauncherDrag(this.#launcher, {
|
|
3504
|
+
enabled: () => this.collapsed && this.#launcherDraggable(),
|
|
3505
|
+
rect: () => this.#launcherBox(),
|
|
3506
|
+
viewport: () => this.#viewport(),
|
|
3507
|
+
apply: (left, top) => this.#moveLauncher(left, top),
|
|
3508
|
+
commit: (left, top) => this.#commitLauncher(left, top),
|
|
3509
|
+
});
|
|
3101
3510
|
|
|
3102
|
-
|
|
3103
|
-
createResizeHandle({
|
|
3511
|
+
for (const grip of RESIZE_GRIPS) {
|
|
3512
|
+
const handle = createResizeHandle(grip, {
|
|
3104
3513
|
axis: () => this.#resizeAxis(),
|
|
3105
|
-
anchor: () => this.#measureAnchor(),
|
|
3106
3514
|
rect: () => this.getBoundingClientRect(),
|
|
3107
|
-
apply: (
|
|
3108
|
-
commit: (
|
|
3109
|
-
this.#persistSize(size);
|
|
3110
|
-
// Re-stamp after the drag: a host whose layout changed underneath us
|
|
3111
|
-
// would otherwise keep the grip in the old corner, which reads as the
|
|
3112
|
-
// control being in the wrong place even though the drag was right.
|
|
3113
|
-
this.#syncResizeAnchor();
|
|
3114
|
-
},
|
|
3515
|
+
apply: (box) => this.#applyResize(grip, box),
|
|
3516
|
+
commit: (box) => this.#commitResize(grip, box),
|
|
3115
3517
|
label: this.#strings.resizePanel,
|
|
3116
|
-
})
|
|
3117
|
-
|
|
3518
|
+
});
|
|
3519
|
+
// Only one of the eight is in the tab order. Eight separators between the
|
|
3520
|
+
// transcript and the composer is not keyboard parity, it is a keyboard
|
|
3521
|
+
// obstacle -- and one grip already reaches both axes, which is exactly
|
|
3522
|
+
// what the single grip this replaced offered. #syncResizeAnchor decides
|
|
3523
|
+
// which one, and it is the free corner, so an arrow key grows the panel
|
|
3524
|
+
// rather than moving it.
|
|
3525
|
+
handle.tabIndex = -1;
|
|
3526
|
+
handle.setAttribute("aria-hidden", "true");
|
|
3527
|
+
this.#resizeHandles.set(gripName(grip), handle);
|
|
3528
|
+
this.#chat.appendChild(handle);
|
|
3529
|
+
}
|
|
3530
|
+
this.#focusableGrip();
|
|
3118
3531
|
this.#adoptStyles();
|
|
3119
3532
|
this.#root.append(this.#announcer, this.#chat, this.#launcher);
|
|
3120
3533
|
}
|
|
@@ -3562,14 +3975,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
3562
3975
|
strings: this.#strings,
|
|
3563
3976
|
// Read at click time, not captured: a bubble rendered from markdown
|
|
3564
3977
|
// holds its text in the DOM, and that is what the user sees and means
|
|
3565
|
-
// to copy.
|
|
3566
|
-
|
|
3978
|
+
// to copy. Serialised rather than read off `textContent`, which welds
|
|
3979
|
+
// a table into one run of digits and picks up the code blocks' own
|
|
3980
|
+
// copy buttons on the way past.
|
|
3981
|
+
...(copyable
|
|
3982
|
+
? {
|
|
3983
|
+
text: () => copyPayload(bubble).text,
|
|
3984
|
+
html: () => copyPayload(bubble).html,
|
|
3985
|
+
}
|
|
3986
|
+
: {}),
|
|
3567
3987
|
...(rateable
|
|
3568
3988
|
? {
|
|
3569
3989
|
onFeedback: (rating: "up" | "down") => {
|
|
3570
3990
|
this.dispatchEvent(
|
|
3571
3991
|
new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
|
|
3572
|
-
detail: { content: bubble.
|
|
3992
|
+
detail: { content: copyPayload(bubble).text, rating },
|
|
3573
3993
|
bubbles: true,
|
|
3574
3994
|
composed: true,
|
|
3575
3995
|
}),
|
|
@@ -3587,7 +4007,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3587
4007
|
/** Move the Retry button onto `bar`, taking it off whoever held it. */
|
|
3588
4008
|
#moveRetryTo(bar: HTMLElement): void {
|
|
3589
4009
|
this.#retryOwner?.querySelector(".message-action--retry")?.remove();
|
|
3590
|
-
const retry = messageActionButton("retry", this.#strings.retryMessage,
|
|
4010
|
+
const retry = messageActionButton("retry", this.#strings.retryMessage, ICON_RETRY);
|
|
3591
4011
|
retry.addEventListener("click", () => {
|
|
3592
4012
|
void this.retryLastTurn();
|
|
3593
4013
|
});
|
|
@@ -3982,7 +4402,18 @@ export class AgUiChat extends HTMLElement {
|
|
|
3982
4402
|
// The server's own words, which the contract keeps to the sub-agent's
|
|
3983
4403
|
// name. Passed through as the status line and set with textContent
|
|
3984
4404
|
// downstream, never parsed as markup.
|
|
3985
|
-
|
|
4405
|
+
//
|
|
4406
|
+
// The message is required by the protocol and can still arrive empty,
|
|
4407
|
+
// which would settle the row to a blank line -- a delegation that reads
|
|
4408
|
+
// as having said nothing rather than as having failed. The fallback was
|
|
4409
|
+
// written and documented in UiStrings and never wired up, so until now
|
|
4410
|
+
// the only reader who knew it existed was the one reading the string
|
|
4411
|
+
// table.
|
|
4412
|
+
this.#closeSubAgent(
|
|
4413
|
+
subagentRunId,
|
|
4414
|
+
SUBAGENT_PHASE.FAILED,
|
|
4415
|
+
message === "" ? this.#strings.subAgentFailed : message,
|
|
4416
|
+
);
|
|
3986
4417
|
},
|
|
3987
4418
|
onMessagesSnapshot: () => {
|
|
3988
4419
|
// Honoured for persistence and announced, not re-rendered.
|
|
@@ -4723,6 +5154,31 @@ function restoredToolCalls(value: unknown): readonly RestoredToolCall[] {
|
|
|
4723
5154
|
return Array.isArray(value) ? value.filter(isRestoredToolCall) : [];
|
|
4724
5155
|
}
|
|
4725
5156
|
|
|
5157
|
+
/**
|
|
5158
|
+
* A pasted file, guaranteed to have a name.
|
|
5159
|
+
*
|
|
5160
|
+
* A file dropped or picked always carries one; a pasted one need not. Some
|
|
5161
|
+
* engines hand over a blob with an empty name, which travels all the way to
|
|
5162
|
+
* the upload as an empty `filename` and lands on the server as a file nobody
|
|
5163
|
+
* can identify -- while the chip in the tray shows an empty label. A file that
|
|
5164
|
+
* already has a name keeps it, including the generic one Chrome gives a pasted
|
|
5165
|
+
* screenshot: it is at least what the file is, and the chip shows the size
|
|
5166
|
+
* beside it.
|
|
5167
|
+
*/
|
|
5168
|
+
function named(file: File): File {
|
|
5169
|
+
if (file.name !== "") {
|
|
5170
|
+
return file;
|
|
5171
|
+
}
|
|
5172
|
+
// The subtype is the extension for every clipboard image type worth naming.
|
|
5173
|
+
// A type with no slash in it falls back to the whole string, and an absent
|
|
5174
|
+
// one leaves a bare stamp rather than a name ending in a dot.
|
|
5175
|
+
const subtype = file.type.split("/")[1] ?? file.type;
|
|
5176
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
5177
|
+
return new File([file], subtype === "" ? `pasted-${stamp}` : `pasted-${stamp}.${subtype}`, {
|
|
5178
|
+
type: file.type,
|
|
5179
|
+
});
|
|
5180
|
+
}
|
|
5181
|
+
|
|
4726
5182
|
/** Whether an unknown history entry has enough shape to render a tool card. */
|
|
4727
5183
|
function isRestoredToolCall(value: unknown): value is RestoredToolCall {
|
|
4728
5184
|
if (typeof value !== "object" || value === null) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Extent, LauncherBox } from "./launcher_placement.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep a launcher fully on screen.
|
|
5
|
+
*
|
|
6
|
+
* Applied to a dragged position and again to a restored one, because the
|
|
7
|
+
* viewport that stored it may since have shrunk. A launcher parked past the
|
|
8
|
+
* edge is unreachable, and it is the only way back to a collapsed
|
|
9
|
+
* conversation -- so this clamps to the viewport itself rather than to the
|
|
10
|
+
* panel's margin, which would refuse the corners users actually want.
|
|
11
|
+
*/
|
|
12
|
+
export function clampLauncher(
|
|
13
|
+
launcher: LauncherBox,
|
|
14
|
+
viewport: Extent,
|
|
15
|
+
): { readonly left: number; readonly top: number } {
|
|
16
|
+
return {
|
|
17
|
+
left: Math.max(0, Math.min(launcher.left, viewport.width - launcher.width)),
|
|
18
|
+
top: Math.max(0, Math.min(launcher.top, viewport.height - launcher.height)),
|
|
19
|
+
};
|
|
20
|
+
}
|