@runtypelabs/persona 3.29.1 → 3.31.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/README.md +15 -2547
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
- package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -7
- package/dist/index.d.ts +164 -7
- package/dist/index.global.js +60 -60
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -49
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -0
- package/dist/plugin-kit.d.cts +98 -0
- package/dist/plugin-kit.d.ts +98 -0
- package/dist/plugin-kit.js +1 -0
- package/dist/smart-dom-reader.d.cts +157 -4
- package/dist/smart-dom-reader.d.ts +157 -4
- package/dist/theme-editor.cjs +40 -40
- package/dist/theme-editor.d.cts +161 -6
- package/dist/theme-editor.d.ts +161 -6
- package/dist/theme-editor.js +40 -40
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +19 -0
- package/package.json +8 -2
- package/src/client.ts +6 -0
- package/src/components/approval-bubble.test.ts +320 -0
- package/src/components/approval-bubble.ts +190 -20
- package/src/components/launcher.ts +6 -3
- package/src/components/panel.ts +7 -1
- package/src/components/tool-bubble.test.ts +39 -0
- package/src/components/tool-bubble.ts +4 -0
- package/src/defaults.ts +14 -0
- package/src/generated/runtype-openapi-contract.ts +4 -0
- package/src/index-core.ts +1 -0
- package/src/plugin-kit.test.ts +230 -0
- package/src/plugin-kit.ts +294 -0
- package/src/plugins/types.ts +49 -2
- package/src/session.test.ts +161 -0
- package/src/session.ts +41 -3
- package/src/styles/widget.css +19 -0
- package/src/theme-editor/preview-utils.test.ts +10 -0
- package/src/theme-editor/preview-utils.ts +29 -1
- package/src/theme-editor/sections.test.ts +17 -0
- package/src/theme-editor/sections.ts +31 -0
- package/src/theme-reference.ts +2 -2
- package/src/types/theme.ts +2 -0
- package/src/types.ts +109 -2
- package/src/ui.approval-plugin.test.ts +204 -0
- package/src/ui.scroll.test.ts +383 -0
- package/src/ui.ts +539 -56
- package/src/utils/auto-follow.test.ts +91 -0
- package/src/utils/auto-follow.ts +68 -0
- package/src/utils/theme.test.ts +6 -2
- package/src/utils/theme.ts +0 -8
- package/src/utils/tokens.ts +6 -1
- package/src/webmcp-bridge.test.ts +66 -0
- package/src/webmcp-bridge.ts +49 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Plugin Kit — small, dependency-free utilities for authoring Persona plugins.
|
|
2
|
+
//
|
|
3
|
+
// Plugin render hooks (`renderApproval`, `renderAskUserQuestion`, `renderMessage`,
|
|
4
|
+
// …) return a detached `HTMLElement` that the widget morphs into the transcript.
|
|
5
|
+
// Two needs come up again and again when that element is more than static markup:
|
|
6
|
+
//
|
|
7
|
+
// 1. Injecting the plugin's own CSS so it survives the widget's Shadow-DOM mode
|
|
8
|
+
// (a plain `document.head` <style> does NOT pierce a shadow root).
|
|
9
|
+
// 2. Floating UI — dropdowns, menus, tooltips — that must overlay the rest of
|
|
10
|
+
// the widget and not be clipped by the transcript's scroll container.
|
|
11
|
+
//
|
|
12
|
+
// Both are easy to get subtly wrong, so they live here as a supported, optional
|
|
13
|
+
// subpath: `@runtypelabs/persona/plugin-kit`. Importing it costs nothing unless
|
|
14
|
+
// you use it, and nothing here touches the widget's core bundle.
|
|
15
|
+
//
|
|
16
|
+
// ```ts
|
|
17
|
+
// import { injectStyles, createPopover, isEditableEventTarget } from
|
|
18
|
+
// "@runtypelabs/persona/plugin-kit";
|
|
19
|
+
// ```
|
|
20
|
+
|
|
21
|
+
/* ============================================================
|
|
22
|
+
Shadow-safe style injection
|
|
23
|
+
============================================================ */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the root a node's styles should live in.
|
|
27
|
+
*
|
|
28
|
+
* Returns the node's `ShadowRoot` when it (or an ancestor) is shadowed, the
|
|
29
|
+
* owning `Document` otherwise. A `<style>` placed in this root reaches the node
|
|
30
|
+
* regardless of whether the widget runs in light or shadow DOM. For a detached
|
|
31
|
+
* node (one not yet mounted) this falls back to the owning document.
|
|
32
|
+
*/
|
|
33
|
+
export function getStyleRoot(node: Node): Document | ShadowRoot {
|
|
34
|
+
const root = node.getRootNode?.();
|
|
35
|
+
if (root instanceof ShadowRoot) return root;
|
|
36
|
+
if (root instanceof Document) return root;
|
|
37
|
+
return node.ownerDocument ?? document;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function injectInto(root: Document | ShadowRoot, id: string, css: string): void {
|
|
41
|
+
const container: Document | ShadowRoot | HTMLHeadElement =
|
|
42
|
+
root instanceof Document ? root.head : root;
|
|
43
|
+
const escaped = id.replace(/["\\]/g, "\\$&");
|
|
44
|
+
if (container.querySelector(`style[data-persona-plugin-style="${escaped}"]`)) {
|
|
45
|
+
return; // Already present in this root — idempotent.
|
|
46
|
+
}
|
|
47
|
+
const doc = root instanceof Document ? root : root.ownerDocument ?? document;
|
|
48
|
+
const style = doc.createElement("style");
|
|
49
|
+
style.setAttribute("data-persona-plugin-style", id);
|
|
50
|
+
style.textContent = css;
|
|
51
|
+
container.appendChild(style);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inject a plugin's CSS once into the correct root — the widget's shadow root
|
|
56
|
+
* when shadowed, the document head otherwise. Idempotent: keyed by `id`, so it
|
|
57
|
+
* is safe to call on every render.
|
|
58
|
+
*
|
|
59
|
+
* Pass the element you're about to return from a render hook. While building, an
|
|
60
|
+
* element is detached and its eventual root is unknown, so this injects into the
|
|
61
|
+
* owning document immediately (covering the default light-DOM case with no
|
|
62
|
+
* flash) and then, on the next microtask — after the widget has mounted the
|
|
63
|
+
* element — re-resolves and also injects into its shadow root if it landed in
|
|
64
|
+
* one. You may also pass an explicit `Document` or `ShadowRoot`.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* renderApproval: ({ message, approve, deny }) => {
|
|
69
|
+
* const card = buildCard(message.approval, approve, deny);
|
|
70
|
+
* injectStyles(card, "my-approval-plugin", CSS);
|
|
71
|
+
* return card;
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function injectStyles(
|
|
76
|
+
target: Node | Document | ShadowRoot,
|
|
77
|
+
id: string,
|
|
78
|
+
css: string
|
|
79
|
+
): void {
|
|
80
|
+
if (target instanceof Document || target instanceof ShadowRoot) {
|
|
81
|
+
injectInto(target, id, css);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const node = target;
|
|
85
|
+
if (node.isConnected) {
|
|
86
|
+
injectInto(getStyleRoot(node), id, css);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Detached (built but not yet mounted): inject into the owning document now,
|
|
90
|
+
// then re-resolve after mount so shadow-DOM widgets also get the <style>.
|
|
91
|
+
const doc = node.ownerDocument ?? document;
|
|
92
|
+
injectInto(doc, id, css);
|
|
93
|
+
queueMicrotask(() => {
|
|
94
|
+
const root = getStyleRoot(node);
|
|
95
|
+
if (root !== doc) injectInto(root, id, css);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ============================================================
|
|
100
|
+
Floating popover
|
|
101
|
+
============================================================ */
|
|
102
|
+
|
|
103
|
+
export type PopoverPlacement =
|
|
104
|
+
| "bottom-start"
|
|
105
|
+
| "bottom-end"
|
|
106
|
+
| "top-start"
|
|
107
|
+
| "top-end";
|
|
108
|
+
|
|
109
|
+
export interface PopoverOptions {
|
|
110
|
+
/** Element the popover is positioned against. */
|
|
111
|
+
anchor: HTMLElement;
|
|
112
|
+
/** The floating element (menu, tooltip, panel). Built detached; mounted on `open`. */
|
|
113
|
+
content: HTMLElement;
|
|
114
|
+
/** Where to place `content` relative to `anchor`. Default `"bottom-start"`. */
|
|
115
|
+
placement?: PopoverPlacement;
|
|
116
|
+
/** Gap in px between anchor and content. Default `6`. */
|
|
117
|
+
offset?: number;
|
|
118
|
+
/** Set `content`'s `min-width` to the anchor's width. Default `false`. */
|
|
119
|
+
matchAnchorWidth?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Inline `z-index` for `content`. Default `2147483000` so it overlays the rest
|
|
122
|
+
* of the widget. Pass `null` to leave z-index to your own CSS.
|
|
123
|
+
*/
|
|
124
|
+
zIndex?: number | null;
|
|
125
|
+
/**
|
|
126
|
+
* Where to mount `content`. Defaults to the anchor's shadow root (when
|
|
127
|
+
* shadowed) or `document.body` — keeping it inside the same style + stacking
|
|
128
|
+
* scope as the anchor while escaping the transcript's scroll clipping.
|
|
129
|
+
*/
|
|
130
|
+
container?: HTMLElement | ShadowRoot;
|
|
131
|
+
onOpen?: () => void;
|
|
132
|
+
/** Fired when the popover closes by itself (outside click or the anchor leaving the DOM). */
|
|
133
|
+
onDismiss?: (reason: "outside" | "anchor-removed") => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PopoverHandle {
|
|
137
|
+
readonly isOpen: boolean;
|
|
138
|
+
open(): void;
|
|
139
|
+
close(): void;
|
|
140
|
+
toggle(): void;
|
|
141
|
+
/** Recompute position from the current anchor rect (called automatically on scroll/resize). */
|
|
142
|
+
reposition(): void;
|
|
143
|
+
/** Close and release all listeners; the handle is inert afterward. */
|
|
144
|
+
destroy(): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function defaultContainer(anchor: HTMLElement): HTMLElement | ShadowRoot {
|
|
148
|
+
const root = anchor.getRootNode?.();
|
|
149
|
+
if (root instanceof ShadowRoot) return root;
|
|
150
|
+
return (anchor.ownerDocument ?? document).body;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A floating popover anchored to an element: `fixed`-positioned (so it overlays
|
|
155
|
+
* the rest of the widget and isn't clipped by scroll containers), dismissed on
|
|
156
|
+
* outside pointerdown, repositioned on scroll/resize, and auto-closed if the
|
|
157
|
+
* anchor leaves the DOM. Mount your styles with {@link injectStyles}.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* const popover = createPopover({
|
|
162
|
+
* anchor: splitButton,
|
|
163
|
+
* content: menu,
|
|
164
|
+
* placement: "bottom-start",
|
|
165
|
+
* matchAnchorWidth: true,
|
|
166
|
+
* });
|
|
167
|
+
* caret.addEventListener("click", () => popover.toggle());
|
|
168
|
+
* // on teardown: popover.destroy();
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function createPopover(options: PopoverOptions): PopoverHandle {
|
|
172
|
+
const {
|
|
173
|
+
anchor,
|
|
174
|
+
content,
|
|
175
|
+
placement = "bottom-start",
|
|
176
|
+
offset = 6,
|
|
177
|
+
matchAnchorWidth = false,
|
|
178
|
+
zIndex = 2147483000,
|
|
179
|
+
onOpen,
|
|
180
|
+
onDismiss,
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
const container = options.container ?? defaultContainer(anchor);
|
|
184
|
+
let open = false;
|
|
185
|
+
let detach: (() => void) | null = null;
|
|
186
|
+
|
|
187
|
+
const reposition = (): void => {
|
|
188
|
+
if (!open) return;
|
|
189
|
+
const rect = anchor.getBoundingClientRect();
|
|
190
|
+
content.style.position = "fixed";
|
|
191
|
+
if (matchAnchorWidth) content.style.minWidth = `${rect.width}px`;
|
|
192
|
+
|
|
193
|
+
const top =
|
|
194
|
+
placement === "top-start" || placement === "top-end"
|
|
195
|
+
? rect.top - offset - content.getBoundingClientRect().height
|
|
196
|
+
: rect.bottom + offset;
|
|
197
|
+
|
|
198
|
+
const left =
|
|
199
|
+
placement === "bottom-end" || placement === "top-end"
|
|
200
|
+
? rect.right - content.getBoundingClientRect().width
|
|
201
|
+
: rect.left;
|
|
202
|
+
|
|
203
|
+
content.style.top = `${top}px`;
|
|
204
|
+
content.style.left = `${left}px`;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const close = (): void => {
|
|
208
|
+
if (!open) return;
|
|
209
|
+
open = false;
|
|
210
|
+
if (detach) {
|
|
211
|
+
detach();
|
|
212
|
+
detach = null;
|
|
213
|
+
}
|
|
214
|
+
content.remove();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const doOpen = (): void => {
|
|
218
|
+
if (open) return;
|
|
219
|
+
open = true;
|
|
220
|
+
if (zIndex != null) content.style.zIndex = String(zIndex);
|
|
221
|
+
container.appendChild(content);
|
|
222
|
+
reposition();
|
|
223
|
+
|
|
224
|
+
const ownerWindow = (anchor.ownerDocument ?? document).defaultView ?? window;
|
|
225
|
+
const ownerDocument = anchor.ownerDocument ?? document;
|
|
226
|
+
|
|
227
|
+
const onReposition = (): void => {
|
|
228
|
+
if (!anchor.isConnected) {
|
|
229
|
+
close();
|
|
230
|
+
onDismiss?.("anchor-removed");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
reposition();
|
|
234
|
+
};
|
|
235
|
+
const onOutside = (event: Event): void => {
|
|
236
|
+
const path =
|
|
237
|
+
typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
238
|
+
if (path.includes(content) || path.includes(anchor)) return;
|
|
239
|
+
close();
|
|
240
|
+
onDismiss?.("outside");
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Defer arming so the click that opened the popover doesn't dismiss it.
|
|
244
|
+
const armId = ownerWindow.setTimeout(() => {
|
|
245
|
+
ownerDocument.addEventListener("pointerdown", onOutside, true);
|
|
246
|
+
}, 0);
|
|
247
|
+
ownerWindow.addEventListener("scroll", onReposition, true);
|
|
248
|
+
ownerWindow.addEventListener("resize", onReposition);
|
|
249
|
+
|
|
250
|
+
detach = () => {
|
|
251
|
+
ownerWindow.clearTimeout(armId);
|
|
252
|
+
ownerDocument.removeEventListener("pointerdown", onOutside, true);
|
|
253
|
+
ownerWindow.removeEventListener("scroll", onReposition, true);
|
|
254
|
+
ownerWindow.removeEventListener("resize", onReposition);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
onOpen?.();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
get isOpen() {
|
|
262
|
+
return open;
|
|
263
|
+
},
|
|
264
|
+
open: doOpen,
|
|
265
|
+
close,
|
|
266
|
+
toggle: () => (open ? close() : doOpen()),
|
|
267
|
+
reposition,
|
|
268
|
+
destroy: close,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ============================================================
|
|
273
|
+
Keyboard helpers
|
|
274
|
+
============================================================ */
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether an event originated from an editable element (`<input>`, `<textarea>`,
|
|
278
|
+
* or `contenteditable`). Use it to avoid hijacking keys like Enter/Escape while
|
|
279
|
+
* the user is typing in the composer.
|
|
280
|
+
*
|
|
281
|
+
* Inspects the composed path, so it works for events that cross the widget's
|
|
282
|
+
* Shadow-DOM boundary (where `event.target` is retargeted to the host).
|
|
283
|
+
*/
|
|
284
|
+
export function isEditableEventTarget(event: Event): boolean {
|
|
285
|
+
const path =
|
|
286
|
+
typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
287
|
+
return path.some(
|
|
288
|
+
(el) =>
|
|
289
|
+
el instanceof HTMLElement &&
|
|
290
|
+
(el.tagName === "INPUT" ||
|
|
291
|
+
el.tagName === "TEXTAREA" ||
|
|
292
|
+
el.isContentEditable)
|
|
293
|
+
);
|
|
294
|
+
}
|
package/src/plugins/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AgentWidgetMessage,
|
|
3
3
|
AgentWidgetConfig,
|
|
4
|
+
AgentWidgetApprovalDecisionOptions,
|
|
4
5
|
AskUserQuestionPayload,
|
|
5
6
|
LoadingIndicatorRenderContext,
|
|
6
7
|
IdleIndicatorRenderContext,
|
|
@@ -165,13 +166,59 @@ export interface AgentWidgetPlugin {
|
|
|
165
166
|
}) => HTMLElement | null;
|
|
166
167
|
|
|
167
168
|
/**
|
|
168
|
-
* Custom renderer for approval bubbles
|
|
169
|
-
*
|
|
169
|
+
* Custom renderer for approval bubbles.
|
|
170
|
+
*
|
|
171
|
+
* Return an `HTMLElement` to fully own the approval UI, `defaultRenderer()`
|
|
172
|
+
* to render (or wrap) the built-in bubble, or `null` to fall through to the
|
|
173
|
+
* default. Unlike the built-in bubble — whose Approve/Deny buttons are wired
|
|
174
|
+
* via delegation — a fully custom element resolves the approval by calling
|
|
175
|
+
* the `approve`/`deny` callbacks. Both route through the same path the
|
|
176
|
+
* built-in buttons use (optimistic update, `onDecision`, in-place anchoring).
|
|
177
|
+
*
|
|
178
|
+
* An approval is a single binary gate, so there are exactly two outcomes.
|
|
179
|
+
* Pass `{ remember: true }` to flag a "remember this" affordance (e.g. an
|
|
180
|
+
* "Always allow" button); the current approval resolves identically, but the
|
|
181
|
+
* flag is forwarded to `config.approval.onDecision` so you can persist a
|
|
182
|
+
* don't-ask-again policy for future approvals.
|
|
183
|
+
*
|
|
184
|
+
* `renderApproval` is called again whenever the approval's status changes, so
|
|
185
|
+
* branch on `message.approval?.status` to render the resolved state (and tear
|
|
186
|
+
* down any global listeners you added while pending).
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```typescript
|
|
190
|
+
* // An alternative prompt: "Always allow" / "Allow once" / "Deny".
|
|
191
|
+
* renderApproval: ({ message, approve, deny }) => {
|
|
192
|
+
* const approval = message.approval;
|
|
193
|
+
* if (!approval || approval.status !== "pending") return null; // default renders resolved state
|
|
194
|
+
* const root = document.createElement("div");
|
|
195
|
+
* root.textContent = `${approval.toolName} requires approval`;
|
|
196
|
+
*
|
|
197
|
+
* const always = document.createElement("button");
|
|
198
|
+
* always.textContent = "Always allow";
|
|
199
|
+
* always.addEventListener("click", () => approve({ remember: true }));
|
|
200
|
+
*
|
|
201
|
+
* const once = document.createElement("button");
|
|
202
|
+
* once.textContent = "Allow once";
|
|
203
|
+
* once.addEventListener("click", () => approve());
|
|
204
|
+
*
|
|
205
|
+
* const no = document.createElement("button");
|
|
206
|
+
* no.textContent = "Deny";
|
|
207
|
+
* no.addEventListener("click", () => deny());
|
|
208
|
+
*
|
|
209
|
+
* root.append(always, once, no);
|
|
210
|
+
* return root;
|
|
211
|
+
* }
|
|
212
|
+
* ```
|
|
170
213
|
*/
|
|
171
214
|
renderApproval?: (context: {
|
|
172
215
|
message: AgentWidgetMessage;
|
|
173
216
|
defaultRenderer: () => HTMLElement;
|
|
174
217
|
config: AgentWidgetConfig;
|
|
218
|
+
/** Resolve this approval as approved. Pass `{ remember: true }` for an "Always allow" affordance. */
|
|
219
|
+
approve: (options?: AgentWidgetApprovalDecisionOptions) => void;
|
|
220
|
+
/** Resolve this approval as denied. Pass `{ remember: true }` for an "Always deny" affordance. */
|
|
221
|
+
deny: (options?: AgentWidgetApprovalDecisionOptions) => void;
|
|
175
222
|
}) => HTMLElement | null;
|
|
176
223
|
|
|
177
224
|
/**
|
package/src/session.test.ts
CHANGED
|
@@ -793,6 +793,167 @@ describe('AgentWidgetSession.resolveApproval', () => {
|
|
|
793
793
|
expect(session.isStreaming()).toBe(false);
|
|
794
794
|
expect(errors.length).toBe(1);
|
|
795
795
|
});
|
|
796
|
+
|
|
797
|
+
it('keeps a resolved approval anchored in its original transcript position', async () => {
|
|
798
|
+
const earlier = new Date('2020-01-01T00:00:00.000Z').toISOString();
|
|
799
|
+
const later = new Date('2020-01-01T00:05:00.000Z').toISOString();
|
|
800
|
+
const approval = makeApproval();
|
|
801
|
+
// The bubble id follows the `approval-<approval.id>` convention used by
|
|
802
|
+
// resolveApproval's optimistic upsert.
|
|
803
|
+
const messageId = `approval-${approval.id}`;
|
|
804
|
+
|
|
805
|
+
const session = new AgentWidgetSession(
|
|
806
|
+
{
|
|
807
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
808
|
+
// Resolve locally (return void) so no network round-trip is needed.
|
|
809
|
+
approval: { onDecision: async () => {} },
|
|
810
|
+
initialMessages: [
|
|
811
|
+
{
|
|
812
|
+
id: messageId,
|
|
813
|
+
role: 'assistant',
|
|
814
|
+
content: '',
|
|
815
|
+
createdAt: earlier,
|
|
816
|
+
variant: 'approval',
|
|
817
|
+
approval,
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
id: 'after-1',
|
|
821
|
+
role: 'assistant',
|
|
822
|
+
content: 'A message created after the approval was requested.',
|
|
823
|
+
createdAt: later,
|
|
824
|
+
},
|
|
825
|
+
],
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
onMessagesChanged: () => {},
|
|
829
|
+
onStatusChanged: () => {},
|
|
830
|
+
onStreamingChanged: () => {},
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
await session.resolveApproval(approval, 'approved');
|
|
835
|
+
|
|
836
|
+
const messages = session.getMessages();
|
|
837
|
+
const approvalMsg = messages.find((m) => m.id === messageId);
|
|
838
|
+
const approvalIdx = messages.findIndex((m) => m.id === messageId);
|
|
839
|
+
const afterIdx = messages.findIndex((m) => m.id === 'after-1');
|
|
840
|
+
|
|
841
|
+
expect(approvalMsg?.approval?.status).toBe('approved');
|
|
842
|
+
// createdAt is preserved (not re-stamped to "now"), so the resolved bubble
|
|
843
|
+
// stays before the message that was created after it.
|
|
844
|
+
expect(approvalMsg?.createdAt).toBe(earlier);
|
|
845
|
+
expect(approvalIdx).toBeGreaterThanOrEqual(0);
|
|
846
|
+
expect(approvalIdx).toBeLessThan(afterIdx);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('forwards the decision options (e.g. remember) to onDecision', async () => {
|
|
850
|
+
const onDecision = vi.fn(async () => {});
|
|
851
|
+
const session = new AgentWidgetSession(
|
|
852
|
+
{
|
|
853
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
854
|
+
approval: { onDecision },
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
onMessagesChanged: () => {},
|
|
858
|
+
onStatusChanged: () => {},
|
|
859
|
+
onStreamingChanged: () => {},
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
await session.resolveApproval(makeApproval(), 'approved', { remember: true });
|
|
864
|
+
|
|
865
|
+
expect(onDecision).toHaveBeenCalledTimes(1);
|
|
866
|
+
expect(onDecision).toHaveBeenCalledWith(
|
|
867
|
+
expect.objectContaining({ toolName: 'send_email', approvalId: 'approval-1' }),
|
|
868
|
+
'approved',
|
|
869
|
+
{ remember: true }
|
|
870
|
+
);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('passes undefined options to onDecision when none are given', async () => {
|
|
874
|
+
const onDecision = vi.fn(async () => {});
|
|
875
|
+
const session = new AgentWidgetSession(
|
|
876
|
+
{
|
|
877
|
+
apiUrl: 'http://localhost:43111/api/chat/dispatch',
|
|
878
|
+
approval: { onDecision },
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
onMessagesChanged: () => {},
|
|
882
|
+
onStatusChanged: () => {},
|
|
883
|
+
onStreamingChanged: () => {},
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
await session.resolveApproval(makeApproval(), 'denied');
|
|
888
|
+
|
|
889
|
+
expect(onDecision).toHaveBeenCalledWith(
|
|
890
|
+
expect.objectContaining({ toolName: 'send_email' }),
|
|
891
|
+
'denied',
|
|
892
|
+
undefined
|
|
893
|
+
);
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
describe('AgentWidgetSession - approval context across agent_approval_complete', () => {
|
|
898
|
+
const sseStream = (events: Array<Record<string, unknown>>): ReadableStream<Uint8Array> => {
|
|
899
|
+
const encoder = new TextEncoder();
|
|
900
|
+
const body = events
|
|
901
|
+
.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`)
|
|
902
|
+
.join('');
|
|
903
|
+
return new ReadableStream({
|
|
904
|
+
start(controller) {
|
|
905
|
+
controller.enqueue(encoder.encode(body));
|
|
906
|
+
controller.close();
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
it('keeps toolName/description/toolType/reason/parameters when the sparse complete event resolves the bubble', async () => {
|
|
912
|
+
let messages: AgentWidgetMessage[] = [];
|
|
913
|
+
const session = new AgentWidgetSession(
|
|
914
|
+
{ apiUrl: 'http://localhost:8000' },
|
|
915
|
+
{
|
|
916
|
+
onMessagesChanged: (m) => { messages = m; },
|
|
917
|
+
onStatusChanged: () => {},
|
|
918
|
+
onStreamingChanged: () => {},
|
|
919
|
+
onError: () => {},
|
|
920
|
+
}
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// `agent_approval_complete` carries only the resolution — none of the
|
|
924
|
+
// context fields from `agent_approval_start`. The session merge must keep
|
|
925
|
+
// them so a full re-render of the resolved bubble (morph, virtual-scroll
|
|
926
|
+
// re-mount, storage restore) still shows the tool, description, and the
|
|
927
|
+
// agent's stated reason.
|
|
928
|
+
await session.connectStream(sseStream([
|
|
929
|
+
{
|
|
930
|
+
type: 'agent_approval_start',
|
|
931
|
+
executionId: 'exec_abc',
|
|
932
|
+
approvalId: 'appr_1',
|
|
933
|
+
toolName: 'send_email',
|
|
934
|
+
toolType: 'external',
|
|
935
|
+
description: 'Send an email to the customer',
|
|
936
|
+
reason: 'The user asked me to notify the customer.',
|
|
937
|
+
parameters: { to: 'customer@example.com' },
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
type: 'agent_approval_complete',
|
|
941
|
+
executionId: 'exec_abc',
|
|
942
|
+
approvalId: 'appr_1',
|
|
943
|
+
decision: 'approved',
|
|
944
|
+
resolvedBy: 'user',
|
|
945
|
+
},
|
|
946
|
+
]));
|
|
947
|
+
|
|
948
|
+
const bubble = messages.find((m) => m.id === 'approval-appr_1');
|
|
949
|
+
expect(bubble?.approval?.status).toBe('approved');
|
|
950
|
+
expect(bubble?.approval?.resolvedAt).toBeDefined();
|
|
951
|
+
expect(bubble?.approval?.toolName).toBe('send_email');
|
|
952
|
+
expect(bubble?.approval?.toolType).toBe('external');
|
|
953
|
+
expect(bubble?.approval?.description).toBe('Send an email to the customer');
|
|
954
|
+
expect(bubble?.approval?.reason).toBe('The user asked me to notify the customer.');
|
|
955
|
+
expect(bubble?.approval?.parameters).toEqual({ to: 'customer@example.com' });
|
|
956
|
+
});
|
|
796
957
|
});
|
|
797
958
|
|
|
798
959
|
describe('AgentWidgetSession - dispatch error fallback', () => {
|
package/src/session.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
AgentWidgetEvent,
|
|
6
6
|
AgentWidgetMessage,
|
|
7
7
|
AgentWidgetApproval,
|
|
8
|
+
AgentWidgetApprovalDecisionOptions,
|
|
8
9
|
WebMcpConfirmInfo,
|
|
9
10
|
AgentExecutionState,
|
|
10
11
|
ClientSession,
|
|
@@ -1196,7 +1197,8 @@ export class AgentWidgetSession {
|
|
|
1196
1197
|
*/
|
|
1197
1198
|
public async resolveApproval(
|
|
1198
1199
|
approval: AgentWidgetApproval,
|
|
1199
|
-
decision: 'approved' | 'denied'
|
|
1200
|
+
decision: 'approved' | 'denied',
|
|
1201
|
+
options?: AgentWidgetApprovalDecisionOptions
|
|
1200
1202
|
): Promise<void> {
|
|
1201
1203
|
// 1. Update approval message status immediately for responsive UI
|
|
1202
1204
|
const approvalMessageId = `approval-${approval.id}`;
|
|
@@ -1205,11 +1207,19 @@ export class AgentWidgetSession {
|
|
|
1205
1207
|
status: decision,
|
|
1206
1208
|
resolvedAt: Date.now(),
|
|
1207
1209
|
};
|
|
1210
|
+
// Anchor the bubble where the agent paused for permission. An approval is a
|
|
1211
|
+
// timeline checkpoint, not a "now" event, so resolving it must preserve the
|
|
1212
|
+
// original message's createdAt/sequence — otherwise sortMessages (which
|
|
1213
|
+
// orders by createdAt first) would re-stamp it to now and float it past any
|
|
1214
|
+
// message created later (e.g. a long-pending approval resolved after more
|
|
1215
|
+
// conversation, or restored/replayed transcripts).
|
|
1216
|
+
const existing = this.messages.find((m) => m.id === approvalMessageId);
|
|
1208
1217
|
const updatedMessage: AgentWidgetMessage = {
|
|
1209
1218
|
id: approvalMessageId,
|
|
1210
1219
|
role: "assistant",
|
|
1211
1220
|
content: "",
|
|
1212
|
-
createdAt: new Date().toISOString(),
|
|
1221
|
+
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
1222
|
+
...(existing?.sequence !== undefined ? { sequence: existing.sequence } : {}),
|
|
1213
1223
|
streaming: false,
|
|
1214
1224
|
variant: "approval",
|
|
1215
1225
|
approval: updatedApproval,
|
|
@@ -1238,7 +1248,8 @@ export class AgentWidgetSession {
|
|
|
1238
1248
|
agentId: approval.agentId,
|
|
1239
1249
|
toolName: approval.toolName,
|
|
1240
1250
|
},
|
|
1241
|
-
decision
|
|
1251
|
+
decision,
|
|
1252
|
+
options
|
|
1242
1253
|
);
|
|
1243
1254
|
} else {
|
|
1244
1255
|
response = await this.client.resolveApproval(
|
|
@@ -2648,6 +2659,33 @@ export class AgentWidgetSession {
|
|
|
2648
2659
|
awaitingLocalTool: false,
|
|
2649
2660
|
};
|
|
2650
2661
|
}
|
|
2662
|
+
// Approval equivalent: `agent_approval_complete` carries only the
|
|
2663
|
+
// resolution (approvalId, decision, resolvedBy) — the runtime does not
|
|
2664
|
+
// re-send toolName/description/toolType/reason/parameters, so client.ts
|
|
2665
|
+
// rebuilds the approval with empty required fields and the optional
|
|
2666
|
+
// ones absent. A wholesale `approval` replacement would wipe that
|
|
2667
|
+
// context from the resolved bubble on the next full re-render (morph,
|
|
2668
|
+
// virtual-scroll re-mount, storage restore). Merge field-wise instead:
|
|
2669
|
+
// take the resolution from the incoming event, keep existing context
|
|
2670
|
+
// wherever the event is silent or empty.
|
|
2671
|
+
if (
|
|
2672
|
+
existing.approval &&
|
|
2673
|
+
withSequence.approval &&
|
|
2674
|
+
existing.approval.id === withSequence.approval.id
|
|
2675
|
+
) {
|
|
2676
|
+
const prior = existing.approval;
|
|
2677
|
+
const incoming = withSequence.approval;
|
|
2678
|
+
merged.approval = {
|
|
2679
|
+
...prior,
|
|
2680
|
+
...incoming,
|
|
2681
|
+
executionId: incoming.executionId || prior.executionId,
|
|
2682
|
+
toolName: incoming.toolName || prior.toolName,
|
|
2683
|
+
description: incoming.description || prior.description,
|
|
2684
|
+
toolType: incoming.toolType ?? prior.toolType,
|
|
2685
|
+
reason: incoming.reason ?? prior.reason,
|
|
2686
|
+
parameters: incoming.parameters ?? prior.parameters,
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2651
2689
|
// WebMCP equivalent: once a `webmcp:*` tool has started resolving
|
|
2652
2690
|
// (inflight) or resolved, a duplicate `step_await` re-emit must not
|
|
2653
2691
|
// flip `awaitingLocalTool` back to true and resurrect the "waiting on
|
package/src/styles/widget.css
CHANGED
|
@@ -2616,6 +2616,25 @@
|
|
|
2616
2616
|
height: var(--persona-scroll-to-bottom-icon-size, 14px);
|
|
2617
2617
|
}
|
|
2618
2618
|
|
|
2619
|
+
/* New-messages count badge pinned to the affordance's top-right corner. */
|
|
2620
|
+
[data-persona-root] .persona-scroll-to-bottom-indicator [data-persona-scroll-to-bottom-count] {
|
|
2621
|
+
position: absolute;
|
|
2622
|
+
top: -6px;
|
|
2623
|
+
right: -6px;
|
|
2624
|
+
box-sizing: border-box;
|
|
2625
|
+
min-width: 18px;
|
|
2626
|
+
height: 18px;
|
|
2627
|
+
padding: 0 5px;
|
|
2628
|
+
border-radius: var(--persona-radius-full, 9999px);
|
|
2629
|
+
border: 1px solid var(--persona-scroll-to-bottom-border, var(--persona-primary, #111827));
|
|
2630
|
+
background: var(--persona-scroll-to-bottom-count-bg, var(--persona-surface, #ffffff));
|
|
2631
|
+
color: var(--persona-scroll-to-bottom-count-fg, var(--persona-primary, #111827));
|
|
2632
|
+
font-size: 11px;
|
|
2633
|
+
font-weight: 600;
|
|
2634
|
+
line-height: 16px;
|
|
2635
|
+
text-align: center;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2619
2638
|
[data-persona-root] .persona-scroll-to-bottom-indicator:hover {
|
|
2620
2639
|
opacity: 0.92;
|
|
2621
2640
|
}
|
|
@@ -49,6 +49,16 @@ describe("theme editor preview demo data", () => {
|
|
|
49
49
|
expect(reasoningMessage.reasoning?.status).toBe("streaming");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
it("creates a pending approval preview entry for theming the approval bubble", () => {
|
|
53
|
+
const message = createPreviewTranscriptEntry("approval-request", 3);
|
|
54
|
+
|
|
55
|
+
expect(message.variant).toBe("approval");
|
|
56
|
+
expect(message.approval?.status).toBe("pending");
|
|
57
|
+
expect(message.approval?.toolName).toBe("add_to_cart");
|
|
58
|
+
// Includes parameters so the params block is exercised when theming.
|
|
59
|
+
expect(message.approval?.parameters).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
it("appends public preview transcript entries to an existing conversation", () => {
|
|
53
63
|
const messages = appendPreviewTranscriptEntry([], "tool-running");
|
|
54
64
|
const updated = appendPreviewTranscriptEntry(messages, "reasoning-complete");
|