@runtypelabs/persona 3.29.1 → 3.30.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 +55 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +112 -7
- package/dist/index.d.ts +112 -7
- package/dist/index.global.js +59 -59
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- 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 +107 -4
- package/dist/smart-dom-reader.d.ts +107 -4
- package/dist/theme-editor.cjs +38 -38
- package/dist/theme-editor.d.cts +111 -6
- package/dist/theme-editor.d.ts +111 -6
- package/dist/theme-editor.js +38 -38
- 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/package.json +8 -2
- package/src/components/approval-bubble.test.ts +268 -0
- package/src/components/approval-bubble.ts +170 -20
- package/src/components/launcher.ts +6 -3
- package/src/components/tool-bubble.test.ts +39 -0
- package/src/components/tool-bubble.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 +99 -0
- package/src/session.ts +14 -3
- 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 +1 -1
- package/src/types/theme.ts +2 -0
- package/src/types.ts +59 -2
- package/src/ui.approval-plugin.test.ts +204 -0
- package/src/ui.ts +149 -16
- 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,105 @@ 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
|
+
});
|
|
796
895
|
});
|
|
797
896
|
|
|
798
897
|
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(
|
|
@@ -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");
|
|
@@ -206,7 +206,8 @@ export type PreviewTranscriptEntryPreset =
|
|
|
206
206
|
| 'reasoning-streaming'
|
|
207
207
|
| 'reasoning-complete'
|
|
208
208
|
| 'tool-running'
|
|
209
|
-
| 'tool-complete'
|
|
209
|
+
| 'tool-complete'
|
|
210
|
+
| 'approval-request';
|
|
210
211
|
|
|
211
212
|
const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, string> = {
|
|
212
213
|
'user-message': 'User message',
|
|
@@ -218,6 +219,7 @@ const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, str
|
|
|
218
219
|
'reasoning-complete': 'Reasoning (complete)',
|
|
219
220
|
'tool-running': 'Tool call (running)',
|
|
220
221
|
'tool-complete': 'Tool call (complete)',
|
|
222
|
+
'approval-request': 'Approval request',
|
|
221
223
|
};
|
|
222
224
|
|
|
223
225
|
export function getPreviewTranscriptPresetLabel(preset: PreviewTranscriptEntryPreset): string {
|
|
@@ -325,6 +327,32 @@ export function createPreviewTranscriptEntry(
|
|
|
325
327
|
durationMs: 1200,
|
|
326
328
|
},
|
|
327
329
|
};
|
|
330
|
+
case 'approval-request': {
|
|
331
|
+
// Use the `approval-<approvalId>` id convention so the optimistic status
|
|
332
|
+
// flip in `session.resolveApproval` updates this bubble in place when the
|
|
333
|
+
// Approve/Deny buttons are clicked.
|
|
334
|
+
const approvalId = `preview-approval-${suffix}`;
|
|
335
|
+
return {
|
|
336
|
+
id: `approval-${approvalId}`,
|
|
337
|
+
role: 'assistant',
|
|
338
|
+
content: '',
|
|
339
|
+
createdAt,
|
|
340
|
+
streaming: false,
|
|
341
|
+
variant: 'approval',
|
|
342
|
+
approval: {
|
|
343
|
+
id: approvalId,
|
|
344
|
+
status: 'pending',
|
|
345
|
+
agentId: 'preview-agent',
|
|
346
|
+
executionId: `preview-exec-${suffix}`,
|
|
347
|
+
toolName: 'add_to_cart',
|
|
348
|
+
description:
|
|
349
|
+
'Add the selected item to the shopping cart. Approve to let the assistant continue.',
|
|
350
|
+
parameters: {
|
|
351
|
+
items: [{ productEntityId: 129, quantity: 1 }],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
328
356
|
case 'tool-complete':
|
|
329
357
|
return {
|
|
330
358
|
id: `preview-seq-tool-complete-${suffix}`,
|
|
@@ -27,6 +27,23 @@ describe("theme editor scroll-to-bottom controls", () => {
|
|
|
27
27
|
expect(fieldPaths).toContain("theme.components.scrollToBottom.iconSize");
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
it("exposes a shadow control for every themeable component", () => {
|
|
31
|
+
const fieldPaths = COMPONENTS_SECTIONS.flatMap((section) => section.fields.map((field) => field.path));
|
|
32
|
+
|
|
33
|
+
// Pre-existing shadow controls.
|
|
34
|
+
expect(fieldPaths).toContain("theme.components.launcher.shadow");
|
|
35
|
+
expect(fieldPaths).toContain("theme.components.panel.shadow");
|
|
36
|
+
expect(fieldPaths).toContain("theme.components.scrollToBottom.shadow");
|
|
37
|
+
// Newly added component shadow controls.
|
|
38
|
+
expect(fieldPaths).toContain("theme.components.message.user.shadow");
|
|
39
|
+
expect(fieldPaths).toContain("theme.components.message.assistant.shadow");
|
|
40
|
+
expect(fieldPaths).toContain("theme.components.toolBubble.shadow");
|
|
41
|
+
expect(fieldPaths).toContain("theme.components.reasoningBubble.shadow");
|
|
42
|
+
expect(fieldPaths).toContain("theme.components.approval.requested.shadow");
|
|
43
|
+
expect(fieldPaths).toContain("theme.components.introCard.shadow");
|
|
44
|
+
expect(fieldPaths).toContain("theme.components.composer.shadow");
|
|
45
|
+
});
|
|
46
|
+
|
|
30
47
|
it("adds a scroll-to-bottom interface role mapping", () => {
|
|
31
48
|
const role = ALL_ROLES.find((entry) => entry.roleId === "role-scroll-to-bottom");
|
|
32
49
|
|
|
@@ -455,6 +455,36 @@ const scrollToBottomSectionDef: SectionDef = {
|
|
|
455
455
|
],
|
|
456
456
|
};
|
|
457
457
|
|
|
458
|
+
// Reusable shadow-scale options matching the launcher / panel / scroll-to-bottom selects.
|
|
459
|
+
const SHADOW_SCALE_OPTIONS: { value: string; label: string }[] = [
|
|
460
|
+
{ value: 'palette.shadows.none', label: 'None' },
|
|
461
|
+
{ value: 'palette.shadows.sm', label: 'Small' },
|
|
462
|
+
{ value: 'palette.shadows.md', label: 'Medium' },
|
|
463
|
+
{ value: 'palette.shadows.lg', label: 'Large' },
|
|
464
|
+
{ value: 'palette.shadows.xl', label: 'Extra Large' },
|
|
465
|
+
];
|
|
466
|
+
|
|
467
|
+
// The approval and intro-card defaults are a bespoke "card" shadow rather than a
|
|
468
|
+
// palette step, so expose it as a leading "Default" option users can keep.
|
|
469
|
+
const CARD_SHADOW = '0 5px 15px rgba(15, 23, 42, 0.08)';
|
|
470
|
+
const CARD_SHADOW_OPTIONS = [{ value: CARD_SHADOW, label: 'Default' }, ...SHADOW_SCALE_OPTIONS];
|
|
471
|
+
|
|
472
|
+
const componentShadowsSectionDef: SectionDef = {
|
|
473
|
+
id: 'comp-shadows',
|
|
474
|
+
title: 'Component Shadows',
|
|
475
|
+
description: 'Box-shadow for chat bubbles and surfaces.',
|
|
476
|
+
collapsed: true,
|
|
477
|
+
fields: [
|
|
478
|
+
{ id: 'shadow-msg-user', label: 'User Message', type: 'select', path: 'theme.components.message.user.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
|
|
479
|
+
{ id: 'shadow-msg-assistant', label: 'Assistant Message', type: 'select', path: 'theme.components.message.assistant.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
|
|
480
|
+
{ id: 'shadow-tool-bubble', label: 'Tool Call Bubble', type: 'select', path: 'theme.components.toolBubble.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
|
|
481
|
+
{ id: 'shadow-reasoning-bubble', label: 'Reasoning Bubble', type: 'select', path: 'theme.components.reasoningBubble.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
|
|
482
|
+
{ id: 'shadow-approval', label: 'Approval Bubble', type: 'select', path: 'theme.components.approval.requested.shadow', defaultValue: CARD_SHADOW, options: CARD_SHADOW_OPTIONS },
|
|
483
|
+
{ id: 'shadow-intro-card', label: 'Intro Card', type: 'select', path: 'theme.components.introCard.shadow', defaultValue: CARD_SHADOW, options: CARD_SHADOW_OPTIONS },
|
|
484
|
+
{ id: 'shadow-composer', label: 'Composer', type: 'select', path: 'theme.components.composer.shadow', defaultValue: 'palette.shadows.none', options: SHADOW_SCALE_OPTIONS },
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
|
|
458
488
|
/** Shared shape sections (not scoped to light/dark) */
|
|
459
489
|
export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
|
|
460
490
|
panelLayoutSectionDef,
|
|
@@ -462,6 +492,7 @@ export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
|
|
|
462
492
|
messageShapeSectionDef,
|
|
463
493
|
inputShapeSectionDef,
|
|
464
494
|
buttonShapeSectionDef,
|
|
495
|
+
componentShadowsSectionDef,
|
|
465
496
|
];
|
|
466
497
|
|
|
467
498
|
/** Component color sections (can be scoped for light/dark) */
|
package/src/theme-reference.ts
CHANGED
|
@@ -136,7 +136,7 @@ export const THEME_TOKEN_DOCS = {
|
|
|
136
136
|
voice:
|
|
137
137
|
'recording (indicator, background, border), processing (icon, background), speaking (icon).',
|
|
138
138
|
approval:
|
|
139
|
-
'requested (background, border, text), approve (background, foreground), deny (background, foreground).',
|
|
139
|
+
'requested (background, border, text, shadow), approve (background, foreground), deny (background, foreground).',
|
|
140
140
|
attachment: 'image (background, border).',
|
|
141
141
|
scrollToBottom:
|
|
142
142
|
'Floating scroll-to-bottom affordance shared by transcript and event stream: background, foreground, border, size, borderRadius, shadow, padding, gap, fontSize, iconSize.',
|
package/src/types/theme.ts
CHANGED
|
@@ -330,6 +330,8 @@ export interface ApprovalTokens {
|
|
|
330
330
|
background: TokenReference<'color'>;
|
|
331
331
|
border: TokenReference<'color'>;
|
|
332
332
|
text: TokenReference<'color'>;
|
|
333
|
+
/** Box-shadow for the approval bubble (token ref or raw CSS, e.g. `none`). */
|
|
334
|
+
shadow: string;
|
|
333
335
|
};
|
|
334
336
|
approve: ComponentTokenSet;
|
|
335
337
|
deny: ComponentTokenSet;
|