@artooi/ag-ui-web-component 0.47.0 → 0.49.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 (60) hide show
  1. package/CHANGELOG.md +205 -1
  2. package/README.md +235 -16
  3. package/dist/ag-ui-web-component.bundle.js +647 -181
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +27 -2
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +44 -5
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/events/bar_height_detail.d.ts +6 -0
  10. package/dist/core/events/bar_height_detail.d.ts.map +1 -0
  11. package/dist/dom/highlight_overlay.d.ts +2 -1
  12. package/dist/dom/highlight_overlay.d.ts.map +1 -1
  13. package/dist/dom/ring_color.d.ts +8 -0
  14. package/dist/dom/ring_color.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1184 -69
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/chat_surface_tools.d.ts +8 -1
  20. package/dist/tools/chat_surface_tools.d.ts.map +1 -1
  21. package/dist/ui/history/conversation_history.d.ts +16 -3
  22. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  23. package/dist/ui/placement/collapse_styles.d.ts.map +1 -1
  24. package/dist/ui/placement/dock_styles.d.ts +2 -0
  25. package/dist/ui/placement/dock_styles.d.ts.map +1 -0
  26. package/dist/ui/placement/panel_placement.d.ts +16 -0
  27. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  28. package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -1
  29. package/dist/ui/placement/surface_for.d.ts +32 -19
  30. package/dist/ui/placement/surface_for.d.ts.map +1 -1
  31. package/dist/ui/progress/tool_group_styles.d.ts.map +1 -1
  32. package/dist/ui/progress/tool_step_styles.d.ts.map +1 -1
  33. package/dist/ui/shell/build_launcher.d.ts +15 -4
  34. package/dist/ui/shell/build_launcher.d.ts.map +1 -1
  35. package/dist/ui/shell/token_styles.d.ts.map +1 -1
  36. package/dist/ui/styles.d.ts.map +1 -1
  37. package/dist/ui/themes/quiet_styles.d.ts.map +1 -1
  38. package/dist/ui/themes/scheme_styles.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/constants.ts +30 -2
  41. package/src/core/ag_ui_chat.ts +335 -28
  42. package/src/core/events/bar_height_detail.ts +5 -0
  43. package/src/dom/highlight_overlay.ts +2 -1
  44. package/src/dom/ring_color.ts +141 -4
  45. package/src/index.ts +4 -0
  46. package/src/tools/chat_surface_tools.ts +13 -5
  47. package/src/ui/history/conversation_history.ts +51 -7
  48. package/src/ui/placement/collapse_styles.ts +248 -2
  49. package/src/ui/placement/dock_styles.ts +95 -0
  50. package/src/ui/placement/panel_placement.ts +300 -16
  51. package/src/ui/placement/small_viewport_styles.ts +9 -0
  52. package/src/ui/placement/surface_for.ts +58 -22
  53. package/src/ui/progress/tool_group_styles.ts +52 -2
  54. package/src/ui/progress/tool_step_styles.ts +5 -3
  55. package/src/ui/shell/build_launcher.ts +32 -5
  56. package/src/ui/shell/token_styles.ts +58 -1
  57. package/src/ui/styles.ts +4 -0
  58. package/src/ui/themes/quiet_styles.ts +19 -3
  59. package/src/ui/themes/scheme_styles.ts +10 -1
  60. package/src/version.ts +1 -1
@@ -0,0 +1,95 @@
1
+ // Where a docked panel stands: above the bottom bar under collapse-to="bar",
2
+ // above the host's anchor under collapse-to="none". Token restatements only;
3
+ // the bar's own shape is in the collapse module, after the launcher it
4
+ // restyles.
5
+ export const DOCK_STYLES: string = `
6
+ /* ── Dock ────────────────────────────────────────────────────────────────────
7
+ A docked panel rises from the bar along the bottom edge, or from the host's
8
+ own control, instead of resting in a corner of its own. The element stamps
9
+ what it collapses to as data-collapsed-to, and writes where the dock is as
10
+ two private properties: --_dock-bottom, the bar's height or the distance
11
+ from the layout viewport's bottom to the anchor's top, and --_dock-right,
12
+ the anchor's distance from the right edge. Both are read with a 0px
13
+ fallback, so a panel measured before the bar has laid out stands where a
14
+ bar of no height would put it.
15
+
16
+ The cap follows the token block's rule: what the host left free less the
17
+ one gutter the anchored edge spends, so a panel grown to the cap runs from
18
+ just above the bar to the far edge. A host therefore needs no inset to keep
19
+ the panel off its bar; --ag-ui-inset still wins when set. A panel the user
20
+ dragged off its dock is written there inline, which is how the dragged spot
21
+ outranks every rule below, the anchor gap included; the element holds that
22
+ spot above the bar or the anchor itself, and drops it on a phone, where the
23
+ small-viewport block reads the same property first.
24
+
25
+ These blocks restate tokens and nothing else, and sit ahead of the phone
26
+ block at no more weight than it has, so on a phone the panel is the screen
27
+ as every other placement is there. The bar measures 0 while it is not drawn
28
+ (a phone with the panel open), which is what lets the side and full shapes
29
+ below fall back to the whole height without a phone rule of their own.
30
+
31
+ Each placement keeps its own side and shape; only the bottom edge moves. */
32
+ :host([data-collapsed-to="bar"]) {
33
+ --_inset: var(
34
+ --ag-ui-inset,
35
+ auto calc(var(--_edge-gutter) + var(--_viewport-inset-right))
36
+ calc(
37
+ var(--_edge-gutter) + var(--_viewport-inset-bottom) +
38
+ max(var(--_dock-bottom, 0px), var(--_keyboard-inset))
39
+ )
40
+ auto
41
+ );
42
+ --_max-height: var(--ag-ui-max-height, calc(var(--_viewport-height) - var(--_dock-bottom, 0px) - var(--_edge-gutter)));
43
+ }
44
+
45
+ :host([data-collapsed-to="bar"][placement="bottom-left"]) {
46
+ --_inset: var(
47
+ --ag-ui-inset,
48
+ auto auto
49
+ calc(
50
+ var(--_edge-gutter) + var(--_viewport-inset-bottom) +
51
+ max(var(--_dock-bottom, 0px), var(--_keyboard-inset))
52
+ )
53
+ calc(var(--_edge-gutter) + var(--_viewport-inset-left))
54
+ );
55
+ }
56
+
57
+ /* The column and the full-screen panel stop at the bar rather than covering
58
+ it, so the bar stays where the user minimises from. */
59
+ :host([data-collapsed-to="bar"][placement="side"]) {
60
+ --_inset: var(
61
+ --ag-ui-inset,
62
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
63
+ calc(var(--_viewport-inset-bottom) + var(--_dock-bottom, 0px)) auto
64
+ );
65
+ --_height: var(--ag-ui-height, calc(var(--_viewport-height) - var(--_dock-bottom, 0px)));
66
+ --_max-height: var(--ag-ui-max-height, calc(var(--_viewport-height) - var(--_dock-bottom, 0px)));
67
+ }
68
+
69
+ :host([data-collapsed-to="bar"][placement="full"]) {
70
+ --_inset: var(
71
+ --ag-ui-inset,
72
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
73
+ calc(var(--_viewport-inset-bottom) + var(--_dock-bottom, 0px)) var(--_viewport-inset-left)
74
+ );
75
+ --_height: var(--ag-ui-height, calc(var(--_viewport-height) - var(--_dock-bottom, 0px)));
76
+ --_max-height: var(--ag-ui-max-height, calc(var(--_viewport-height) - var(--_dock-bottom, 0px)));
77
+ }
78
+
79
+ /* The host's own bar: the panel stands the anchor gap (8px unless the host
80
+ says otherwise) above the anchor and lines up with its right edge. Only
81
+ once the element found the anchor; until then, and when it never does, the
82
+ placement's own corner stands.
83
+
84
+ The cap spends the larger of the gap and the gutter. The gutter alone is
85
+ what this always spent, so the default is unchanged; but a host that opens
86
+ the gap past the gutter to clear a tall toolbar would otherwise push a
87
+ panel grown to the cap off the top of the window by the difference. */
88
+ :host([data-collapsed-to="none"][data-dock-anchored]) {
89
+ --_inset: var(--ag-ui-inset, auto var(--_dock-right, 0px) calc(var(--_dock-bottom, 0px) + var(--_anchor-gap)) auto);
90
+ --_max-height: var(
91
+ --ag-ui-max-height,
92
+ calc(var(--_viewport-height) - var(--_dock-bottom, 0px) - max(var(--_edge-gutter), var(--_anchor-gap)))
93
+ );
94
+ }
95
+ `;
@@ -22,7 +22,7 @@ import {
22
22
  type ResizeGrip,
23
23
  type ResizeSize,
24
24
  } from "./resize_handle.js";
25
- import { surfaceFor } from "./surface_for.js";
25
+ import { type Surface, surfaceFor } from "./surface_for.js";
26
26
 
27
27
  /** Per-tab persistence key for a dragged panel size. */
28
28
  const SIZE_KEY = "ag-ui-chat:size";
@@ -37,10 +37,28 @@ const SIZE_KEY = "ag-ui-chat:size";
37
37
  */
38
38
  const DRAG_WIDTH = "--_drag-width";
39
39
  const DRAG_HEIGHT = "--_drag-height";
40
+ /**
41
+ * How far above the bottom of the layout viewport a docked panel stands, and
42
+ * how far in from its right edge: the bar's height, or the host anchor's top
43
+ * and right. Private and written inline, like the dragged size, and read only
44
+ * by the dock rules; never an inline --_inset, which would outrank the phone
45
+ * block and keep a docked panel off full-bleed on a phone.
46
+ */
47
+ const DOCK_BOTTOM = "--_dock-bottom";
48
+ const DOCK_RIGHT = "--_dock-right";
40
49
 
41
50
  /** Per-tab persistence key for a dragged launcher position. */
42
51
  const LAUNCHER_KEY = "ag-ui-chat:launcher";
43
52
 
53
+ /**
54
+ * Persistence key for where the user dragged a docked panel. Its own key, not
55
+ * the launcher's: a spot stored under the corner launcher describes another
56
+ * surface, and reading it here would open every panel away from the bar for a
57
+ * host that switched to the bar after its users had dragged their corner
58
+ * panels.
59
+ */
60
+ const DOCK_KEY = "ag-ui-chat:dock";
61
+
44
62
  /** A stored `{ left, top }` pair, or null for anything that is not one. */
45
63
  function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
46
64
  if (typeof value !== "object" || value === null) {
@@ -148,7 +166,7 @@ export class PanelPlacement {
148
166
  */
149
167
  enablePanelDrag(header: HTMLElement): void {
150
168
  enablePanelDrag(header, {
151
- enabled: () => !this.#host.collapsed() && this.#launcherDraggable(),
169
+ enabled: () => !this.#host.collapsed() && this.#canDragPanel(),
152
170
  rect: () => this.#host.element.getBoundingClientRect(),
153
171
  apply: (box, from) => this.#movePanel(box, from),
154
172
  commit: (box, from) => this.#commitPanel(box, from),
@@ -204,11 +222,23 @@ export class PanelPlacement {
204
222
  * Drop the dragged size and its stored copy, so the placement's own size and
205
223
  * the host's tokens apply again. For a host entering a sized state of its
206
224
  * own, which a user's drag would otherwise outrank.
225
+ *
226
+ * A docked panel the user dragged off its dock goes back to it as well, and
227
+ * the stored spot with it: there the position is part of the same resting
228
+ * state the host is asking for. A corner panel keeps a dragged position, as
229
+ * it always has.
207
230
  */
208
231
  resetSize(): void {
209
232
  this.#host.element.style.removeProperty(DRAG_WIDTH);
210
233
  this.#host.element.style.removeProperty(DRAG_HEIGHT);
211
234
  this.#host.clearPreference(SIZE_KEY);
235
+ if (this.#docked()) {
236
+ if (this.#dockPos !== null) {
237
+ this.#host.element.style.removeProperty("--ag-ui-inset");
238
+ }
239
+ this.#dockPos = null;
240
+ this.#storeDockPosition(null);
241
+ }
212
242
  // The anchor was measured against the dragged box; the resting one may
213
243
  // pin different edges.
214
244
  this.syncResizeAnchor();
@@ -241,6 +271,25 @@ export class PanelPlacement {
241
271
  */
242
272
  #panelPos: { readonly left: number; readonly top: number } | null = null;
243
273
 
274
+ /**
275
+ * Where the user dragged a docked panel, in viewport coordinates, or null
276
+ * while it stands on the bar or the anchor.
277
+ *
278
+ * Apart from the two corner positions above because it moves nothing else:
279
+ * a docked panel has no launcher to carry, and the corner it opens from is
280
+ * the dock's. Held as the user stated it and never as clamped, so a bar that
281
+ * grew, or a phone that took the panel full-bleed, gives the spot back
282
+ * unchanged once there is room for it again.
283
+ */
284
+ #dockPos: { readonly left: number; readonly top: number } | null = null;
285
+
286
+ /**
287
+ * The line a docked panel stays above, in viewport coordinates: the bar's
288
+ * top, or the anchor's. Infinite where neither is drawn. Measured by
289
+ * syncDock, which runs on every change either can make.
290
+ */
291
+ #dockTop = Number.POSITIVE_INFINITY;
292
+
244
293
  /**
245
294
  * The corner the panel opens away from, once this element is placing itself.
246
295
  * Null means the host's layout still decides, and the anchor is measured.
@@ -272,7 +321,10 @@ export class PanelPlacement {
272
321
  * rather than collapsed to nothing.
273
322
  */
274
323
  #withinViewport(box: PanelRect): PanelRect {
275
- const viewport = this.#viewport();
324
+ // A docked panel that moves has its room stop at the dock, so no grip
325
+ // pulls it over the bar it minimises to. A placement that owns its
326
+ // position keeps the bound it always had.
327
+ const viewport = this.#docked() && this.#panelMovable() ? this.#dockRoom() : this.#viewport();
276
328
  // The same bound a drag stops at, so a grip pulled to the edge and a panel
277
329
  // dragged to it come to rest on the same line. The inner Math.max/min pair
278
330
  // keeps an already-inverted box from turning inside out.
@@ -309,10 +361,17 @@ export class PanelPlacement {
309
361
  const fullBleed = coversViewport(box, viewport);
310
362
  return {
311
363
  placement: this.#host.element.getAttribute("placement"),
364
+ collapseTo: this.#surface().collapseTo,
312
365
  collapsed: this.#host.collapsed(),
313
366
  collapsible: this.#host.collapsible(),
314
- movable: this.#launcherDraggable() && !fullBleed,
315
- draggable: this.#launcherDraggable(),
367
+ movable: this.#panelMovable() && !fullBleed,
368
+ // Whether the page left moving on, which is not the same question as
369
+ // whether this panel moves: a docked sidebar's position is owned by its
370
+ // placement, and reporting that as the host having turned moving off is
371
+ // the sentence the move refusal would then get wrong.
372
+ draggable:
373
+ this.#host.element.getAttribute("data-launcher-drag") !== "false" &&
374
+ (this.#surface().launcherDraggable || this.#docked()),
316
375
  fullBleed,
317
376
  box: {
318
377
  left: Math.round(box.left),
@@ -331,7 +390,7 @@ export class PanelPlacement {
331
390
 
332
391
  /** The move behind `AgUiChat.moveTo`, whose doc is the contract. */
333
392
  moveTo(corner: ChatCorner, options: { readonly announce?: boolean } = {}): boolean {
334
- if (!this.#launcherDraggable()) {
393
+ if (!this.#panelMovable()) {
335
394
  return false;
336
395
  }
337
396
  const restore = options.announce === true ? this.#captureGeometry() : null;
@@ -341,23 +400,31 @@ export class PanelPlacement {
341
400
  return false;
342
401
  }
343
402
  const [edgeY, edgeX] = corner.split("-");
403
+ // A docked panel's bottom corners are above its dock, not on the bar.
404
+ const docked = this.#docked();
405
+ const room = docked ? this.#dockRoom() : viewport;
344
406
  // Every term is an absolute screen coordinate, because that is what the
345
407
  // clamps and the insets both speak. The usable box carries an origin, so
346
408
  // its near edge is `viewport.left`, not zero, and its far edge is
347
409
  // `viewport.left + viewport.width` -- a margin applied to the extents
348
410
  // alone would send the agent's own move under the chrome the host
349
411
  // reserved, which is the failure the usable box exists to prevent.
350
- const nearX = viewport.left + EDGE_MARGIN;
351
- const nearY = viewport.top + EDGE_MARGIN;
412
+ const nearX = room.left + EDGE_MARGIN;
413
+ const nearY = room.top + EDGE_MARGIN;
352
414
  const left =
353
- edgeX === "left"
354
- ? nearX
355
- : Math.max(nearX, viewport.left + viewport.width - EDGE_MARGIN - box.width);
415
+ edgeX === "left" ? nearX : Math.max(nearX, room.left + room.width - EDGE_MARGIN - box.width);
356
416
  const top =
357
- edgeY === "top"
358
- ? nearY
359
- : Math.max(nearY, viewport.top + viewport.height - EDGE_MARGIN - box.height);
417
+ edgeY === "top" ? nearY : Math.max(nearY, room.top + room.height - EDGE_MARGIN - box.height);
360
418
  const host = { left, top, right: left + box.width, bottom: top + box.height };
419
+ if (docked) {
420
+ this.#dockPos = { left, top };
421
+ this.#writeDockInset(host);
422
+ this.#storeDockPosition(this.#dockPos);
423
+ if (restore !== null) {
424
+ this.#host.announceSurfaceChange(this.#host.strings().chatMoved, restore);
425
+ }
426
+ return true;
427
+ }
361
428
  // Both axes measured, rather than one read twice: a host can restyle the
362
429
  // launcher as a pill, and squaring it here would put it off the corner.
363
430
  const launcherWidth = this.#host.launcher.offsetWidth;
@@ -391,6 +458,7 @@ export class PanelPlacement {
391
458
  const corner = this.#host.element.getAttribute("data-expand-corner");
392
459
  const launcherPos = this.#launcherPos;
393
460
  const panelPos = this.#panelPos;
461
+ const dockPos = this.#dockPos;
394
462
  const expandCorner = this.#expandCorner;
395
463
  return () => {
396
464
  this.#restoreProperty("--ag-ui-inset", inset);
@@ -402,7 +470,15 @@ export class PanelPlacement {
402
470
  }
403
471
  this.#launcherPos = launcherPos;
404
472
  this.#panelPos = panelPos;
473
+ this.#dockPos = dockPos;
405
474
  this.#expandCorner = expandCorner;
475
+ if (this.#docked()) {
476
+ // The dock's own record, and only that: the launcher's belongs to the
477
+ // corner surface and has nothing to undo here.
478
+ this.#storeDockPosition(dockPos);
479
+ this.syncResizeAnchor();
480
+ return;
481
+ }
406
482
  // Erased rather than rewritten when there was nothing to go back to.
407
483
  // #storeLauncherPosition returns early for a null position, which would
408
484
  // leave the move this is undoing sitting in storage -- and since that
@@ -425,7 +501,7 @@ export class PanelPlacement {
425
501
  * per interaction, because `placement` is a live attribute.
426
502
  */
427
503
  #resizeAxis(): ResizeAxis {
428
- return surfaceFor(this.#host.element.getAttribute("placement")).resizeAxis;
504
+ return this.#surface().resizeAxis;
429
505
  }
430
506
 
431
507
  /**
@@ -567,6 +643,165 @@ export class PanelPlacement {
567
643
  }
568
644
  }
569
645
 
646
+ /** The surface the live placement and collapse-to attributes describe. */
647
+ #surface(): Surface {
648
+ const element = this.#host.element;
649
+ return surfaceFor(element.getAttribute("placement"), element.getAttribute("collapse-to"));
650
+ }
651
+
652
+ /**
653
+ * Whether the panel rises from the bottom bar or the host's anchor, until the
654
+ * user drags it somewhere else.
655
+ */
656
+ #docked(): boolean {
657
+ const target = this.#surface().collapseTo;
658
+ return target === "bar" || target === "none";
659
+ }
660
+
661
+ /**
662
+ * Write where a docked panel stands, and return the bar's height.
663
+ *
664
+ * Under the bar it is the strip's border box, which is 0 whenever the strip
665
+ * is not drawn (a phone with the panel open, where the panel is the screen).
666
+ * Under a host anchor it is the anchor's top and right, measured against the
667
+ * layout viewport a fixed element is placed in; an anchor of null leaves the
668
+ * panel in its placement's own corner. Every other surface carries neither
669
+ * property, so nothing stale is left behind by a live switch.
670
+ */
671
+ syncDock(bar: HTMLElement, anchor: Element | null): number {
672
+ const element = this.#host.element;
673
+ const target = this.#surface().collapseTo;
674
+ const height = target === "bar" ? bar.offsetHeight : 0;
675
+ if (target === "bar") {
676
+ element.style.setProperty(DOCK_BOTTOM, `${height}px`);
677
+ } else if (target === "none" && anchor !== null) {
678
+ const rect = anchor.getBoundingClientRect();
679
+ const screen = this.#screen();
680
+ element.style.setProperty(DOCK_BOTTOM, `${Math.max(0, screen.height - rect.top)}px`);
681
+ element.style.setProperty(DOCK_RIGHT, `${Math.max(0, screen.width - rect.right)}px`);
682
+ } else {
683
+ element.style.removeProperty(DOCK_BOTTOM);
684
+ }
685
+ if (target !== "none" || anchor === null) {
686
+ element.style.removeProperty(DOCK_RIGHT);
687
+ }
688
+ element.toggleAttribute("data-dock-anchored", target === "none" && anchor !== null);
689
+ this.#dockTop =
690
+ target === "bar" && height > 0
691
+ ? bar.getBoundingClientRect().top
692
+ : target === "none" && anchor !== null
693
+ ? anchor.getBoundingClientRect().top
694
+ : Number.POSITIVE_INFINITY;
695
+ // Every change the dock can make passes through here -- the bar's height
696
+ // arrives by a ResizeObserver after the first restore, and a bar that grew
697
+ // since the drag would otherwise cover the panel's foot -- so the dragged
698
+ // spot is held against it here rather than only when it is restored.
699
+ this.#placeDocked();
700
+ return height;
701
+ }
702
+
703
+ /**
704
+ * The part of the usable viewport a docked panel may stand in: all of it
705
+ * above the dock's line. The bar is what it minimises to, and the anchor is
706
+ * the host's own way back, so a dragged panel never covers either.
707
+ */
708
+ #dockRoom(): ViewportBox {
709
+ const viewport = this.#viewport();
710
+ const bottom = Math.min(viewport.top + viewport.height, this.#dockTop);
711
+ return { ...viewport, height: Math.max(0, bottom - viewport.top) };
712
+ }
713
+
714
+ /**
715
+ * Put a docked panel back where the user dragged it, held inside its room.
716
+ *
717
+ * The inline inset comes off first, which is both how the panel's resting
718
+ * box is measured and what leaves a phone full-bleed: the small-viewport
719
+ * block reads the host's inset before its own, so a dragged spot written
720
+ * there would hold the panel off the screen's edges. The spot itself stays,
721
+ * for the next time there is room for it.
722
+ */
723
+ #placeDocked(): void {
724
+ if (!this.#docked() || !this.#panelMovable() || this.dragging()) {
725
+ return;
726
+ }
727
+ const at = this.#dockPos ?? this.#readDockPosition();
728
+ if (at === null) {
729
+ return;
730
+ }
731
+ this.#dockPos = at;
732
+ this.#host.element.style.removeProperty("--ag-ui-inset");
733
+ const rest = this.#host.element.getBoundingClientRect();
734
+ if (coversViewport(rest, this.#viewport())) {
735
+ return;
736
+ }
737
+ // Measured at the dock, so the inset below is written from the corner the
738
+ // dock holds still and a resize grows the panel away from it.
739
+ this.syncResizeAnchor();
740
+ this.#writeDockInset(
741
+ clampPanel(
742
+ { left: at.left, top: at.top, right: at.left + rest.width, bottom: at.top + rest.height },
743
+ this.#dockRoom(),
744
+ SCREEN_EDGE_MARGIN,
745
+ ),
746
+ );
747
+ }
748
+
749
+ /** Write the inset that stands a docked panel at `box`. */
750
+ #writeDockInset(box: PanelRect): void {
751
+ this.#host.element.style.setProperty(
752
+ "--ag-ui-inset",
753
+ insetFrom(this.#anchor, box, this.#screen()),
754
+ );
755
+ }
756
+
757
+ /** Remember a docked panel's dragged spot, or forget it for null. */
758
+ #storeDockPosition(at: { readonly left: number; readonly top: number } | null): void {
759
+ if (at === null) {
760
+ this.#host.clearPreference(DOCK_KEY);
761
+ return;
762
+ }
763
+ this.#host.writePreference(DOCK_KEY, JSON.stringify({ left: at.left, top: at.top }));
764
+ }
765
+
766
+ /** The stored spot of a docked panel, or null for none or anything corrupt. */
767
+ #readDockPosition(): { readonly left: number; readonly top: number } | null {
768
+ const raw = this.#host.readPreference(DOCK_KEY);
769
+ if (raw === null) {
770
+ return null;
771
+ }
772
+ try {
773
+ return asPoint(JSON.parse(raw));
774
+ } catch {
775
+ return null;
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Whether the header drag and moveTo may move the open panel: the
781
+ * placement's answer, unless the host turned moving off.
782
+ */
783
+ #panelMovable(): boolean {
784
+ return (
785
+ this.#host.element.getAttribute("data-launcher-drag") !== "false" &&
786
+ this.#surface().panelMovable
787
+ );
788
+ }
789
+
790
+ /**
791
+ * Whether a header drag may start now. A docked panel on a phone is the
792
+ * screen, and a drag there would pull it off the edges it fills; a corner
793
+ * panel's header is left as it was.
794
+ */
795
+ #canDragPanel(): boolean {
796
+ if (!this.#panelMovable()) {
797
+ return false;
798
+ }
799
+ return (
800
+ !this.#docked() ||
801
+ !coversViewport(this.#host.element.getBoundingClientRect(), this.#viewport())
802
+ );
803
+ }
804
+
570
805
  /**
571
806
  * Whether this placement lets the launcher be dragged, read per interaction
572
807
  * because `placement` is a live attribute. `data-launcher-drag="false"` opts
@@ -575,7 +810,7 @@ export class PanelPlacement {
575
810
  #launcherDraggable(): boolean {
576
811
  return (
577
812
  this.#host.element.getAttribute("data-launcher-drag") !== "false" &&
578
- surfaceFor(this.#host.element.getAttribute("placement")).launcherDraggable
813
+ this.#surface().launcherDraggable
579
814
  );
580
815
  }
581
816
 
@@ -810,6 +1045,13 @@ export class PanelPlacement {
810
1045
  * other still names the old one would move the launcher for no reason.
811
1046
  */
812
1047
  #movePanel(box: PanelRect, from: PanelRect): { held: PanelRect; launcher: LauncherBox | null } {
1048
+ if (this.#docked()) {
1049
+ // Inside the room above the dock, with nothing carried along.
1050
+ const held = clampPanel(box, this.#dockRoom(), SCREEN_EDGE_MARGIN);
1051
+ this.#dockPos = { left: held.left, top: held.top };
1052
+ this.#writeDockInset(held);
1053
+ return { held, launcher: null };
1054
+ }
813
1055
  if (!this.#launcherDraggable()) {
814
1056
  return { held: box, launcher: null };
815
1057
  }
@@ -883,6 +1125,10 @@ export class PanelPlacement {
883
1125
  // again. Recomputing it is how the two came apart: releasing the drag
884
1126
  // moved the bubble by the panel's whole travel a second time.
885
1127
  const { held, launcher } = this.#movePanel(box, from);
1128
+ if (this.#docked()) {
1129
+ this.#storeDockPosition({ left: held.left, top: held.top });
1130
+ return;
1131
+ }
886
1132
  if (launcher === null) {
887
1133
  return;
888
1134
  }
@@ -973,6 +1219,13 @@ export class PanelPlacement {
973
1219
  * unreachable -- it is the only way back to a collapsed conversation.
974
1220
  */
975
1221
  restoreLauncherPosition(): void {
1222
+ // Before the launcher's record is read at all: it belongs to the corner
1223
+ // surface, and seeding anything from it would put a spot dragged there on
1224
+ // a panel that should stand on its dock.
1225
+ if (this.#docked()) {
1226
+ this.#placeDocked();
1227
+ return;
1228
+ }
976
1229
  const stored = this.#readLauncherPosition();
977
1230
  const launcher = this.#launcherPos ?? stored;
978
1231
  if (launcher === null) {
@@ -1030,6 +1283,13 @@ export class PanelPlacement {
1030
1283
  * the switch inline and pins a sidebar to wherever the floating launcher was.
1031
1284
  */
1032
1285
  releaseLauncherPosition(): void {
1286
+ // A dragged dock spot belongs to a docked panel that moves. Any other
1287
+ // surface hands it back; a docked one keeps it, and syncDock holds it
1288
+ // against the new dock.
1289
+ if (this.#dockPos !== null && !(this.#docked() && this.#panelMovable())) {
1290
+ this.#dockPos = null;
1291
+ this.#host.element.style.removeProperty("--ag-ui-inset");
1292
+ }
1033
1293
  if (this.#launcherDraggable()) {
1034
1294
  return;
1035
1295
  }
@@ -1057,7 +1317,28 @@ export class PanelPlacement {
1057
1317
  */
1058
1318
  #applyResize(grip: ResizeGrip, box: PanelRect): PanelRect {
1059
1319
  box = this.#withinViewport(box);
1320
+ if (this.#docked() && this.#dockPos === null) {
1321
+ // A panel standing on its dock keeps it through a resize: the edges it
1322
+ // stands on are the bar's or the anchor's, and a grip on one of them
1323
+ // would write the position below and take the panel off its dock. So
1324
+ // the pinned edges stay where they are and only the free ones follow.
1325
+ // Once dragged off, it resizes like a corner panel, below.
1326
+ const now = this.#host.element.getBoundingClientRect();
1327
+ box = {
1328
+ left: grip.x === this.#anchor.x ? now.left : box.left,
1329
+ right: grip.x === this.#anchor.x ? now.right : box.right,
1330
+ top: grip.y === this.#anchor.y ? now.top : box.top,
1331
+ bottom: grip.y === this.#anchor.y ? now.bottom : box.bottom,
1332
+ };
1333
+ this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
1334
+ return box;
1335
+ }
1060
1336
  this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
1337
+ if (this.#docked()) {
1338
+ // Any grip moves the top-left corner the spot is kept as, including one
1339
+ // on a free edge, which leaves the inset alone.
1340
+ this.#dockPos = { left: box.left, top: box.top };
1341
+ }
1061
1342
  if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
1062
1343
  return box;
1063
1344
  }
@@ -1093,6 +1374,9 @@ export class PanelPlacement {
1093
1374
  const held = this.#applyResize(grip, box);
1094
1375
  this.#persistSize({ width: held.right - held.left, height: held.bottom - held.top });
1095
1376
  this.#storeLauncherPosition();
1377
+ if (this.#dockPos !== null) {
1378
+ this.#storeDockPosition(this.#dockPos);
1379
+ }
1096
1380
  // Re-stamp after the drag: a host whose layout changed underneath us would
1097
1381
  // otherwise keep the tab-reachable grip in the old corner, which reads as
1098
1382
  // the control being in the wrong place even though the drag was right.
@@ -57,5 +57,14 @@ export const SMALL_VIEWPORT_STYLES: string = `
57
57
  :host([placement=""]:not([data-small-viewport="off"])) .resize-handle {
58
58
  display: none;
59
59
  }
60
+
61
+ /* The bottom bar shows only while collapsed. With the panel open the panel
62
+ is the screen, its header's minimise control is the way back, and a
63
+ second strip fixed to the bottom edge would sit under the keyboard while
64
+ the composer has focus. Hidden by display, so the bar measures 0 and the
65
+ host's padding goes with it. */
66
+ :host([data-collapsed-to="bar"]:not([collapsed]):not([data-small-viewport="off"])) .dock-bar {
67
+ display: none;
68
+ }
60
69
  }
61
70
  `;