@artooi/ag-ui-web-component 0.34.0 → 0.35.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 (58) hide show
  1. package/CHANGELOG.md +501 -1
  2. package/README.md +232 -13
  3. package/dist/ag-ui-web-component.bundle.js +614 -96
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +76 -3
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +37 -2
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/dom/animations.d.ts +14 -0
  10. package/dist/dom/animations.d.ts.map +1 -1
  11. package/dist/dom/highlight_overlay.d.ts +47 -0
  12. package/dist/dom/highlight_overlay.d.ts.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1886 -320
  16. package/dist/index.js.map +4 -4
  17. package/dist/tools/chat_surface_tools.d.ts +96 -0
  18. package/dist/tools/chat_surface_tools.d.ts.map +1 -0
  19. package/dist/tools/page_action_tools.d.ts +2 -0
  20. package/dist/tools/page_action_tools.d.ts.map +1 -1
  21. package/dist/ui/clamp_launcher.d.ts +10 -5
  22. package/dist/ui/clamp_launcher.d.ts.map +1 -1
  23. package/dist/ui/clamp_panel.d.ts +2 -2
  24. package/dist/ui/clamp_panel.d.ts.map +1 -1
  25. package/dist/ui/launcher_drag.d.ts +2 -2
  26. package/dist/ui/launcher_drag.d.ts.map +1 -1
  27. package/dist/ui/launcher_placement.d.ts +14 -1
  28. package/dist/ui/launcher_placement.d.ts.map +1 -1
  29. package/dist/ui/panel_drag.d.ts.map +1 -1
  30. package/dist/ui/place_widget.d.ts +9 -1
  31. package/dist/ui/place_widget.d.ts.map +1 -1
  32. package/dist/ui/run_notice.d.ts +14 -3
  33. package/dist/ui/run_notice.d.ts.map +1 -1
  34. package/dist/ui/styles.d.ts +1 -1
  35. package/dist/ui/styles.d.ts.map +1 -1
  36. package/dist/ui/thread_drawer.d.ts +21 -0
  37. package/dist/ui/thread_drawer.d.ts.map +1 -1
  38. package/dist/ui/ui_strings.d.ts +16 -0
  39. package/dist/ui/ui_strings.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/constants.ts +82 -3
  42. package/src/core/ag_ui_chat.ts +965 -55
  43. package/src/dom/animations.ts +30 -0
  44. package/src/dom/highlight_overlay.ts +256 -0
  45. package/src/index.ts +12 -0
  46. package/src/tools/chat_surface_tools.ts +207 -0
  47. package/src/tools/page_action_tools.ts +2 -0
  48. package/src/ui/clamp_launcher.ts +25 -7
  49. package/src/ui/clamp_panel.ts +10 -4
  50. package/src/ui/launcher_drag.ts +11 -2
  51. package/src/ui/launcher_placement.ts +34 -8
  52. package/src/ui/panel_drag.ts +4 -0
  53. package/src/ui/place_widget.ts +11 -3
  54. package/src/ui/run_notice.ts +32 -3
  55. package/src/ui/styles.ts +563 -45
  56. package/src/ui/thread_drawer.ts +138 -8
  57. package/src/ui/ui_strings.ts +24 -0
  58. package/src/version.ts +1 -1
@@ -6,6 +6,7 @@
6
6
  * Each is configurable; pass small/zero durations in tests (or use fake timers).
7
7
  */
8
8
 
9
+ import { showHighlightOverlay } from "./highlight_overlay.js";
9
10
  import { setNativeChecked, setNativeValue } from "./native_setter.js";
10
11
 
11
12
  /** Ring colour when the host page has not themed `--ag-ui-accent`. */
@@ -180,6 +181,20 @@ export interface FlashOptions {
180
181
  flashMs?: number;
181
182
  /** Ring colour. Defaults to the target's `--ag-ui-accent`, else the package accent. */
182
183
  color?: string;
184
+ /**
185
+ * Dim everything except the target.
186
+ *
187
+ * Switches the ring from an outline on the element to an overlay drawn
188
+ * outside it -- see {@link showHighlightOverlay} for why those are the same
189
+ * decision. The overlay never takes a pointer event, so a scrim here does
190
+ * not stop the page being used, or the driver clicking what it just pointed
191
+ * at.
192
+ */
193
+ scrim?: boolean;
194
+ /** Draw the ring as a moving gradient. Also an overlay, for the same reason. */
195
+ gradient?: boolean;
196
+ /** Gap between the target's box and an overlay ring. Default 4. */
197
+ ringPadding?: number;
183
198
  /**
184
199
  * Move keyboard focus to the element as well. Defaults to false for
185
200
  * {@link flash} and true for {@link focusWithFlash}.
@@ -201,6 +216,21 @@ async function flashRing(
201
216
  if (flashMs <= 0) {
202
217
  return;
203
218
  }
219
+ // A scrim or a gradient cannot be an outline on the element -- an outline
220
+ // takes a colour, and anything that can be a gradient is a property of the
221
+ // target and so is clipped by whatever is clipping the target. Both are the
222
+ // same overlay, so asking for either takes that path.
223
+ if (options.scrim === true || options.gradient === true) {
224
+ const dismiss = showHighlightOverlay(el, {
225
+ scrim: options.scrim === true,
226
+ gradient: options.gradient === true,
227
+ ...(options.color === undefined ? {} : { color: options.color }),
228
+ ...(options.ringPadding === undefined ? {} : { padding: options.ringPadding }),
229
+ });
230
+ await delay(flashMs);
231
+ dismiss();
232
+ return;
233
+ }
204
234
  const previousOutline = el.style.outline;
205
235
  const previousOffset = el.style.outlineOffset;
206
236
  const previousTransition = el.style.transition;
@@ -0,0 +1,256 @@
1
+ import { HIGHLIGHT_OVERLAY_Z_INDEX } from "../constants.js";
2
+
3
+ /** The package accent, used when the host has themed nothing. */
4
+ const ACCENT = "#4f46e5";
5
+
6
+ /** How the overlay is drawn around the element being pointed at. */
7
+ export interface HighlightOverlayOptions {
8
+ /** Dim everything except the target. Default false. */
9
+ readonly scrim?: boolean;
10
+ /** Draw the ring as a moving gradient rather than a flat colour. Default false. */
11
+ readonly gradient?: boolean;
12
+ /** Ring colour, and the flat fallback for the gradient. */
13
+ readonly color?: string;
14
+ /** Gap between the target's box and the ring. Default 4. */
15
+ readonly padding?: number;
16
+ /** Corner radius of the cut-out and the ring. Default: the target's own. */
17
+ readonly radius?: number;
18
+ /** Ring thickness. Default 3, or `--ag-ui-highlight-ring-width` on the target. */
19
+ readonly ringWidth?: number;
20
+ /** One pass of the gradient. Default 2400, or `--ag-ui-highlight-flow-ms`. */
21
+ readonly flowMs?: number;
22
+ }
23
+
24
+ /**
25
+ * Read a `--ag-ui-*` token from the element being pointed at.
26
+ *
27
+ * From the *target*, not from the overlay. The overlay is appended to the
28
+ * document body so it can escape the clipping this exists to avoid, which also
29
+ * means a `var()` in its own inline style resolves against the body's cascade --
30
+ * so a host that themes the widget the documented way, on `ag-ui-chat` or on a
31
+ * wrapper, would never reach it. The target is on the host's page and inherits
32
+ * the host's cascade, which is the same reason the flat ring reads its accent
33
+ * there.
34
+ */
35
+ function tokenOn(target: HTMLElement, name: string, fallback: string): string {
36
+ const value = window.getComputedStyle(target).getPropertyValue(name).trim();
37
+ return value === "" ? fallback : value;
38
+ }
39
+
40
+ /** Default gap between the target's border box and the ring around it. */
41
+ const DEFAULT_PADDING_PX = 4;
42
+
43
+ /**
44
+ * Draw an overlay that points at `target`, and return a function that removes
45
+ * it.
46
+ *
47
+ * **Why an overlay rather than a style on the target.** The flat highlight
48
+ * writes an `outline` onto the element itself, and that is deliberate: a
49
+ * `box-shadow` paints outside the border box, so any `overflow: hidden`
50
+ * ancestor sharing the target's box -- a card, a table cell -- clips the whole
51
+ * ring away while the helper still reports success. But an outline takes a
52
+ * *colour*. There is no `outline-image`, so a gradient ring cannot be one, and
53
+ * every alternative that can be a gradient is a property of the target and
54
+ * lands back inside whatever is clipping it.
55
+ *
56
+ * Dimming everything else wants the same thing from the other direction: a
57
+ * surface larger than the target, which cannot be a property of the target. So
58
+ * the scrim and the gradient are one mechanism rather than two features, and
59
+ * both escape the clipping the outline was chosen to avoid, because nothing
60
+ * here is a descendant of the element being pointed at.
61
+ *
62
+ * **It is inert.** The overlay never takes a pointer event, at the cut-out or
63
+ * anywhere else. A dim that swallows clicks is a modal the user did not open,
64
+ * and the driver's own point-then-click helper has to reach the control it just
65
+ * finished pointing at.
66
+ *
67
+ * **It follows.** The cut-out is recomputed on scroll and resize, because a
68
+ * highlight that stays where the target used to be is worse than none: it
69
+ * points confidently at the wrong thing.
70
+ */
71
+ export function showHighlightOverlay(
72
+ target: HTMLElement,
73
+ options: HighlightOverlayOptions = {},
74
+ ): () => void {
75
+ const root = document.createElement("div");
76
+ root.setAttribute("data-ag-ui-highlight", "");
77
+ // aria-hidden because the ring is decoration: the announcement that this
78
+ // element matters is the agent's message, and a screen reader reaching a
79
+ // second, contentless copy of it learns nothing.
80
+ root.setAttribute("aria-hidden", "true");
81
+ root.style.cssText = [
82
+ "position: fixed",
83
+ "inset: 0",
84
+ "pointer-events: none",
85
+ // Above the widget's own default, because the overlay's whole job is to
86
+ // point at something on the page the widget is sitting over. A host whose
87
+ // chrome stacks higher still can say so.
88
+ `z-index: ${tokenOn(target, "--ag-ui-highlight-z-index", String(HIGHLIGHT_OVERLAY_Z_INDEX))}`,
89
+ ].join(";");
90
+
91
+ const scrim = document.createElement("div");
92
+ const ring = document.createElement("div");
93
+ // Named, because this overlay lives in the host's document rather than in
94
+ // the shadow tree: `::part` cannot reach it, so a class is the only handle a
95
+ // host has on the two boxes beyond the tokens they read.
96
+ root.className = "ag-ui-highlight";
97
+ scrim.className = "ag-ui-highlight-scrim";
98
+ ring.className = "ag-ui-highlight-ring";
99
+ if (options.scrim === true) {
100
+ root.append(scrim);
101
+ }
102
+ root.append(ring);
103
+
104
+ const ringWidth =
105
+ options.ringWidth ?? Number.parseFloat(tokenOn(target, "--ag-ui-highlight-ring-width", "3"));
106
+ const flowMs =
107
+ options.flowMs ?? Number.parseFloat(tokenOn(target, "--ag-ui-highlight-flow-ms", "2400"));
108
+
109
+ const paint = (): void => {
110
+ const box = target.getBoundingClientRect();
111
+ const pad = options.padding ?? DEFAULT_PADDING_PX;
112
+ // No fallback for an unparseable radius: getComputedStyle on an element in
113
+ // the document always resolves this, and the overlay is only ever shown for
114
+ // one the driver has already found. A guard here would be a branch no test
115
+ // can reach honestly.
116
+ const radius = options.radius ?? Number.parseFloat(getComputedStyle(target).borderRadius);
117
+ const left = box.left - pad;
118
+ const top = box.top - pad;
119
+ const width = box.width + pad * 2;
120
+ const height = box.height + pad * 2;
121
+
122
+ if (options.scrim === true) {
123
+ // A hole rather than four rectangles: one element, and it stays a hole
124
+ // through a radius. The outer ring runs clockwise and the inner one
125
+ // anticlockwise, which is what makes evenodd treat the inside as outside.
126
+ scrim.style.cssText = [
127
+ "position: absolute",
128
+ "inset: 0",
129
+ `background: ${tokenOn(target, "--ag-ui-highlight-scrim", "rgba(15, 15, 25, 0.45)")}`,
130
+ `clip-path: path(evenodd, '${holePath(left, top, width, height, radius + pad)}')`,
131
+ ].join(";");
132
+ }
133
+
134
+ ring.style.cssText = [
135
+ "position: absolute",
136
+ `left: ${left}px`,
137
+ `top: ${top}px`,
138
+ `width: ${width}px`,
139
+ `height: ${height}px`,
140
+ `border-radius: ${radius + pad}px`,
141
+ `border: ${ringWidth}px solid transparent`,
142
+ "box-sizing: border-box",
143
+ ringPaint(target, options),
144
+ ].join(";");
145
+ };
146
+
147
+ paint();
148
+ // Animated from script rather than a CSS keyframe, because the overlay lives
149
+ // in the host's document and not in the shadow tree: a keyframe would mean
150
+ // injecting a rule into someone else's stylesheet and remembering whether it
151
+ // had already been injected, which is module-level state this package does
152
+ // not keep.
153
+ //
154
+ // Reduced motion asks for no animation, not for no feedback -- the gradient
155
+ // is still drawn, it simply stops travelling.
156
+ let flow: Animation | null = null;
157
+ if (options.gradient === true && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
158
+ flow = ring.animate([{ backgroundPosition: "100% 0" }, { backgroundPosition: "-100% 0" }], {
159
+ duration: flowMs,
160
+ iterations: Number.POSITIVE_INFINITY,
161
+ easing: "linear",
162
+ });
163
+ }
164
+ // Capture, so a scroll inside any container reaches this and not just one on
165
+ // the document. Passive: this only reads geometry and paints.
166
+ const listen = { capture: true, passive: true } as const;
167
+ window.addEventListener("scroll", paint, listen);
168
+ window.addEventListener("resize", paint, listen);
169
+ document.body.appendChild(root);
170
+
171
+ return () => {
172
+ window.removeEventListener("scroll", paint, listen);
173
+ window.removeEventListener("resize", paint, listen);
174
+ // Cancelled as well as detached. An infinite animation never finishes, so
175
+ // it is never auto-removed the way a finite one is; removing the node
176
+ // ought to make it irrelevant and collectable, but that is a claim about
177
+ // when an engine drops a non-relevant animation rather than something this
178
+ // function controls. Cancelling makes the teardown total and costs a call.
179
+ flow?.cancel();
180
+ root.remove();
181
+ };
182
+ }
183
+
184
+ /**
185
+ * A caller's colour, or `null` if it is not one.
186
+ *
187
+ * The value is joined into a `;`-separated `cssText` run, so a string carrying
188
+ * its own semicolon would write further declarations onto a `position: fixed;
189
+ * inset: 0` element in the host's page. This function is newly public and
190
+ * "point at this element in colour X" is the obvious wiring for it, with X
191
+ * coming from wherever the host got it -- which can be the agent.
192
+ */
193
+ function safeColor(value: string | undefined): string | null {
194
+ if (value === undefined) {
195
+ return null;
196
+ }
197
+ return CSS.supports("color", value) ? value : null;
198
+ }
199
+
200
+ /**
201
+ * The ring's paint: a flat border, or a gradient masked to the border box.
202
+ *
203
+ * A gradient border needs the mask because a background paints inside the
204
+ * border, not on it -- the two-layer mask keeps the border box and subtracts
205
+ * the padding box, leaving the frame.
206
+ */
207
+ function ringPaint(target: HTMLElement, options: HighlightOverlayOptions): string {
208
+ const color = safeColor(options.color) ?? tokenOn(target, "--ag-ui-accent", ACCENT);
209
+ if (options.gradient !== true) {
210
+ return `border-color: ${color}`;
211
+ }
212
+ const image = tokenOn(
213
+ target,
214
+ "--ag-ui-highlight-gradient",
215
+ `linear-gradient(115deg, transparent 20%, ${color} 50%, transparent 80%)`,
216
+ );
217
+ const mask = "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)";
218
+ return [
219
+ `background-image: ${image}`,
220
+ "background-origin: border-box",
221
+ "background-size: 300% 100%",
222
+ `-webkit-mask: ${mask}`,
223
+ `mask: ${mask}`,
224
+ "-webkit-mask-composite: xor",
225
+ "mask-composite: exclude",
226
+ "background-position: 50% 0",
227
+ ].join(";");
228
+ }
229
+
230
+ /**
231
+ * A viewport-sized rectangle with a rounded rectangle cut out of it, as an SVG
232
+ * path for `clip-path: path(evenodd, ...)`.
233
+ *
234
+ * Written in absolute commands and closed explicitly, because a `clip-path`
235
+ * that fails to parse is not an error anywhere -- the declaration is dropped
236
+ * and the scrim silently covers the target it was meant to reveal.
237
+ */
238
+ function holePath(x: number, y: number, width: number, height: number, radius: number): string {
239
+ const r = Math.max(0, Math.min(radius, width / 2, height / 2));
240
+ const right = x + width;
241
+ const bottom = y + height;
242
+ const outer = `M 0 0 H ${window.innerWidth} V ${window.innerHeight} H 0 Z`;
243
+ const hole = [
244
+ `M ${x + r} ${y}`,
245
+ `H ${right - r}`,
246
+ `A ${r} ${r} 0 0 1 ${right} ${y + r}`,
247
+ `V ${bottom - r}`,
248
+ `A ${r} ${r} 0 0 1 ${right - r} ${bottom}`,
249
+ `H ${x + r}`,
250
+ `A ${r} ${r} 0 0 1 ${x} ${bottom - r}`,
251
+ `V ${y + r}`,
252
+ `A ${r} ${r} 0 0 1 ${x + r} ${y}`,
253
+ "Z",
254
+ ].join(" ");
255
+ return `${outer} ${hole}`;
256
+ }
package/src/index.ts CHANGED
@@ -107,8 +107,20 @@ export {
107
107
  setControlValue,
108
108
  toggleCheckbox,
109
109
  } from "./dom/dom_driver.js";
110
+ export {
111
+ type HighlightOverlayOptions,
112
+ showHighlightOverlay,
113
+ } from "./dom/highlight_overlay.js";
110
114
  export { setNativeChecked, setNativeValue } from "./dom/native_setter.js";
111
115
  export type { Skill } from "./skills/skill.js";
116
+ export {
117
+ CHAT_CORNERS,
118
+ type ChatCorner,
119
+ type ChatSurface,
120
+ type ChatSurfaceReport,
121
+ createChatSurfaceTools,
122
+ isChatCorner,
123
+ } from "./tools/chat_surface_tools.js";
112
124
  export { type ClientTool, ClientToolRegistry } from "./tools/client_tool_registry.js";
113
125
  export { isDestructive } from "./tools/is_destructive.js";
114
126
  export { isNavigates } from "./tools/is_navigates.js";
@@ -0,0 +1,207 @@
1
+ import { X_SUMMARY_KEY } from "../constants.js";
2
+ import type { ClientTool } from "./client_tool_registry.js";
3
+
4
+ /** Every corner the panel can be sent to, in the order the schema states them. */
5
+ export const CHAT_CORNERS = ["top-left", "top-right", "bottom-left", "bottom-right"] as const;
6
+
7
+ /** A corner the panel can be sent to. */
8
+ export type ChatCorner = (typeof CHAT_CORNERS)[number];
9
+
10
+ /** What the agent is told about the surface it is speaking from. */
11
+ export interface ChatSurfaceReport {
12
+ /** The placement in force, or `null` when the host set none. */
13
+ readonly placement: string | null;
14
+ /** Whether the panel is currently at its launcher. */
15
+ readonly collapsed: boolean;
16
+ /** Whether this placement has a collapsed state at all. */
17
+ readonly collapsible: boolean;
18
+ /** Whether the panel can be moved, or the placement owns its position. */
19
+ readonly movable: boolean;
20
+ /**
21
+ * Whether this page allows the panel to be moved at all.
22
+ *
23
+ * Reported apart from {@link ChatSurfaceReport.movable} only because the two
24
+ * reasons a move is refused call for different sentences: a host that turned
25
+ * dragging off is not the placement owning the position, and telling the
26
+ * user the second when the first is true is a plain falsehood.
27
+ */
28
+ readonly draggable: boolean;
29
+ /** Whether the panel currently covers the whole viewport. */
30
+ readonly fullBleed: boolean;
31
+ /** The panel's box, in viewport coordinates. */
32
+ readonly box: {
33
+ readonly left: number;
34
+ readonly top: number;
35
+ readonly width: number;
36
+ readonly height: number;
37
+ };
38
+ /**
39
+ * The part of the viewport the panel may rest in.
40
+ *
41
+ * With an origin, because it is not always the screen's: a host can reserve
42
+ * the edges its own chrome occupies, and an agent reasoning about the room
43
+ * to the left of `box.left` would otherwise be mixing two coordinate frames
44
+ * without being told there were two.
45
+ */
46
+ readonly viewport: {
47
+ readonly left: number;
48
+ readonly top: number;
49
+ readonly width: number;
50
+ readonly height: number;
51
+ };
52
+ }
53
+
54
+ /** Whether a string names one of the four corners. */
55
+ export function isChatCorner(value: string): value is ChatCorner {
56
+ return (CHAT_CORNERS as readonly string[]).includes(value);
57
+ }
58
+
59
+ /**
60
+ * The part of the widget these tools drive.
61
+ *
62
+ * A narrow port rather than the element itself, because the element imports
63
+ * this module to build its own tools and importing it back would be a cycle.
64
+ * It also says exactly what the agent is allowed to reach, which is a shorter
65
+ * list than the element's public surface.
66
+ */
67
+ export interface ChatSurface {
68
+ describeSurface(): ChatSurfaceReport;
69
+ /**
70
+ * `announce` writes a notice into the transcript with an undo beside it.
71
+ * The tools always pass it: a panel that rearranges itself mid-conversation
72
+ * has to say so, and a host driving the same method is arranging its own
73
+ * page and needs no telling.
74
+ */
75
+ moveTo(corner: ChatCorner, options?: { readonly announce?: boolean }): boolean;
76
+ setCollapsed(collapsed: boolean, options?: { readonly announce?: boolean }): void;
77
+ }
78
+
79
+ /**
80
+ * Tools that let the agent move the panel it is speaking from.
81
+ *
82
+ * This is the affordance nobody else can offer: every other assistant's chat
83
+ * is a surface of its own, so there is nothing for it to be in the way *of*.
84
+ * Ours is mounted in the page the user is working in, which is what makes
85
+ * "let me move this aside so you can see the table" a sentence the agent can
86
+ * act on rather than apologise for.
87
+ *
88
+ * **They report what happened, not what was asked.** A full-bleed panel on a
89
+ * phone has nowhere to move to, and a placement that places itself owns its
90
+ * position -- so `move_chat` answers `moved: false` with the reason and what
91
+ * would work instead, rather than returning success on a panel that did not
92
+ * budge. The same rule the page actions already follow: a tool reports that it
93
+ * fired, not that it worked.
94
+ *
95
+ * `read_chat_surface` exists so the agent can ask before it acts rather than
96
+ * discovering the answer through a failure. It is deliberately a tool and not
97
+ * ambient context: a snapshot the agent chose to take is honest about being
98
+ * one, and it needs no channel that does not already exist.
99
+ */
100
+ export function createChatSurfaceTools(surface: ChatSurface): ClientTool[] {
101
+ return [
102
+ {
103
+ name: "read_chat_surface",
104
+ description:
105
+ "Describe the chat panel you are speaking from: its placement, whether it is " +
106
+ "collapsed, whether it can be moved, and the box it occupies. Read this before " +
107
+ "moving or minimising yourself, since a full-screen panel has nowhere to move to. " +
108
+ "Read-only.",
109
+ parameters: {
110
+ type: "object",
111
+ properties: {},
112
+ required: [],
113
+ [X_SUMMARY_KEY]: "Read the chat's own position",
114
+ },
115
+ handler: () => surface.describeSurface(),
116
+ },
117
+ {
118
+ name: "move_chat",
119
+ description:
120
+ "Move your own panel to a corner, to uncover something the user needs to see. " +
121
+ "`corner` is top-left, top-right, bottom-left or bottom-right. Answers " +
122
+ "`moved: false` with a reason when the placement owns its position or the panel " +
123
+ "fills the screen; check `read_chat_surface` first, and prefer minimise_chat when " +
124
+ "there is nowhere to move to.",
125
+ parameters: {
126
+ type: "object",
127
+ properties: {
128
+ corner: {
129
+ type: "string",
130
+ enum: [...CHAT_CORNERS],
131
+ },
132
+ },
133
+ required: ["corner"],
134
+ [X_SUMMARY_KEY]: "Move the chat out of the way",
135
+ },
136
+ handler: (args) => {
137
+ const asked = String(args["corner"] ?? "");
138
+ const report = surface.describeSurface();
139
+ // Checked, not cast. `required` is advisory and a model can answer
140
+ // with a corner that does not exist; `moveTo` would then match none of
141
+ // its edge tests, send the panel bottom-right, and this would report
142
+ // success for the corner it was asked for -- so the agent tells the
143
+ // user it moved the chat somewhere it did not.
144
+ if (!isChatCorner(asked)) {
145
+ return {
146
+ moved: false,
147
+ reason: `"${asked}" is not a corner; use one of ${CHAT_CORNERS.join(", ")}`,
148
+ };
149
+ }
150
+ if (!report.movable) {
151
+ // Named rather than thrown: this is a fact about the surface the
152
+ // agent can act on -- minimise instead -- not a malformed call.
153
+ return {
154
+ moved: false,
155
+ reason: report.fullBleed
156
+ ? "the panel fills the screen, so there is nowhere to move it to"
157
+ : report.draggable === false
158
+ ? "this page has turned off moving the panel"
159
+ : `the "${report.placement ?? ""}" placement owns the panel's position`,
160
+ suggestion: report.collapsible ? "minimise_chat" : null,
161
+ };
162
+ }
163
+ return { moved: surface.moveTo(asked, { announce: true }), corner: asked };
164
+ },
165
+ },
166
+ {
167
+ name: "minimise_chat",
168
+ description:
169
+ "Collapse your own panel to its launcher, so the user can see the whole page. " +
170
+ "The launcher stays visible and reopens it. Answers `minimised: false` when the " +
171
+ "placement has no collapsed state, which is the case for a full-page chat.",
172
+ parameters: {
173
+ type: "object",
174
+ properties: {},
175
+ required: [],
176
+ [X_SUMMARY_KEY]: "Minimise the chat",
177
+ },
178
+ handler: () => {
179
+ const report = surface.describeSurface();
180
+ if (!report.collapsible) {
181
+ return {
182
+ minimised: false,
183
+ reason: "this placement has no collapsed state, so there is no launcher to return to",
184
+ };
185
+ }
186
+ surface.setCollapsed(true, { announce: true });
187
+ return { minimised: true };
188
+ },
189
+ },
190
+ {
191
+ name: "restore_chat",
192
+ description: "Open your own panel again after minimising it.",
193
+ parameters: {
194
+ type: "object",
195
+ properties: {},
196
+ required: [],
197
+ [X_SUMMARY_KEY]: "Restore the chat",
198
+ },
199
+ handler: () => {
200
+ // No notice: the user can see the panel come back, and a notice about
201
+ // something visibly happening is noise.
202
+ surface.setCollapsed(false);
203
+ return { restored: true };
204
+ },
205
+ },
206
+ ];
207
+ }
@@ -13,6 +13,8 @@ export type ResolvePageTarget = (target: string) => HTMLElement | null;
13
13
  export const PAGE_ACTIONS = {
14
14
  SCROLL: "scroll",
15
15
  DRAG: "drag",
16
+ /** The widget's own position: see `createChatSurfaceTools`. */
17
+ CHAT: "chat",
16
18
  } as const;
17
19
 
18
20
  /**
@@ -1,20 +1,38 @@
1
- import type { Extent, LauncherBox } from "./launcher_placement.js";
1
+ import { SCREEN_EDGE_MARGIN } from "../constants.js";
2
+ import type { LauncherBox, ViewportBox } from "./launcher_placement.js";
2
3
 
3
4
  /**
4
- * Keep a launcher fully on screen.
5
+ * Keep a launcher fully on screen, and a little clear of the edge.
5
6
  *
6
7
  * Applied to a dragged position and again to a restored one, because the
7
8
  * viewport that stored it may since have shrunk. A launcher parked past the
8
9
  * edge is unreachable, and it is the only way back to a collapsed
9
- * conversation -- so this clamps to the viewport itself rather than to the
10
- * panel's margin, which would refuse the corners users actually want.
10
+ * conversation.
11
+ *
12
+ * Not the panel's 24px gutter, which was rejected here on purpose: a launcher
13
+ * held that far in refuses the corners people actually drag it to. But not
14
+ * zero either -- the bubble is a circle with a drop shadow, and one flush
15
+ * against the boundary has its shadow cut and its curve running into the edge,
16
+ * which reads as clipped whether or not a pixel is actually missing.
11
17
  */
12
18
  export function clampLauncher(
13
19
  launcher: LauncherBox,
14
- viewport: Extent,
20
+ viewport: ViewportBox,
21
+ margin: number = SCREEN_EDGE_MARGIN,
15
22
  ): { readonly left: number; readonly top: number } {
23
+ // The margin is given up rather than enforced where it does not fit: a
24
+ // viewport narrower than the bubble and its two margins would otherwise
25
+ // produce a lower bound above the upper one and pin it to the wrong edge.
26
+ const room = Math.min(margin, Math.max(0, (viewport.width - launcher.width) / 2));
27
+ const vertical = Math.min(margin, Math.max(0, (viewport.height - launcher.height) / 2));
16
28
  return {
17
- left: Math.max(0, Math.min(launcher.left, viewport.width - launcher.width)),
18
- top: Math.max(0, Math.min(launcher.top, viewport.height - launcher.height)),
29
+ left: Math.max(
30
+ viewport.left + room,
31
+ Math.min(launcher.left, viewport.left + viewport.width - launcher.width - room),
32
+ ),
33
+ top: Math.max(
34
+ viewport.top + vertical,
35
+ Math.min(launcher.top, viewport.top + viewport.height - launcher.height - vertical),
36
+ ),
19
37
  };
20
38
  }
@@ -1,5 +1,5 @@
1
1
  import { EDGE_MARGIN } from "../constants.js";
2
- import type { Extent } from "./launcher_placement.js";
2
+ import type { ViewportBox } from "./launcher_placement.js";
3
3
  import type { PanelRect } from "./resize_handle.js";
4
4
 
5
5
  /**
@@ -13,12 +13,18 @@ import type { PanelRect } from "./resize_handle.js";
13
13
  */
14
14
  export function clampPanel(
15
15
  host: PanelRect,
16
- viewport: Extent,
16
+ viewport: ViewportBox,
17
17
  margin: number = EDGE_MARGIN,
18
18
  ): PanelRect {
19
19
  const width = host.right - host.left;
20
20
  const height = host.bottom - host.top;
21
- const left = Math.max(margin, Math.min(host.left, viewport.width - margin - width));
22
- const top = Math.max(margin, Math.min(host.top, viewport.height - margin - height));
21
+ const left = Math.max(
22
+ viewport.left + margin,
23
+ Math.min(host.left, viewport.left + viewport.width - margin - width),
24
+ );
25
+ const top = Math.max(
26
+ viewport.top + margin,
27
+ Math.min(host.top, viewport.top + viewport.height - margin - height),
28
+ );
23
29
  return { left, top, right: left + width, bottom: top + height };
24
30
  }
@@ -1,5 +1,5 @@
1
1
  import { clampLauncher } from "./clamp_launcher.js";
2
- import type { Extent, LauncherBox } from "./launcher_placement.js";
2
+ import type { LauncherBox, ViewportBox } from "./launcher_placement.js";
3
3
 
4
4
  /** What the drag needs from its host to do its job. */
5
5
  export interface LauncherDragOptions {
@@ -12,7 +12,7 @@ export interface LauncherDragOptions {
12
12
  /** The launcher's current box, in viewport coordinates. */
13
13
  readonly rect: () => LauncherBox;
14
14
  /** The viewport the launcher has to stay inside. */
15
- readonly viewport: () => Extent;
15
+ readonly viewport: () => ViewportBox;
16
16
  /** Put the launcher's top-left at this point. Called per pointer move. */
17
17
  readonly apply: (left: number, top: number) => void;
18
18
  /**
@@ -106,6 +106,7 @@ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragO
106
106
  const onUp = (up: PointerEvent): void => {
107
107
  window.removeEventListener("pointermove", onMove);
108
108
  window.removeEventListener("pointerup", onUp);
109
+ window.removeEventListener("pointercancel", onUp);
109
110
  if (!dragging) {
110
111
  return;
111
112
  }
@@ -124,8 +125,16 @@ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragO
124
125
 
125
126
  // Listeners on `window`, not the launcher: a fast drag outruns the pointer
126
127
  // and would otherwise strand it mid-move with no pointerup.
128
+ //
129
+ // pointercancel matters on touch, where it is routine rather than
130
+ // exceptional: the browser takes the pointer back for a scroll or a system
131
+ // gesture and never sends pointerup. Without this the move listeners stay
132
+ // attached and the drag stamp never clears, which leaves the launcher
133
+ // following a finger that has stopped and the element believing a gesture
134
+ // is still in flight.
127
135
  window.addEventListener("pointermove", onMove);
128
136
  window.addEventListener("pointerup", onUp);
137
+ window.addEventListener("pointercancel", onUp);
129
138
  });
130
139
 
131
140
  // The position this key gesture has applied but not yet persisted. The