@artooi/ag-ui-web-component 0.33.0 → 0.34.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/src/constants.ts CHANGED
@@ -380,6 +380,14 @@ export const ICON_FILE_TEXT = `<svg class="glyph" viewBox="0 0 24 24" aria-hidde
380
380
  */
381
381
  export const CHART_ACTIVITY_TYPE = "chart";
382
382
 
383
+ /**
384
+ * The gutter a floating panel keeps from the viewport edge.
385
+ *
386
+ * It matches the default `--ag-ui-inset`, so an undragged widget resolves to
387
+ * exactly the placement it already had. Changing it moves every clamped panel.
388
+ */
389
+ export const EDGE_MARGIN = 24;
390
+
383
391
  /**
384
392
  * How long a screen-reader status stays in the announcer before it is emptied.
385
393
  *
@@ -58,6 +58,7 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
58
58
  import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
59
59
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
60
60
  import { clampLauncher } from "../ui/clamp_launcher.js";
61
+ import { clampPanel } from "../ui/clamp_panel.js";
61
62
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
62
63
  import { copyPayload } from "../ui/copy_payload.js";
63
64
  import { enableLauncherDrag } from "../ui/launcher_drag.js";
@@ -73,6 +74,8 @@ import {
73
74
  messageActionButton,
74
75
  } from "../ui/message_actions.js";
75
76
  import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
77
+ import { enablePanelDrag } from "../ui/panel_drag.js";
78
+ import { placeWidget } from "../ui/place_widget.js";
76
79
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
77
80
  import {
78
81
  type QuestionRenderer,
@@ -316,6 +319,15 @@ const THEME_KEY = "ag-ui-chat:theme";
316
319
  /** Per-tab persistence key for a dragged launcher position. */
317
320
  const LAUNCHER_KEY = "ag-ui-chat:launcher";
318
321
 
322
+ /** A stored `{ left, top }` pair, or null for anything that is not one. */
323
+ function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
324
+ if (typeof value !== "object" || value === null) {
325
+ return null;
326
+ }
327
+ const { left, top } = value as { left?: unknown; top?: unknown };
328
+ return typeof left === "number" && typeof top === "number" ? { left, top } : null;
329
+ }
330
+
319
331
  /**
320
332
  * Placements whose launcher can be dragged. The rest have nowhere to put it:
321
333
  * a sidebar collapses to a full-height edge rail, "embedded" and "page" hide
@@ -818,6 +830,19 @@ export class AgUiChat extends HTMLElement {
818
830
  */
819
831
  #launcherPos: { readonly left: number; readonly top: number } | null = null;
820
832
 
833
+ /**
834
+ * Where the user dragged the *panel*, in viewport coordinates, or null while
835
+ * its position is still derived from the launcher's.
836
+ *
837
+ * The two gestures state different things and are restored differently. A
838
+ * launcher drag says where the bubble goes and leaves the panel to open into
839
+ * whatever space the viewport has, so it is re-derived every time -- which is
840
+ * what lets a widget re-decide its direction when the window changes under
841
+ * it. A header drag states the panel's own position, and re-deriving that
842
+ * from the launcher would move the panel the user just placed.
843
+ */
844
+ #panelPos: { readonly left: number; readonly top: number } | null = null;
845
+
821
846
  /**
822
847
  * The corner the panel opens away from, once this element is placing itself.
823
848
  * Null means the host's layout still decides, and the anchor is measured.
@@ -2444,6 +2469,10 @@ export class AgUiChat extends HTMLElement {
2444
2469
  return;
2445
2470
  }
2446
2471
  this.#launcherPos = at;
2472
+ // Dropping the bubble hands the panel's position back to the placement.
2473
+ // Keeping a stated one would pin the panel where it was dragged and leave
2474
+ // the launcher deriving nothing, which is the gesture doing half its job.
2475
+ this.#panelPos = null;
2447
2476
  // The host box keeps its expanded size while collapsed, so its own rect is
2448
2477
  // the panel's size in either state and needs no separate bookkeeping.
2449
2478
  const panel = this.getBoundingClientRect();
@@ -2471,13 +2500,146 @@ export class AgUiChat extends HTMLElement {
2471
2500
  this.#storeLauncherPosition();
2472
2501
  }
2473
2502
 
2474
- /** Write the current launcher position, if this element owns one. */
2503
+ /**
2504
+ * Move the panel live during a header drag, without persisting.
2505
+ *
2506
+ * Only the host box is written, and that is the whole trick: the launcher is
2507
+ * positioned *inside* that box, so leaving its own inset alone carries it
2508
+ * along by exactly the distance the panel travelled -- which is what a person
2509
+ * dragging a window expects of the thing it collapses into. Placing it on the
2510
+ * panel's pinned corner instead, as an earlier version did, sent it leaping
2511
+ * across the panel the moment the drag re-picked that corner.
2512
+ *
2513
+ * The corner is therefore held for the length of the gesture. Both insets are
2514
+ * measured from it, and rewriting one of them from a new corner while the
2515
+ * other still names the old one would move the launcher for no reason.
2516
+ */
2517
+ #movePanel(box: PanelRect): PanelRect {
2518
+ if (!this.#launcherDraggable()) {
2519
+ return box;
2520
+ }
2521
+ // Where the launcher rests, recorded before the first move writes anything.
2522
+ // From here on the DOM shows it mid-gesture, so this is the last moment it
2523
+ // can be read rather than derived.
2524
+ if (this.#launcherPos === null) {
2525
+ const resting = this.#launcherBox();
2526
+ this.#launcherPos = { left: resting.left, top: resting.top };
2527
+ }
2528
+ const held = clampPanel(box, this.#viewport());
2529
+ const corner = this.#expandCorner ?? this.#anchor;
2530
+ const viewport = this.#viewport();
2531
+ this.style.setProperty(
2532
+ "--ag-ui-inset",
2533
+ [
2534
+ corner.y === "top" ? `${Math.round(held.top)}px` : "auto",
2535
+ corner.x === "right" ? `${Math.round(viewport.width - held.right)}px` : "auto",
2536
+ corner.y === "bottom" ? `${Math.round(viewport.height - held.bottom)}px` : "auto",
2537
+ corner.x === "left" ? `${Math.round(held.left)}px` : "auto",
2538
+ ].join(" "),
2539
+ );
2540
+ this.#panelPos = { left: held.left, top: held.top };
2541
+ return held;
2542
+ }
2543
+
2544
+ /**
2545
+ * Finish a header drag: settle where both halves ended up, and remember it.
2546
+ *
2547
+ * The launcher travels the distance the panel actually travelled, which is
2548
+ * the clamped distance rather than the pointer's -- a panel held against the
2549
+ * viewport margin stops, and so does the bubble attached to it. Measured from
2550
+ * the box the press started on, so a long drag cannot accumulate the rounding
2551
+ * each move writes into the inset.
2552
+ *
2553
+ * Only now is the corner re-picked, from where the launcher has ended up, so
2554
+ * the panel opens into clear space next time. Re-picking it moves nothing:
2555
+ * both insets are rewritten from positions that are already decided.
2556
+ */
2557
+ #commitPanel(box: PanelRect, from: PanelRect): void {
2558
+ const held = this.#movePanel(box);
2559
+ const start = this.#launcherPos;
2560
+ if (!this.#launcherDraggable() || start === null) {
2561
+ return;
2562
+ }
2563
+ const carried = {
2564
+ ...this.#launcherBox(),
2565
+ left: start.left + (held.left - from.left),
2566
+ top: start.top + (held.top - from.top),
2567
+ };
2568
+ // The screen is the last word: a bubble dragged past the edge is one nobody
2569
+ // can click, and it is the only way back to a collapsed conversation.
2570
+ this.#placePanelAndLauncher(held, {
2571
+ ...carried,
2572
+ ...clampLauncher(carried, this.#viewport()),
2573
+ });
2574
+ this.#storeLauncherPosition();
2575
+ }
2576
+
2577
+ /**
2578
+ * Write both insets for a panel and launcher that are already positioned,
2579
+ * re-picking the corner they are measured from.
2580
+ */
2581
+ #placePanelAndLauncher(host: PanelRect, launcher: LauncherBox): void {
2582
+ const viewport = this.#viewport();
2583
+ const size = { width: host.right - host.left, height: host.bottom - host.top };
2584
+ const { corner } = launcherPlacement(launcher, size, viewport);
2585
+ const insets = placeWidget(host, launcher, corner, viewport);
2586
+ this.style.setProperty("--ag-ui-inset", insets.hostInset);
2587
+ this.style.setProperty("--ag-ui-launcher-inset", insets.launcherInset);
2588
+ this.#launcherPos = { left: launcher.left, top: launcher.top };
2589
+ this.#panelPos = { left: host.left, top: host.top };
2590
+ this.#expandCorner = corner;
2591
+ this.setAttribute("data-expand-corner", `${corner.y}-${corner.x}`);
2592
+ this.#syncResizeAnchor();
2593
+ }
2594
+
2595
+ /**
2596
+ * Re-apply a panel position the user stated, against the current viewport.
2597
+ *
2598
+ * The launcher keeps its offset from the panel through the clamp -- it was
2599
+ * put where it is relative to the panel, and a viewport that has since shrunk
2600
+ * is no reason to move one without the other -- and is then held on screen in
2601
+ * its own right.
2602
+ */
2603
+ #restorePanelPosition(at: { readonly left: number; readonly top: number }): void {
2604
+ if (!this.#launcherDraggable()) {
2605
+ return;
2606
+ }
2607
+ const rect = this.getBoundingClientRect();
2608
+ const held = clampPanel(
2609
+ { left: at.left, top: at.top, right: at.left + rect.width, bottom: at.top + rect.height },
2610
+ this.#viewport(),
2611
+ );
2612
+ const launcher = this.#launcherBox();
2613
+ const carried = {
2614
+ ...launcher,
2615
+ left: launcher.left + (held.left - at.left),
2616
+ top: launcher.top + (held.top - at.top),
2617
+ };
2618
+ this.#placePanelAndLauncher(held, {
2619
+ ...carried,
2620
+ ...clampLauncher(carried, this.#viewport()),
2621
+ });
2622
+ }
2623
+
2624
+ /**
2625
+ * Write the current position, if this element owns one.
2626
+ *
2627
+ * The panel's own position rides along only when the user stated it, because
2628
+ * its presence is what tells a restore which of the two gestures to honour:
2629
+ * with it, the panel goes back where it was put; without it, the panel is
2630
+ * re-derived from the launcher and opens into whatever room the viewport has
2631
+ * now.
2632
+ */
2475
2633
  #storeLauncherPosition(): void {
2476
2634
  const position = this.#launcherPos;
2477
2635
  if (position === null) {
2478
2636
  return;
2479
2637
  }
2480
- writeStoredItem(this.#storageKey(LAUNCHER_KEY), JSON.stringify(position));
2638
+ const panel = this.#panelPos;
2639
+ writeStoredItem(
2640
+ this.#storageKey(LAUNCHER_KEY),
2641
+ JSON.stringify(panel === null ? position : { ...position, panel }),
2642
+ );
2481
2643
  }
2482
2644
 
2483
2645
  /**
@@ -2487,18 +2649,32 @@ export class AgUiChat extends HTMLElement {
2487
2649
  * unreachable -- it is the only way back to a collapsed conversation.
2488
2650
  */
2489
2651
  #restoreLauncherPosition(): void {
2490
- const stored = this.#launcherPos ?? this.#readLauncherPosition();
2491
- if (stored === null) {
2652
+ const stored = this.#readLauncherPosition();
2653
+ const launcher = this.#launcherPos ?? stored;
2654
+ if (launcher === null) {
2655
+ return;
2656
+ }
2657
+ const panel = this.#panelPos ?? stored?.panel ?? null;
2658
+ if (panel !== null) {
2659
+ // Stated rather than derived: the launcher is only read here to keep the
2660
+ // offset the two were left with, so it has to be seeded before the panel
2661
+ // is placed around it.
2662
+ this.#launcherPos = { left: launcher.left, top: launcher.top };
2663
+ this.#restorePanelPosition(panel);
2492
2664
  return;
2493
2665
  }
2494
2666
  const box = this.#launcherBox();
2495
2667
  this.#applyLauncherPlacement(
2496
- clampLauncher({ ...box, left: stored.left, top: stored.top }, this.#viewport()),
2668
+ clampLauncher({ ...box, left: launcher.left, top: launcher.top }, this.#viewport()),
2497
2669
  );
2498
2670
  }
2499
2671
 
2500
- /** The persisted launcher position for this instance, or null. */
2501
- #readLauncherPosition(): { readonly left: number; readonly top: number } | null {
2672
+ /** The persisted position for this instance, or null. */
2673
+ #readLauncherPosition(): {
2674
+ readonly left: number;
2675
+ readonly top: number;
2676
+ readonly panel?: { readonly left: number; readonly top: number };
2677
+ } | null {
2502
2678
  const raw = this.#readScopedItem(LAUNCHER_KEY);
2503
2679
  if (raw === null) {
2504
2680
  return null;
@@ -2508,8 +2684,15 @@ export class AgUiChat extends HTMLElement {
2508
2684
  if (typeof parsed !== "object" || parsed === null) {
2509
2685
  return null;
2510
2686
  }
2511
- const { left, top } = parsed as { left?: unknown; top?: unknown };
2512
- return typeof left === "number" && typeof top === "number" ? { left, top } : null;
2687
+ const { left, top, panel } = parsed as { left?: unknown; top?: unknown; panel?: unknown };
2688
+ if (typeof left !== "number" || typeof top !== "number") {
2689
+ return null;
2690
+ }
2691
+ // A record written before the panel could be dragged has no panel half,
2692
+ // and one written by a launcher drag never will -- both restore by
2693
+ // deriving the panel, which is what they meant.
2694
+ const at = asPoint(panel);
2695
+ return at === null ? { left, top } : { left, top, panel: at };
2513
2696
  } catch {
2514
2697
  // A corrupt entry is not worth failing a mount over; fall back to the
2515
2698
  // placement's own corner.
@@ -2527,6 +2710,7 @@ export class AgUiChat extends HTMLElement {
2527
2710
  return;
2528
2711
  }
2529
2712
  this.#launcherPos = null;
2713
+ this.#panelPos = null;
2530
2714
  this.#expandCorner = null;
2531
2715
  this.style.removeProperty("--ag-ui-inset");
2532
2716
  this.style.removeProperty("--ag-ui-launcher-inset");
@@ -2575,6 +2759,10 @@ export class AgUiChat extends HTMLElement {
2575
2759
  top: anchor.y === "top" ? box.top : box.bottom - size,
2576
2760
  };
2577
2761
  }
2762
+ // A stated panel position is a claim about this box, so it moves with it.
2763
+ if (this.#panelPos !== null) {
2764
+ this.#panelPos = { left: box.left, top: box.top };
2765
+ }
2578
2766
  }
2579
2767
 
2580
2768
  /** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
@@ -3289,6 +3477,16 @@ export class AgUiChat extends HTMLElement {
3289
3477
  controls.append(collapse);
3290
3478
  header.append(title, headerActions, controls);
3291
3479
 
3480
+ // A panel is a window and a header is its title bar. Only while open: a
3481
+ // collapsed widget has no header on screen, and the launcher is the handle
3482
+ // then.
3483
+ enablePanelDrag(header, {
3484
+ enabled: () => !this.collapsed && this.#launcherDraggable(),
3485
+ rect: () => this.getBoundingClientRect(),
3486
+ apply: (box) => this.#movePanel(box),
3487
+ commit: (box, from) => this.#commitPanel(box, from),
3488
+ });
3489
+
3292
3490
  this.#messages.className = "messages";
3293
3491
  this.#messages.setAttribute("part", "messages");
3294
3492
  this.#messages.setAttribute("role", "log");