@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.
@@ -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(() => this.#syncResizeAnchor());
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
- this.#applySize({ width: before.width + 1, height: before.height + 1 });
2144
- const after = this.getBoundingClientRect();
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
- x: Math.abs(after.left - before.left) < 0.5 ? "left" : "right",
2151
- y: Math.abs(after.top - before.top) < 0.5 ? "top" : "bottom",
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
- const anchor = this.#measureAnchor();
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
- this.#chat.append(
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: (size) => this.#applySize(size),
3108
- commit: (size) => {
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
- ...(copyable ? { text: () => bubble.textContent as string } : {}),
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.textContent as string, rating },
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, "\u21BB");
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
- this.#closeSubAgent(subagentRunId, SUBAGENT_PHASE.FAILED, message);
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
+ }