@artooi/ag-ui-web-component 0.38.0 → 0.40.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 (142) hide show
  1. package/CHANGELOG.md +515 -1
  2. package/README.md +232 -56
  3. package/dist/ag-ui-web-component.bundle.js +370 -139
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +17 -14
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/activity_registry.d.ts +46 -0
  8. package/dist/core/activity_registry.d.ts.map +1 -0
  9. package/dist/core/ag_ui_chat.d.ts +23 -10
  10. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  11. package/dist/core/agui_client.d.ts +28 -0
  12. package/dist/core/agui_client.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +27 -0
  14. package/dist/core/client_seed.d.ts.map +1 -0
  15. package/dist/core/read_max_tool_rounds.d.ts +9 -0
  16. package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
  17. package/dist/core/run_handlers.d.ts +80 -0
  18. package/dist/core/run_handlers.d.ts.map +1 -0
  19. package/dist/core/storage_scope.d.ts +110 -0
  20. package/dist/core/storage_scope.d.ts.map +1 -0
  21. package/dist/core/tool_outcome.d.ts +2 -3
  22. package/dist/core/tool_outcome.d.ts.map +1 -1
  23. package/dist/core/utils.d.ts +46 -0
  24. package/dist/core/utils.d.ts.map +1 -1
  25. package/dist/index.js +16268 -14747
  26. package/dist/index.js.map +4 -4
  27. package/dist/skills/skill_catalog.d.ts +67 -0
  28. package/dist/skills/skill_catalog.d.ts.map +1 -0
  29. package/dist/skills/skill_name_from.d.ts +12 -0
  30. package/dist/skills/skill_name_from.d.ts.map +1 -0
  31. package/dist/tools/tool_catalog.d.ts +79 -0
  32. package/dist/tools/tool_catalog.d.ts.map +1 -0
  33. package/dist/tools/tool_dispatch.d.ts +104 -0
  34. package/dist/tools/tool_dispatch.d.ts.map +1 -0
  35. package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
  36. package/dist/ui/composer/auto_grow.d.ts +10 -0
  37. package/dist/ui/composer/auto_grow.d.ts.map +1 -0
  38. package/dist/ui/composer/composer_attachments.d.ts +57 -0
  39. package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
  40. package/dist/ui/composer/composer_voice.d.ts +48 -0
  41. package/dist/ui/composer/composer_voice.d.ts.map +1 -0
  42. package/dist/ui/composer/voice_input.d.ts.map +1 -1
  43. package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
  44. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
  45. package/dist/ui/fill_ui_string.d.ts +32 -0
  46. package/dist/ui/fill_ui_string.d.ts.map +1 -0
  47. package/dist/ui/history/conversation_history.d.ts +203 -0
  48. package/dist/ui/history/conversation_history.d.ts.map +1 -0
  49. package/dist/ui/history/relative_time.d.ts.map +1 -1
  50. package/dist/ui/history/thread_drawer.d.ts +0 -14
  51. package/dist/ui/history/thread_drawer.d.ts.map +1 -1
  52. package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
  53. package/dist/ui/interrupts/pending_decision.d.ts +23 -0
  54. package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
  55. package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
  56. package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
  57. package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
  58. package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
  59. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  60. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  61. package/dist/ui/placement/panel_placement.d.ts +119 -0
  62. package/dist/ui/placement/panel_placement.d.ts.map +1 -0
  63. package/dist/ui/progress/run_announcer.d.ts +41 -0
  64. package/dist/ui/progress/run_announcer.d.ts.map +1 -0
  65. package/dist/ui/progress/subagent_progress.d.ts +58 -0
  66. package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
  67. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  68. package/dist/ui/shell/adopt_styles.d.ts +23 -0
  69. package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
  70. package/dist/ui/shell/glyph_slot.d.ts +7 -0
  71. package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
  72. package/dist/ui/shell/header_button.d.ts +10 -0
  73. package/dist/ui/shell/header_button.d.ts.map +1 -0
  74. package/dist/ui/shell/icon_element.d.ts +12 -0
  75. package/dist/ui/shell/icon_element.d.ts.map +1 -0
  76. package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
  77. package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
  78. package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
  79. package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
  80. package/dist/ui/styles.d.ts +1 -1
  81. package/dist/ui/styles.d.ts.map +1 -1
  82. package/dist/ui/transcript/answer_actions.d.ts +39 -0
  83. package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
  84. package/dist/ui/transcript/answer_stream.d.ts +56 -0
  85. package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
  86. package/dist/ui/transcript/render_or_warn.d.ts +16 -0
  87. package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
  88. package/dist/ui/transcript/starter_chips.d.ts +21 -0
  89. package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
  90. package/dist/ui/transcript/transcript.d.ts +171 -0
  91. package/dist/ui/transcript/transcript.d.ts.map +1 -0
  92. package/dist/ui/ui_strings.d.ts +29 -1
  93. package/dist/ui/ui_strings.d.ts.map +1 -1
  94. package/package.json +1 -1
  95. package/src/constants.ts +17 -15
  96. package/src/core/activity_registry.ts +133 -0
  97. package/src/core/ag_ui_chat.ts +945 -4419
  98. package/src/core/agui_client.ts +172 -16
  99. package/src/core/client_seed.ts +27 -0
  100. package/src/core/read_max_tool_rounds.ts +13 -0
  101. package/src/core/remote_conversation_store.ts +1 -1
  102. package/src/core/run_handlers.ts +501 -0
  103. package/src/core/storage_scope.ts +279 -0
  104. package/src/core/tool_outcome.ts +5 -3
  105. package/src/core/utils.ts +109 -0
  106. package/src/skills/skill_catalog.ts +166 -0
  107. package/src/skills/skill_name_from.ts +19 -0
  108. package/src/tools/tool_catalog.ts +354 -0
  109. package/src/tools/tool_dispatch.ts +521 -0
  110. package/src/ui/composer/attachment_tray.ts +6 -5
  111. package/src/ui/composer/auto_grow.ts +12 -0
  112. package/src/ui/composer/composer_attachments.ts +324 -0
  113. package/src/ui/composer/composer_voice.ts +93 -0
  114. package/src/ui/composer/voice_input.ts +4 -4
  115. package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
  116. package/src/ui/fill_ui_string.ts +41 -0
  117. package/src/ui/history/conversation_history.ts +699 -0
  118. package/src/ui/history/relative_time.ts +5 -4
  119. package/src/ui/history/thread_drawer.ts +3 -50
  120. package/src/ui/interrupts/confirmation_card.ts +4 -2
  121. package/src/ui/interrupts/pending_decision.ts +34 -0
  122. package/src/ui/placement/is_collapsible_placement.ts +19 -0
  123. package/src/ui/placement/is_draggable_placement.ts +23 -0
  124. package/src/ui/placement/launcher_drag.ts +104 -89
  125. package/src/ui/placement/panel_placement.ts +1130 -0
  126. package/src/ui/progress/run_announcer.ts +67 -0
  127. package/src/ui/progress/subagent_progress.ts +201 -0
  128. package/src/ui/progress/tool_call_card.ts +2 -0
  129. package/src/ui/shell/adopt_styles.ts +28 -0
  130. package/src/ui/shell/glyph_slot.ts +12 -0
  131. package/src/ui/shell/header_button.ts +21 -0
  132. package/src/ui/shell/icon_element.ts +33 -0
  133. package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
  134. package/src/ui/shell/read_launcher_icon_url.ts +9 -0
  135. package/src/ui/styles.ts +301 -70
  136. package/src/ui/transcript/answer_actions.ts +139 -0
  137. package/src/ui/transcript/answer_stream.ts +123 -0
  138. package/src/ui/transcript/render_or_warn.ts +22 -0
  139. package/src/ui/transcript/starter_chips.ts +42 -0
  140. package/src/ui/transcript/transcript.ts +429 -0
  141. package/src/ui/ui_strings.ts +38 -1
  142. package/src/version.ts +1 -1
@@ -0,0 +1,1130 @@
1
+ import { EDGE_MARGIN, SCREEN_EDGE_MARGIN } from "../../constants.js";
2
+ import type { ChatCorner, ChatSurfaceReport } from "../../tools/chat_surface_tools.js";
3
+ import type { UiStrings } from "../ui_strings.js";
4
+ import { clampLauncher } from "./clamp_launcher.js";
5
+ import { clampPanel } from "./clamp_panel.js";
6
+ import { isDraggablePlacement } from "./is_draggable_placement.js";
7
+ import { enableLauncherDrag } from "./launcher_drag.js";
8
+ import {
9
+ type ExpandCorner,
10
+ type Extent,
11
+ type LauncherBox,
12
+ launcherPlacement,
13
+ type ViewportBox,
14
+ } from "./launcher_placement.js";
15
+ import { enablePanelDrag } from "./panel_drag.js";
16
+ import { placeWidget } from "./place_widget.js";
17
+ import {
18
+ createResizeHandle,
19
+ gripName,
20
+ type PanelRect,
21
+ type ResizeAnchor,
22
+ type ResizeAxis,
23
+ type ResizeGrip,
24
+ type ResizeSize,
25
+ } from "./resize_handle.js";
26
+
27
+ /** Per-tab persistence key for a dragged panel size. */
28
+ const SIZE_KEY = "ag-ui-chat:size";
29
+
30
+ /** Per-tab persistence key for a dragged launcher position. */
31
+ const LAUNCHER_KEY = "ag-ui-chat:launcher";
32
+
33
+ /** A stored `{ left, top }` pair, or null for anything that is not one. */
34
+ function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
35
+ if (typeof value !== "object" || value === null) {
36
+ return null;
37
+ }
38
+ const { left, top } = value as { left?: unknown; top?: unknown };
39
+ return typeof left === "number" && typeof top === "number" ? { left, top } : null;
40
+ }
41
+
42
+ /**
43
+ * Whether a box covers the usable viewport, which is what full-bleed means.
44
+ *
45
+ * A pixel of slack on each axis, because a box sized from `100vw` and one sized
46
+ * from the visual viewport disagree by sub-pixel rounding, and a panel that
47
+ * fills the screen must not read as one that could still be moved within it.
48
+ */
49
+ function coversViewport(box: Extent, viewport: Extent): boolean {
50
+ return box.width >= viewport.width - 1 && box.height >= viewport.height - 1;
51
+ }
52
+
53
+ /**
54
+ * The `--ag-ui-inset` value that pins `box` by `corner`.
55
+ *
56
+ * Only the two sides the corner names get a length; the other two are `auto`,
57
+ * so the box keeps its size and grows away from that corner. Right and bottom
58
+ * are measured from `screen`, the layout viewport, because that is what the
59
+ * browser measures a fixed element's inset from.
60
+ */
61
+ function insetFrom(corner: ResizeAnchor, box: PanelRect, screen: Extent): string {
62
+ const side = (value: number): string => `${Math.round(value)}px`;
63
+ return [
64
+ corner.y === "top" ? side(box.top) : "auto",
65
+ corner.x === "right" ? side(screen.width - box.right) : "auto",
66
+ corner.y === "bottom" ? side(screen.height - box.bottom) : "auto",
67
+ corner.x === "left" ? side(box.left) : "auto",
68
+ ].join(" ");
69
+ }
70
+
71
+ /**
72
+ * Every edge and corner the panel can be dragged by. Corners last, so they sit
73
+ * above the edge strips they overlap and win the pointer at the corners.
74
+ */
75
+ const RESIZE_GRIPS: readonly ResizeGrip[] = [
76
+ { y: "top" },
77
+ { y: "bottom" },
78
+ { x: "left" },
79
+ { x: "right" },
80
+ { x: "left", y: "top" },
81
+ { x: "right", y: "top" },
82
+ { x: "left", y: "bottom" },
83
+ { x: "right", y: "bottom" },
84
+ ];
85
+
86
+ /**
87
+ * What the placement needs from the element that owns it.
88
+ *
89
+ * Everything the element keeps private is reached through a thunk here, the
90
+ * same shape `PanelDragOptions` and `ResizeOptions` already give their hosts:
91
+ * read per call, so a value the element replaces later (the resolved string
92
+ * table, the connected flag) is never captured stale.
93
+ */
94
+ export interface PlacementHost {
95
+ /** The custom element itself: its inline style, attributes and box. */
96
+ readonly element: HTMLElement;
97
+ /** The collapsed widget's button, which a drag moves and a resize carries. */
98
+ readonly launcher: HTMLButtonElement;
99
+ /** The shadow root, searched for a header mid-drag. */
100
+ readonly root: ShadowRoot;
101
+ /** Whether the element is in the document. */
102
+ readonly connected: () => boolean;
103
+ /** Whether the widget is collapsed. */
104
+ readonly collapsed: () => boolean;
105
+ /** Whether the current placement has a collapsed state at all. */
106
+ readonly collapsible: () => boolean;
107
+ /** The resolved string table. */
108
+ readonly strings: () => UiStrings;
109
+ /** Read a layout preference, durable store first. */
110
+ readonly readPreference: (base: string) => string | null;
111
+ /** Persist a layout preference to both stores. */
112
+ readonly writePreference: (base: string, value: string) => void;
113
+ /** Drop a layout preference from both stores. */
114
+ readonly clearPreference: (base: string) => void;
115
+ /** Tell the reader the agent moved their window, with the way back. */
116
+ readonly announceSurfaceChange: (text: string, undo: () => void) => void;
117
+ }
118
+
119
+ /**
120
+ * Where the panel and its launcher sit, and how big the panel is: the dragged
121
+ * launcher, the dragged header, the eight resize grips, the corner the panel
122
+ * opens from, and the persisted record of all of it.
123
+ *
124
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
125
+ * instance, so two elements on a page place themselves independently.
126
+ */
127
+ export class PanelPlacement {
128
+ readonly #host: PlacementHost;
129
+
130
+ constructor(host: PlacementHost) {
131
+ this.#host = host;
132
+ }
133
+
134
+ /**
135
+ * Make the header a title bar. Only while open: a collapsed widget has no
136
+ * header on screen, and the launcher is the handle then.
137
+ */
138
+ enablePanelDrag(header: HTMLElement): void {
139
+ enablePanelDrag(header, {
140
+ enabled: () => !this.#host.collapsed() && this.#launcherDraggable(),
141
+ rect: () => this.#host.element.getBoundingClientRect(),
142
+ apply: (box, from) => this.#movePanel(box, from),
143
+ commit: (box, from) => this.#commitPanel(box, from),
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Let the launcher be dragged. Only while collapsed: the launcher is scaled
149
+ * away and unclickable behind the open panel, so a drag there would move
150
+ * something nobody can see.
151
+ */
152
+ enableLauncherDrag(signal: AbortSignal): void {
153
+ enableLauncherDrag(this.#host.launcher, {
154
+ signal,
155
+ enabled: () => this.#host.collapsed() && this.#launcherDraggable(),
156
+ rect: () => this.#launcherBox(),
157
+ viewport: () => this.#viewport(),
158
+ apply: (left, top) => this.#moveLauncher(left, top),
159
+ commit: (left, top) => this.#commitLauncher(left, top),
160
+ });
161
+ }
162
+
163
+ /** Build the eight resize grips into `chat`, with one of them in the tab order. */
164
+ mountResizeGrips(chat: HTMLElement): void {
165
+ for (const grip of RESIZE_GRIPS) {
166
+ const handle = createResizeHandle(grip, {
167
+ axis: () => this.#resizeAxis(),
168
+ rect: () => this.#host.element.getBoundingClientRect(),
169
+ apply: (box) => this.#applyResize(grip, box),
170
+ commit: (box) => this.#commitResize(grip, box),
171
+ label: this.#host.strings().resizePanel,
172
+ });
173
+ // Only one of the eight is in the tab order. Eight separators between the
174
+ // transcript and the composer is not keyboard parity, it is a keyboard
175
+ // obstacle -- and one grip already reaches both axes, which is exactly
176
+ // what the single grip this replaced offered. syncResizeAnchor decides
177
+ // which one, and it is the free corner, so an arrow key grows the panel
178
+ // rather than moving it.
179
+ handle.tabIndex = -1;
180
+ handle.setAttribute("aria-hidden", "true");
181
+ this.#resizeHandles.set(gripName(grip), handle);
182
+ chat.appendChild(handle);
183
+ }
184
+ this.#focusableGrip();
185
+ }
186
+
187
+ /** Restore a dragged size before the panel paints. */
188
+ restoreSize(): void {
189
+ this.#applySize(this.#readSize());
190
+ }
191
+
192
+ /**
193
+ * A zero-sized box carrying the host's viewport insets as padding, so they
194
+ * can be read back as used pixel lengths. See the `.viewport-probe` rule for
195
+ * why a custom property cannot be read directly.
196
+ */
197
+ readonly probe = document.createElement("div");
198
+
199
+ /**
200
+ * Where the user dragged the launcher, in viewport coordinates, or null
201
+ * while the host's own CSS still places it. Set means this element owns its
202
+ * position -- see #applyLauncherPlacement for what that costs the host.
203
+ */
204
+ #launcherPos: { readonly left: number; readonly top: number } | null = null;
205
+
206
+ /**
207
+ * Where the user dragged the *panel*, in viewport coordinates, or null while
208
+ * its position is still derived from the launcher's.
209
+ *
210
+ * The two gestures state different things and are restored differently. A
211
+ * launcher drag says where the bubble goes and leaves the panel to open into
212
+ * whatever space the viewport has, so it is re-derived every time -- which is
213
+ * what lets a widget re-decide its direction when the window changes under
214
+ * it. A header drag states the panel's own position, and re-deriving that
215
+ * from the launcher would move the panel the user just placed.
216
+ */
217
+ #panelPos: { readonly left: number; readonly top: number } | null = null;
218
+
219
+ /**
220
+ * The corner the panel opens away from, once this element is placing itself.
221
+ * Null means the host's layout still decides, and the anchor is measured.
222
+ */
223
+ #expandCorner: ExpandCorner | null = null;
224
+
225
+ /**
226
+ * The edges the layout is holding still, as last measured. Cached because a
227
+ * resize reads it per pointer move and measuring forces a reflow -- thirty a
228
+ * second while the panel is already being laid out on every one of them.
229
+ */
230
+ #anchor: ResizeAnchor = { x: "right", y: "bottom" };
231
+
232
+ /** The eight grips, by name, so the keyboard-reachable one can be moved. */
233
+ readonly #resizeHandles = new Map<string, HTMLDivElement>();
234
+
235
+ /**
236
+ * Hold a resized box inside the part of the screen the host left free.
237
+ *
238
+ * Each edge on its own, unlike the drag's clamp: a drag moves a box of fixed
239
+ * size, so pushing it back in is right, while a resize is anchored on the
240
+ * opposite edge and pushing it back would move the edge the user is not
241
+ * touching. Bounding each edge instead leaves the grip stopped at the limit
242
+ * -- the gesture keeps going and the panel simply stops growing, which is
243
+ * what dragging already does.
244
+ *
245
+ * The minimum size is the grip's own concern and is applied before this, so
246
+ * a panel that cannot fit the space is left at its minimum and overflowing
247
+ * rather than collapsed to nothing.
248
+ */
249
+ #withinViewport(box: PanelRect): PanelRect {
250
+ const viewport = this.#viewport();
251
+ // The same bound a drag stops at, so a grip pulled to the edge and a panel
252
+ // dragged to it come to rest on the same line. The inner Math.max/min pair
253
+ // keeps an already-inverted box from turning inside out.
254
+ const left = viewport.left + SCREEN_EDGE_MARGIN;
255
+ const top = viewport.top + SCREEN_EDGE_MARGIN;
256
+ const right = viewport.left + viewport.width - SCREEN_EDGE_MARGIN;
257
+ const bottom = viewport.top + viewport.height - SCREEN_EDGE_MARGIN;
258
+ return {
259
+ left: Math.min(Math.max(box.left, left), box.right),
260
+ top: Math.min(Math.max(box.top, top), box.bottom),
261
+ right: Math.max(Math.min(box.right, right), box.left),
262
+ bottom: Math.max(Math.min(box.bottom, bottom), box.top),
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Whether a pointer or key gesture is currently placing the widget.
268
+ *
269
+ * Read from the stamp the drag helpers already set, rather than tracked
270
+ * separately: one source of truth, and it clears on `pointercancel` as well
271
+ * as `pointerup`, which is the end a touch gesture usually gets.
272
+ */
273
+ dragging(): boolean {
274
+ return (
275
+ this.#host.launcher.hasAttribute("data-dragging") ||
276
+ this.#host.root.querySelector(".header[data-dragging]") !== null
277
+ );
278
+ }
279
+
280
+ /** The report behind `AgUiChat.describeSurface`, whose doc is the contract. */
281
+ describeSurface(): ChatSurfaceReport {
282
+ const box = this.#host.element.getBoundingClientRect();
283
+ const viewport = this.#viewport();
284
+ const fullBleed = coversViewport(box, viewport);
285
+ return {
286
+ placement: this.#host.element.getAttribute("placement"),
287
+ collapsed: this.#host.collapsed(),
288
+ collapsible: this.#host.collapsible(),
289
+ movable: this.#launcherDraggable() && !fullBleed,
290
+ draggable: this.#launcherDraggable(),
291
+ fullBleed,
292
+ box: {
293
+ left: Math.round(box.left),
294
+ top: Math.round(box.top),
295
+ width: Math.round(box.width),
296
+ height: Math.round(box.height),
297
+ },
298
+ viewport: {
299
+ left: Math.round(viewport.left),
300
+ top: Math.round(viewport.top),
301
+ width: Math.round(viewport.width),
302
+ height: Math.round(viewport.height),
303
+ },
304
+ };
305
+ }
306
+
307
+ /** The move behind `AgUiChat.moveTo`, whose doc is the contract. */
308
+ moveTo(corner: ChatCorner, options: { readonly announce?: boolean } = {}): boolean {
309
+ if (!this.#launcherDraggable()) {
310
+ return false;
311
+ }
312
+ const restore = options.announce === true ? this.#captureGeometry() : null;
313
+ const viewport = this.#viewport();
314
+ const box = this.#host.element.getBoundingClientRect();
315
+ if (coversViewport(box, viewport)) {
316
+ return false;
317
+ }
318
+ const [edgeY, edgeX] = corner.split("-");
319
+ // Every term is an absolute screen coordinate, because that is what the
320
+ // clamps and the insets both speak. The usable box carries an origin, so
321
+ // its near edge is `viewport.left`, not zero, and its far edge is
322
+ // `viewport.left + viewport.width` -- a margin applied to the extents
323
+ // alone would send the agent's own move under the chrome the host
324
+ // reserved, which is the failure the usable box exists to prevent.
325
+ const nearX = viewport.left + EDGE_MARGIN;
326
+ const nearY = viewport.top + EDGE_MARGIN;
327
+ const left =
328
+ edgeX === "left"
329
+ ? nearX
330
+ : Math.max(nearX, viewport.left + viewport.width - EDGE_MARGIN - box.width);
331
+ const top =
332
+ edgeY === "top"
333
+ ? nearY
334
+ : Math.max(nearY, viewport.top + viewport.height - EDGE_MARGIN - box.height);
335
+ const host = { left, top, right: left + box.width, bottom: top + box.height };
336
+ // Both axes measured, rather than one read twice: a host can restyle the
337
+ // launcher as a pill, and squaring it here would put it off the corner.
338
+ const launcherWidth = this.#host.launcher.offsetWidth;
339
+ const launcherHeight = this.#host.launcher.offsetHeight;
340
+ this.#placePanelAndLauncher(host, {
341
+ left: edgeX === "left" ? host.left : host.right - launcherWidth,
342
+ top: edgeY === "top" ? host.top : host.bottom - launcherHeight,
343
+ width: launcherWidth,
344
+ height: launcherHeight,
345
+ });
346
+ this.#storeLauncherPosition();
347
+ if (restore !== null) {
348
+ this.#host.announceSurfaceChange(this.#host.strings().chatMoved, restore);
349
+ }
350
+ return true;
351
+ }
352
+
353
+ /**
354
+ * Snapshot the panel's stated position, and return a function that puts it
355
+ * back.
356
+ *
357
+ * Both insets and the expand corner, because they are one decision: the
358
+ * corner is what the panel grows from, so restoring a position without it
359
+ * puts the box back and animates it out of the wrong side. Absent values are
360
+ * captured as absent and removed on the way back, rather than written as
361
+ * empty strings that would outrank the placement.
362
+ */
363
+ #captureGeometry(): () => void {
364
+ const inset = this.#host.element.style.getPropertyValue("--ag-ui-inset");
365
+ const launcherInset = this.#host.element.style.getPropertyValue("--ag-ui-launcher-inset");
366
+ const corner = this.#host.element.getAttribute("data-expand-corner");
367
+ const launcherPos = this.#launcherPos;
368
+ const panelPos = this.#panelPos;
369
+ const expandCorner = this.#expandCorner;
370
+ return () => {
371
+ this.#restoreProperty("--ag-ui-inset", inset);
372
+ this.#restoreProperty("--ag-ui-launcher-inset", launcherInset);
373
+ if (corner === null) {
374
+ this.#host.element.removeAttribute("data-expand-corner");
375
+ } else {
376
+ this.#host.element.setAttribute("data-expand-corner", corner);
377
+ }
378
+ this.#launcherPos = launcherPos;
379
+ this.#panelPos = panelPos;
380
+ this.#expandCorner = expandCorner;
381
+ // Erased rather than rewritten when there was nothing to go back to.
382
+ // #storeLauncherPosition returns early for a null position, which would
383
+ // leave the move this is undoing sitting in storage -- and since that
384
+ // store outlives the tab, the next resize or reload would quietly put
385
+ // the panel back in the corner the user had just rejected.
386
+ if (launcherPos === null) {
387
+ this.#host.clearPreference(LAUNCHER_KEY);
388
+ } else {
389
+ this.#storeLauncherPosition();
390
+ }
391
+ this.syncResizeAnchor();
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Which axes the current placement allows.
397
+ *
398
+ * A full-bleed layout is `100vw`/`100vh` by definition and cannot be resized
399
+ * at all; a docked panel owns its height, leaving only its inner edge. Read
400
+ * per interaction, because `placement` is a live attribute.
401
+ */
402
+ #resizeAxis(): ResizeAxis {
403
+ switch (this.#host.element.getAttribute("placement")) {
404
+ case "full":
405
+ case "page":
406
+ return "none";
407
+ case "sidebar":
408
+ case "side":
409
+ return "width";
410
+ default:
411
+ return "both";
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Which edges the layout is holding still, by measuring rather than guessing:
417
+ * nudge the size by a pixel, see which edges stayed put, and undo. One forced
418
+ * reflow per drag.
419
+ *
420
+ * `placement` cannot answer this — an embedded panel goes wherever the page's
421
+ * CSS puts it — and see {@link createResizeHandle} for why guessing produces
422
+ * a visibly broken control.
423
+ */
424
+ #measureAnchor(): ResizeAnchor {
425
+ const before = this.#host.element.getBoundingClientRect();
426
+ const width = this.#host.element.style.getPropertyValue("--ag-ui-width");
427
+ const height = this.#host.element.style.getPropertyValue("--ag-ui-height");
428
+ // Shrink first. Growing is the obvious probe and cannot answer the question
429
+ // at a size already resting against max-width or max-height: the box does
430
+ // not change, no edge moves, and every clamped axis then reads as pinned on
431
+ // the side that did not move -- which is the side that is free. That is not
432
+ // an edge case. The default panel is 380px wide against a max-width of
433
+ // 100vw minus 48, so any viewport under 428px is born clamped, and the grip
434
+ // rendered on the wrong corner with the drag inverted before anyone touched
435
+ // it. Shrinking always moves an edge, because the shrink is measured from
436
+ // the box's *used* width rather than from whatever was asked for.
437
+ const shrunk = this.#probeAnchor(before, -1);
438
+ // Unless a host rule sets a minimum, in which case that axis is asked the
439
+ // opposite question rather than left to a guess.
440
+ const grown = shrunk.x === null || shrunk.y === null ? this.#probeAnchor(before, 1) : shrunk;
441
+ // Restore exactly what was there, including "nothing" — leaving a probe
442
+ // value behind would pin a panel that had been sizing itself.
443
+ this.#restoreProperty("--ag-ui-width", width);
444
+ this.#restoreProperty("--ag-ui-height", height);
445
+ return {
446
+ // Neither direction moved it: the axis cannot be resized at all, so the
447
+ // floating default is the best answer available and is the one the
448
+ // stylesheet would have used with no measurement at all.
449
+ x: shrunk.x ?? grown.x ?? "right",
450
+ y: shrunk.y ?? grown.y ?? "bottom",
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Which edge each axis holds still when the panel changes size by `delta`.
456
+ *
457
+ * Null for an axis whose size did not change: nothing moved, so nothing was
458
+ * learned, and reporting the unmoved edge as the pinned one would be exactly
459
+ * backwards.
460
+ */
461
+ #probeAnchor(
462
+ before: DOMRect,
463
+ delta: number,
464
+ ): { x: "left" | "right" | null; y: "top" | "bottom" | null } {
465
+ this.#applySize({ width: before.width + delta, height: before.height + delta });
466
+ const after = this.#host.element.getBoundingClientRect();
467
+ const moved = (a: number, b: number): boolean => Math.abs(a - b) >= 0.5;
468
+ return {
469
+ x: moved(after.width, before.width)
470
+ ? moved(after.left, before.left)
471
+ ? "right"
472
+ : "left"
473
+ : null,
474
+ y: moved(after.height, before.height)
475
+ ? moved(after.top, before.top)
476
+ ? "bottom"
477
+ : "top"
478
+ : null,
479
+ };
480
+ }
481
+
482
+ /** Stamp the measured anchor so the shadow CSS can place the grip. */
483
+ syncResizeAnchor(): void {
484
+ if (!this.#host.connected()) {
485
+ return;
486
+ }
487
+ // When this element owns its position it knows which edges are pinned --
488
+ // it just wrote them -- so there is nothing to probe. The probe is also
489
+ // unreliable at a size resting against max-width or max-height, where a
490
+ // nudge moves no edge and every axis reads as pinned on the wrong side.
491
+ const anchor = this.#expandCorner ?? this.#measureAnchor();
492
+ this.#anchor = anchor;
493
+ this.#host.element.setAttribute("data-resize-anchor", `${anchor.y}-${anchor.x}`);
494
+ this.#focusableGrip();
495
+ }
496
+
497
+ /** Put a custom property back to a previous value, or remove it if there was none. */
498
+ #restoreProperty(name: string, value: string): void {
499
+ if (value === "") {
500
+ this.#host.element.style.removeProperty(name);
501
+ return;
502
+ }
503
+ this.#host.element.style.setProperty(name, value);
504
+ }
505
+
506
+ /**
507
+ * Write a dragged size onto the host, on the axes this placement leaves free.
508
+ *
509
+ * Writing the custom property rather than inline `width` / `height` does not
510
+ * by itself leave placement in charge: an inline custom property still
511
+ * outranks a `:host([placement=…])` rule setting the same property, so a
512
+ * height dragged while floating would cap a docked sidebar asking for
513
+ * `100vh`. The cascade cannot arbitrate this, so the axis check must — a
514
+ * placement owns the axes it fixes, and a persisted size is applied only to
515
+ * the ones it leaves free.
516
+ */
517
+ #applySize(size: ResizeSize): void {
518
+ const axis = this.#resizeAxis();
519
+ if (axis === "none") {
520
+ return;
521
+ }
522
+ // The placement's max-width and max-height are left alone, which means a
523
+ // grip pushed against the edge the placement is *not* anchored to stops one
524
+ // gutter short of the screen. Moving the cap with the size fixes that and
525
+ // shifts several resting sizes by a pixel or two, because the cap and the
526
+ // size are not measured from the same box -- not worth the churn for a
527
+ // symmetry nobody has asked for. The limit that matters, staying inside
528
+ // what the host left free, is enforced above.
529
+ if (size.width !== undefined) {
530
+ this.#host.element.style.setProperty("--ag-ui-width", `${size.width}px`);
531
+ }
532
+ if (size.height !== undefined && axis === "both") {
533
+ this.#host.element.style.setProperty("--ag-ui-height", `${size.height}px`);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Drop any dragged size the new placement has taken ownership of.
539
+ *
540
+ * Without this a size survives the switch as an inline property and silently
541
+ * overrides the placement it moved to — the panel keeps a floating height
542
+ * while docked, and reads as a component that cannot do full height.
543
+ */
544
+ releaseOwnedAxes(): void {
545
+ const axis = this.#resizeAxis();
546
+ if (axis !== "both") {
547
+ this.#host.element.style.removeProperty("--ag-ui-height");
548
+ }
549
+ if (axis === "none") {
550
+ this.#host.element.style.removeProperty("--ag-ui-width");
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Whether this placement lets the launcher be dragged, read per interaction
556
+ * because `placement` is a live attribute. `data-launcher-drag="false"` opts
557
+ * a host out without giving up the launcher itself.
558
+ */
559
+ #launcherDraggable(): boolean {
560
+ return (
561
+ this.#host.element.getAttribute("data-launcher-drag") !== "false" &&
562
+ isDraggablePlacement(this.#host.element.getAttribute("placement"))
563
+ );
564
+ }
565
+
566
+ /**
567
+ * The viewport the launcher and the panel both have to fit inside.
568
+ *
569
+ * The *visual* viewport, not the layout one, because they come apart exactly
570
+ * when this matters. An on-screen keyboard shrinks the visual viewport and
571
+ * leaves the layout viewport alone, so clamping against `innerHeight` parks
572
+ * the launcher behind the keyboard and decides which corner to open into
573
+ * using space that is not on the screen. Pinch-zoom does the same on both
574
+ * axes.
575
+ *
576
+ * Falls back where the API is absent, which keeps this working in the
577
+ * happy-dom project as well as in an old browser.
578
+ */
579
+ #viewport(): ViewportBox {
580
+ const visual = window.visualViewport;
581
+ const width = visual?.width ?? window.innerWidth;
582
+ const height = visual?.height ?? window.innerHeight;
583
+ // And minus whatever the host reserved for its own chrome. Without this a
584
+ // panel is clamped against the whole screen and settles happily underneath
585
+ // a sticky header, where it cannot be reached -- and where collapsing it,
586
+ // the one thing a user tries, hides it completely rather than rescuing it.
587
+ // Read as padding off the probe, not as custom properties off this
588
+ // element. `getPropertyValue` on an unregistered custom property returns
589
+ // the token stream rather than a length, so a host stating `4rem` reserves
590
+ // four pixels here and sixty-four in the stylesheet, and one stating
591
+ // `calc(56px + env(safe-area-inset-top))` -- which is the natural spelling
592
+ // of what the token's own documentation recommends -- parses as NaN and
593
+ // takes the panel's whole inset down with it.
594
+ const style = getComputedStyle(this.probe);
595
+ const edge = (name: string): number => {
596
+ const value = Number.parseFloat(style.getPropertyValue(name));
597
+ // A detached or not-yet-rendered probe resolves to nothing at all, and
598
+ // reserving NaN is worse than reserving zero in every case.
599
+ return Number.isFinite(value) ? value : 0;
600
+ };
601
+ const left = edge("padding-left");
602
+ const top = edge("padding-top");
603
+ return {
604
+ left,
605
+ top,
606
+ width: Math.max(0, width - left - edge("padding-right")),
607
+ height: Math.max(0, height - top - edge("padding-bottom")),
608
+ };
609
+ }
610
+
611
+ /**
612
+ * The whole viewport, before anything the host reserved is taken out of it.
613
+ *
614
+ * Distinct from {@link #viewport} on purpose, and the two must not be
615
+ * swapped. The usable box decides where the widget may rest; this is what a
616
+ * CSS `inset` on a fixed element is measured from, because that is what the
617
+ * browser measures it from.
618
+ */
619
+ #screen(): Extent {
620
+ // The *layout* viewport, and `clientWidth`/`clientHeight` rather than
621
+ // `innerWidth`/`innerHeight`, because those two disagree by the width of a
622
+ // classic scrollbar and it is the smaller one a fixed element is laid out
623
+ // against. Reading the visual viewport here would be the same mistake one
624
+ // level up as clamping against the whole screen was one level down: a
625
+ // keyboard shrinks the visual viewport without moving the box CSS measures
626
+ // an inset from, so a panel the clamp had just held inside the visible
627
+ // band would be written back out behind the keyboard.
628
+ //
629
+ // The zero checks are for a detached or not-yet-laid-out document, where
630
+ // `clientWidth` is 0 and no viewport ever is.
631
+ const root = document.documentElement;
632
+ return {
633
+ width: root.clientWidth || window.innerWidth,
634
+ height: root.clientHeight || window.innerHeight,
635
+ };
636
+ }
637
+
638
+ /**
639
+ * Publish the measured viewport height so the stylesheet can size a
640
+ * full-bleed panel to what the user can see.
641
+ *
642
+ * No CSS length carries this. An on-screen keyboard has no effect on any
643
+ * viewport-percentage unit, so a panel sized from `100dvh` puts its composer
644
+ * behind the keyboard being typed into. Written inline, and read through a
645
+ * token the host's own `--ag-ui-viewport-height` still outranks.
646
+ *
647
+ * Removed rather than frozen when the two viewports agree, so a desktop that
648
+ * never diverges carries no inline override at all and the declared fallback
649
+ * stays in charge.
650
+ */
651
+ publishVisualViewport(): void {
652
+ const visual = window.visualViewport;
653
+ if (visual === null || visual === undefined) {
654
+ return;
655
+ }
656
+ // The layout viewport from the box a fixed element is laid out against,
657
+ // for the reasons #screen gives -- and one more that only a phone shows.
658
+ // While iOS Safari has the visible area panned down to show a focused
659
+ // field, innerHeight reads the layout height less the pan, and goes on
660
+ // reading it until a window scroll the element never hears about. Measured
661
+ // from that, the band below came out as nothing, and at the deepest pan the
662
+ // browser makes -- the one that shows a composer docked at the foot of the
663
+ // screen -- the shorter number equals the visible height, so the check
664
+ // below took every measurement back and put the panel at the layout top at
665
+ // full height, its header above the screen. clientHeight held the layout
666
+ // height throughout.
667
+ const layout = this.#screen().height;
668
+ if (Math.abs(visual.height - layout) < 1) {
669
+ this.#host.element.style.removeProperty("--ag-ui-visual-viewport-height");
670
+ this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-bottom");
671
+ this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-top");
672
+ return;
673
+ }
674
+ this.#host.element.style.setProperty(
675
+ "--ag-ui-visual-viewport-height",
676
+ `${Math.round(visual.height)}px`,
677
+ );
678
+ // What is hidden below the visible area, which is where a keyboard is. A
679
+ // shorter panel does not help anything anchored to the bottom: a floating
680
+ // widget is positioned against the layout viewport, so its bottom edge and
681
+ // the launcher at that corner stay behind the keyboard until this lifts
682
+ // them. Never negative -- a visual viewport panned up past the layout one
683
+ // would otherwise pull the panel down off the screen.
684
+ const hidden = layout - visual.height - visual.offsetTop;
685
+ this.#host.element.style.setProperty(
686
+ "--ag-ui-visual-viewport-inset-bottom",
687
+ `${Math.max(0, Math.round(hidden))}px`,
688
+ );
689
+ // And what is hidden above it. To show a field the keyboard would cover, a
690
+ // browser pans the visual viewport down the layout one, and a fixed panel
691
+ // anchored at the top stays at the layout top: sized to the visible height,
692
+ // it then shows only its lower part, from the pan down, with its header off
693
+ // the screen and page background under it. Never negative, for the same
694
+ // reason as the band below.
695
+ this.#host.element.style.setProperty(
696
+ "--ag-ui-visual-viewport-inset-top",
697
+ `${Math.max(0, Math.round(visual.offsetTop))}px`,
698
+ );
699
+ }
700
+
701
+ /**
702
+ * The launcher's box in viewport coordinates, with its transform divided out.
703
+ *
704
+ * The launcher is scaled in four states -- the collapse animation, hover,
705
+ * press, and the resting scale(0.4) it sits at while the panel is open -- and
706
+ * `getBoundingClientRect` reports every one of them. A drag that started from
707
+ * that rect would begin a couple of pixels off, because a press is one of
708
+ * those states.
709
+ *
710
+ * So the size comes from `offsetWidth`/`offsetHeight`, which are layout
711
+ * metrics no transform reaches, and the position from the rect's *centre*,
712
+ * which a centred scale is the one point that cannot move.
713
+ */
714
+ #launcherBox(): LauncherBox {
715
+ const width = this.#host.launcher.offsetWidth;
716
+ const height = this.#host.launcher.offsetHeight;
717
+ const dragged = this.#launcherPos;
718
+ if (dragged !== null) {
719
+ return { left: dragged.left, top: dragged.top, width, height };
720
+ }
721
+ const rect = this.#host.launcher.getBoundingClientRect();
722
+ return {
723
+ left: rect.left + rect.width / 2 - width / 2,
724
+ top: rect.top + rect.height / 2 - height / 2,
725
+ width,
726
+ height,
727
+ };
728
+ }
729
+
730
+ /**
731
+ * Place the host box and the launcher for the position the user dragged to.
732
+ *
733
+ * This writes `--ag-ui-inset`, which is a host-facing property: an inline
734
+ * value outranks the page's own rule for it, exactly as a dragged width
735
+ * outranks a placement's. That is the intent -- the user moved it -- and it
736
+ * is why switching to a placement that owns its position hands the property
737
+ * back rather than leaving a stale inline one behind.
738
+ */
739
+ #applyLauncherPlacement(at: { readonly left: number; readonly top: number }): void {
740
+ // The single gate: callers hand over a position and this decides whether
741
+ // it is this element's to honour. Checking in both places instead would
742
+ // leave one of the two checks permanently unreachable.
743
+ if (!this.#launcherDraggable()) {
744
+ return;
745
+ }
746
+ this.#launcherPos = at;
747
+ // Dropping the bubble hands the panel's position back to the placement.
748
+ // Keeping a stated one would pin the panel where it was dragged and leave
749
+ // the launcher deriving nothing, which is the gesture doing half its job.
750
+ this.#panelPos = null;
751
+ // The host box keeps its expanded size while collapsed, so its own rect is
752
+ // the panel's size in either state and needs no separate bookkeeping.
753
+ const panel = this.#host.element.getBoundingClientRect();
754
+ const placement = launcherPlacement(
755
+ this.#launcherBox(),
756
+ { width: panel.width, height: panel.height },
757
+ this.#viewport(),
758
+ this.#screen(),
759
+ );
760
+ this.#host.element.style.setProperty("--ag-ui-inset", placement.hostInset);
761
+ this.#host.element.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
762
+ this.#expandCorner = placement.corner;
763
+ // The corner the panel grows from, for the open/close animation's origin.
764
+ this.#host.element.setAttribute(
765
+ "data-expand-corner",
766
+ `${placement.corner.y}-${placement.corner.x}`,
767
+ );
768
+ this.syncResizeAnchor();
769
+ }
770
+
771
+ /** Move the launcher live during a drag, without persisting. */
772
+ #moveLauncher(left: number, top: number): void {
773
+ this.#applyLauncherPlacement({ left, top });
774
+ }
775
+
776
+ /** Move the launcher and remember where, per tab, like the dragged size. */
777
+ #commitLauncher(left: number, top: number): void {
778
+ this.#moveLauncher(left, top);
779
+ this.#storeLauncherPosition();
780
+ }
781
+
782
+ /**
783
+ * Move the panel live during a header drag, without persisting.
784
+ *
785
+ * Only the host box is written, and that is the whole trick: the launcher is
786
+ * positioned *inside* that box, so leaving its own inset alone carries it
787
+ * along by exactly the distance the panel travelled -- which is what a person
788
+ * dragging a window expects of the thing it collapses into. Placing it on the
789
+ * panel's pinned corner instead, as an earlier version did, sent it leaping
790
+ * across the panel the moment the drag re-picked that corner.
791
+ *
792
+ * The corner is therefore held for the length of the gesture. Both insets are
793
+ * measured from it, and rewriting one of them from a new corner while the
794
+ * other still names the old one would move the launcher for no reason.
795
+ */
796
+ #movePanel(box: PanelRect, from: PanelRect): { held: PanelRect; launcher: LauncherBox | null } {
797
+ if (!this.#launcherDraggable()) {
798
+ return { held: box, launcher: null };
799
+ }
800
+ // Where the launcher rests, recorded before the first move writes anything.
801
+ // From here on the DOM shows it mid-gesture, so this is the last moment it
802
+ // can be read rather than derived.
803
+ if (this.#launcherPos === null) {
804
+ const resting = this.#launcherBox();
805
+ this.#launcherPos = { left: resting.left, top: resting.top };
806
+ }
807
+ // The launcher as it was when the gesture began. Held for the whole drag:
808
+ // every move measures from here, so the two halves cannot drift apart.
809
+ const start = this.#launcherPos;
810
+ // The screen-edge bound, not the resting gutter. The 24px margin is where
811
+ // a placement rests one, not a rule about where a person may put it, and
812
+ // enforcing it against a drag is what made the panel feel stuck short of
813
+ // every edge on all four sides at once. Zero was the correction and it
814
+ // went too far the other way: it welded the panel to the boundary while
815
+ // the launcher -- same shadow, same rounded edge -- was held 8px off it,
816
+ // and it disagreed with the restore below, so a panel dragged flush leapt
817
+ // inward the next time the viewport changed.
818
+ const held = clampPanel(box, this.#viewport(), SCREEN_EDGE_MARGIN);
819
+ const corner = this.#expandCorner ?? this.#anchor;
820
+ // The usable box decides where the panel may rest, above; the screen is
821
+ // what these insets are measured from, because that is what the browser
822
+ // measures a fixed element's inset from. Using the usable box here made a
823
+ // right or bottom short by whatever the host had reserved, and the panel
824
+ // jumped by that much the first time a gesture wrote one.
825
+ this.#host.element.style.setProperty("--ag-ui-inset", insetFrom(corner, held, this.#screen()));
826
+ this.#panelPos = { left: held.left, top: held.top };
827
+
828
+ // Where the launcher has ended up, derived rather than read. During a
829
+ // header drag it rides inside the host box with its own inset untouched,
830
+ // so the DOM shows it moving while `#launcherPos` still holds where it
831
+ // started -- reading it back mid-gesture returns the stale value, and
832
+ // adding the panel's travel to that a second time on release is a jump.
833
+ // Measured from the box the press started on, so a long drag cannot
834
+ // accumulate the rounding each move writes.
835
+ const carried = {
836
+ ...this.#launcherBox(),
837
+ left: start.left + (held.left - from.left),
838
+ top: start.top + (held.top - from.top),
839
+ };
840
+ // A bubble carried into an edge the host reserved is one nobody can press.
841
+ // Clamping it live rather than at the end is what makes releasing the drag
842
+ // change nothing: leaving it until then parked it under a nav bar for the
843
+ // whole gesture and hopped it out on pointerup.
844
+ const launcher = { ...carried, ...clampLauncher(carried, this.#viewport()) };
845
+ this.#host.element.style.setProperty(
846
+ "--ag-ui-launcher-inset",
847
+ placeWidget(held, launcher, corner, this.#screen()).launcherInset,
848
+ );
849
+ return { held, launcher };
850
+ }
851
+
852
+ /**
853
+ * Finish a header drag: settle where both halves ended up, and remember it.
854
+ *
855
+ * The launcher travels the distance the panel actually travelled, which is
856
+ * the clamped distance rather than the pointer's -- a panel held against the
857
+ * viewport margin stops, and so does the bubble attached to it. Measured from
858
+ * the box the press started on, so a long drag cannot accumulate the rounding
859
+ * each move writes into the inset.
860
+ *
861
+ * Only now is the corner re-picked, from where the launcher has ended up, so
862
+ * the panel opens into clear space next time. Re-picking it moves nothing:
863
+ * both insets are rewritten from positions that are already decided.
864
+ */
865
+ #commitPanel(box: PanelRect, from: PanelRect): void {
866
+ // Exactly what the last move applied, rather than the same sum computed
867
+ // again. Recomputing it is how the two came apart: releasing the drag
868
+ // moved the bubble by the panel's whole travel a second time.
869
+ const { held, launcher } = this.#movePanel(box, from);
870
+ if (launcher === null) {
871
+ return;
872
+ }
873
+ this.#placePanelAndLauncher(held, launcher);
874
+ this.#storeLauncherPosition();
875
+ }
876
+
877
+ /**
878
+ * Write both insets for a panel and launcher that are already positioned,
879
+ * re-picking the corner they are measured from.
880
+ */
881
+ #placePanelAndLauncher(host: PanelRect, launcher: LauncherBox): void {
882
+ const viewport = this.#viewport();
883
+ const screen = this.#screen();
884
+ const size = { width: host.right - host.left, height: host.bottom - host.top };
885
+ const { corner } = launcherPlacement(launcher, size, viewport, screen);
886
+ // The screen again, not the usable box: these are CSS insets on a fixed
887
+ // element and the browser measures them from the real edges.
888
+ const insets = placeWidget(host, launcher, corner, screen);
889
+ this.#host.element.style.setProperty("--ag-ui-inset", insets.hostInset);
890
+ this.#host.element.style.setProperty("--ag-ui-launcher-inset", insets.launcherInset);
891
+ this.#launcherPos = { left: launcher.left, top: launcher.top };
892
+ this.#panelPos = { left: host.left, top: host.top };
893
+ this.#expandCorner = corner;
894
+ this.#host.element.setAttribute("data-expand-corner", `${corner.y}-${corner.x}`);
895
+ this.syncResizeAnchor();
896
+ }
897
+
898
+ /**
899
+ * Re-apply a panel position the user stated, against the current viewport.
900
+ *
901
+ * The launcher keeps its offset from the panel through the clamp -- it was
902
+ * put where it is relative to the panel, and a viewport that has since shrunk
903
+ * is no reason to move one without the other -- and is then held on screen in
904
+ * its own right.
905
+ */
906
+ #restorePanelPosition(at: { readonly left: number; readonly top: number }): void {
907
+ if (!this.#launcherDraggable()) {
908
+ return;
909
+ }
910
+ const rect = this.#host.element.getBoundingClientRect();
911
+ // The same bound the drag itself used. Taking the default here instead is
912
+ // what made a panel dragged to an edge jump a whole resting gutter inward
913
+ // on the next resize, reload or expand -- re-placing a position the user
914
+ // had stated, against a limit they had never been shown.
915
+ const held = clampPanel(
916
+ { left: at.left, top: at.top, right: at.left + rect.width, bottom: at.top + rect.height },
917
+ this.#viewport(),
918
+ SCREEN_EDGE_MARGIN,
919
+ );
920
+ const launcher = this.#launcherBox();
921
+ const carried = {
922
+ ...launcher,
923
+ left: launcher.left + (held.left - at.left),
924
+ top: launcher.top + (held.top - at.top),
925
+ };
926
+ this.#placePanelAndLauncher(held, {
927
+ ...carried,
928
+ ...clampLauncher(carried, this.#viewport()),
929
+ });
930
+ }
931
+
932
+ /**
933
+ * Write the current position, if this element owns one.
934
+ *
935
+ * The panel's own position rides along only when the user stated it, because
936
+ * its presence is what tells a restore which of the two gestures to honour:
937
+ * with it, the panel goes back where it was put; without it, the panel is
938
+ * re-derived from the launcher and opens into whatever room the viewport has
939
+ * now.
940
+ */
941
+ #storeLauncherPosition(): void {
942
+ const position = this.#launcherPos;
943
+ if (position === null) {
944
+ return;
945
+ }
946
+ const panel = this.#panelPos;
947
+ this.#host.writePreference(
948
+ LAUNCHER_KEY,
949
+ JSON.stringify(panel === null ? position : { ...position, panel }),
950
+ );
951
+ }
952
+
953
+ /**
954
+ * Re-apply the dragged position against the current viewport: on connect,
955
+ * whenever the window resizes, and before an expand. The viewport that
956
+ * stored a position may since have shrunk, and a launcher past the edge is
957
+ * unreachable -- it is the only way back to a collapsed conversation.
958
+ */
959
+ restoreLauncherPosition(): void {
960
+ const stored = this.#readLauncherPosition();
961
+ const launcher = this.#launcherPos ?? stored;
962
+ if (launcher === null) {
963
+ return;
964
+ }
965
+ const panel = this.#panelPos ?? stored?.panel ?? null;
966
+ if (panel !== null) {
967
+ // Stated rather than derived: the launcher is only read here to keep the
968
+ // offset the two were left with, so it has to be seeded before the panel
969
+ // is placed around it.
970
+ this.#launcherPos = { left: launcher.left, top: launcher.top };
971
+ this.#restorePanelPosition(panel);
972
+ return;
973
+ }
974
+ const box = this.#launcherBox();
975
+ this.#applyLauncherPlacement(
976
+ clampLauncher({ ...box, left: launcher.left, top: launcher.top }, this.#viewport()),
977
+ );
978
+ }
979
+
980
+ /** The persisted position for this instance, or null. */
981
+ #readLauncherPosition(): {
982
+ readonly left: number;
983
+ readonly top: number;
984
+ readonly panel?: { readonly left: number; readonly top: number };
985
+ } | null {
986
+ const raw = this.#host.readPreference(LAUNCHER_KEY);
987
+ if (raw === null) {
988
+ return null;
989
+ }
990
+ try {
991
+ const parsed: unknown = JSON.parse(raw);
992
+ if (typeof parsed !== "object" || parsed === null) {
993
+ return null;
994
+ }
995
+ const { left, top, panel } = parsed as { left?: unknown; top?: unknown; panel?: unknown };
996
+ if (typeof left !== "number" || typeof top !== "number") {
997
+ return null;
998
+ }
999
+ // A record written before the panel could be dragged has no panel half,
1000
+ // and one written by a launcher drag never will -- both restore by
1001
+ // deriving the panel, which is what they meant.
1002
+ const at = asPoint(panel);
1003
+ return at === null ? { left, top } : { left, top, panel: at };
1004
+ } catch {
1005
+ // A corrupt entry is not worth failing a mount over; fall back to the
1006
+ // placement's own corner.
1007
+ return null;
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Give the position back to the host when the new placement owns it, the way
1013
+ * releaseOwnedAxes gives back a size. Without this a dragged inset survives
1014
+ * the switch inline and pins a sidebar to wherever the floating launcher was.
1015
+ */
1016
+ releaseLauncherPosition(): void {
1017
+ if (this.#launcherDraggable()) {
1018
+ return;
1019
+ }
1020
+ this.#launcherPos = null;
1021
+ this.#panelPos = null;
1022
+ this.#expandCorner = null;
1023
+ this.#host.element.style.removeProperty("--ag-ui-inset");
1024
+ this.#host.element.style.removeProperty("--ag-ui-launcher-inset");
1025
+ this.#host.element.removeAttribute("data-expand-corner");
1026
+ }
1027
+
1028
+ /**
1029
+ * Apply the box a grip is asking for.
1030
+ *
1031
+ * The size is the easy half. The other half is that **dragging the edge the
1032
+ * layout is holding still moves the panel as well as resizing it**, and the
1033
+ * layout cannot express that on its own: a floating panel pinned bottom-right
1034
+ * cannot grow rightward, because its right edge is what the placement fixed.
1035
+ * So a grip on a pinned edge takes the position over -- which is the same
1036
+ * ownership the launcher drag takes, written the same way.
1037
+ *
1038
+ * A grip on a free edge writes nothing but the size, exactly as before, so a
1039
+ * host positioning the panel with its own rule keeps that rule until someone
1040
+ * drags the edge it was holding.
1041
+ */
1042
+ #applyResize(grip: ResizeGrip, box: PanelRect): PanelRect {
1043
+ box = this.#withinViewport(box);
1044
+ this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
1045
+ if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
1046
+ return box;
1047
+ }
1048
+ // The whole screen, not the usable box: a CSS inset on a fixed element is
1049
+ // measured from the real edges, so expressing a right or bottom against a
1050
+ // box the host has inset comes out short by exactly that inset.
1051
+ const anchor = this.#anchor;
1052
+ this.#host.element.style.setProperty("--ag-ui-inset", insetFrom(anchor, box, this.#screen()));
1053
+ // The launcher lives at this corner of the panel, so a corner that moved
1054
+ // takes it along. Without this the next expand would re-derive the panel's
1055
+ // position from a launcher still standing where the panel used to be, and
1056
+ // undo the move.
1057
+ if (this.#launcherPos !== null) {
1058
+ const size = this.#host.launcher.offsetWidth;
1059
+ this.#launcherPos = {
1060
+ left: anchor.x === "left" ? box.left : box.right - size,
1061
+ top: anchor.y === "top" ? box.top : box.bottom - size,
1062
+ };
1063
+ }
1064
+ // A stated panel position is a claim about this box, so it moves with it.
1065
+ if (this.#panelPos !== null) {
1066
+ this.#panelPos = { left: box.left, top: box.top };
1067
+ }
1068
+ return box;
1069
+ }
1070
+
1071
+ /** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
1072
+ #commitResize(grip: ResizeGrip, box: PanelRect): void {
1073
+ // The bounded box, not the one the pointer asked for. Persisting the raw
1074
+ // one would store a size the panel never had and restore it on the next
1075
+ // mount, which is the same disagreement between apply and commit that made
1076
+ // the header drag jump on release.
1077
+ const held = this.#applyResize(grip, box);
1078
+ this.#persistSize({ width: held.right - held.left, height: held.bottom - held.top });
1079
+ this.#storeLauncherPosition();
1080
+ // Re-stamp after the drag: a host whose layout changed underneath us would
1081
+ // otherwise keep the tab-reachable grip in the old corner, which reads as
1082
+ // the control being in the wrong place even though the drag was right.
1083
+ this.syncResizeAnchor();
1084
+ }
1085
+
1086
+ /**
1087
+ * Put the tab stop on the grip diagonally opposite the pinned corner.
1088
+ *
1089
+ * That is the corner a resize grows the panel from, so an arrow key there
1090
+ * changes the size and never the position -- the behaviour the single grip
1091
+ * this replaced had, kept for the one path that cannot simply grab a
1092
+ * different edge.
1093
+ */
1094
+ #focusableGrip(): void {
1095
+ const free = `${this.#anchor.y === "top" ? "bottom" : "top"}-${
1096
+ this.#anchor.x === "left" ? "right" : "left"
1097
+ }`;
1098
+ for (const [name, handle] of this.#resizeHandles) {
1099
+ const reachable = name === free;
1100
+ handle.tabIndex = reachable ? 0 : -1;
1101
+ if (reachable) {
1102
+ handle.removeAttribute("aria-hidden");
1103
+ } else {
1104
+ handle.setAttribute("aria-hidden", "true");
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
1110
+ #persistSize(size: ResizeSize): void {
1111
+ const stored = { ...this.#readSize(), ...size };
1112
+ this.#host.writePreference(SIZE_KEY, JSON.stringify(stored));
1113
+ }
1114
+
1115
+ /** The persisted size for this instance, or an empty record. */
1116
+ #readSize(): ResizeSize {
1117
+ const raw = this.#host.readPreference(SIZE_KEY);
1118
+ if (raw === null) {
1119
+ return {};
1120
+ }
1121
+ try {
1122
+ const parsed: unknown = JSON.parse(raw);
1123
+ return typeof parsed === "object" && parsed !== null ? (parsed as ResizeSize) : {};
1124
+ } catch {
1125
+ // A corrupt entry is not worth failing a mount over; fall back to the
1126
+ // placement's own size.
1127
+ return {};
1128
+ }
1129
+ }
1130
+ }