@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.
- package/CHANGELOG.md +501 -1
- package/README.md +232 -13
- package/dist/ag-ui-web-component.bundle.js +614 -96
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +76 -3
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +37 -2
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/dom/animations.d.ts +14 -0
- package/dist/dom/animations.d.ts.map +1 -1
- package/dist/dom/highlight_overlay.d.ts +47 -0
- package/dist/dom/highlight_overlay.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1886 -320
- package/dist/index.js.map +4 -4
- package/dist/tools/chat_surface_tools.d.ts +96 -0
- package/dist/tools/chat_surface_tools.d.ts.map +1 -0
- package/dist/tools/page_action_tools.d.ts +2 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -1
- package/dist/ui/clamp_launcher.d.ts +10 -5
- package/dist/ui/clamp_launcher.d.ts.map +1 -1
- package/dist/ui/clamp_panel.d.ts +2 -2
- package/dist/ui/clamp_panel.d.ts.map +1 -1
- package/dist/ui/launcher_drag.d.ts +2 -2
- package/dist/ui/launcher_drag.d.ts.map +1 -1
- package/dist/ui/launcher_placement.d.ts +14 -1
- package/dist/ui/launcher_placement.d.ts.map +1 -1
- package/dist/ui/panel_drag.d.ts.map +1 -1
- package/dist/ui/place_widget.d.ts +9 -1
- package/dist/ui/place_widget.d.ts.map +1 -1
- package/dist/ui/run_notice.d.ts +14 -3
- package/dist/ui/run_notice.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +21 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +16 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +82 -3
- package/src/core/ag_ui_chat.ts +965 -55
- package/src/dom/animations.ts +30 -0
- package/src/dom/highlight_overlay.ts +256 -0
- package/src/index.ts +12 -0
- package/src/tools/chat_surface_tools.ts +207 -0
- package/src/tools/page_action_tools.ts +2 -0
- package/src/ui/clamp_launcher.ts +25 -7
- package/src/ui/clamp_panel.ts +10 -4
- package/src/ui/launcher_drag.ts +11 -2
- package/src/ui/launcher_placement.ts +34 -8
- package/src/ui/panel_drag.ts +4 -0
- package/src/ui/place_widget.ts +11 -3
- package/src/ui/run_notice.ts +32 -3
- package/src/ui/styles.ts +563 -45
- package/src/ui/thread_drawer.ts +138 -8
- package/src/ui/ui_strings.ts +24 -0
- package/src/version.ts +1 -1
package/src/dom/animations.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/clamp_launcher.ts
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
|
-
import
|
|
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
|
|
10
|
-
*
|
|
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:
|
|
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(
|
|
18
|
-
|
|
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
|
}
|
package/src/ui/clamp_panel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EDGE_MARGIN } from "../constants.js";
|
|
2
|
-
import type {
|
|
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:
|
|
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(
|
|
22
|
-
|
|
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
|
}
|
package/src/ui/launcher_drag.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { clampLauncher } from "./clamp_launcher.js";
|
|
2
|
-
import type {
|
|
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: () =>
|
|
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
|