@runtypelabs/persona 3.29.0 → 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/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CxvHw0X6.d.cts → types-B_Qazlm4.d.cts} +6 -0
- package/dist/animations/{types-CxvHw0X6.d.ts → types-B_Qazlm4.d.ts} +6 -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 +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +127 -7
- package/dist/index.d.ts +127 -7
- package/dist/index.global.js +60 -60
- 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 +113 -4
- package/dist/smart-dom-reader.d.ts +113 -4
- package/dist/theme-editor.cjs +38 -38
- package/dist/theme-editor.d.cts +117 -6
- package/dist/theme-editor.d.ts +117 -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/client.test.ts +40 -0
- package/src/client.ts +18 -4
- 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/generated/runtype-openapi-contract.ts +12 -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 +204 -32
- package/src/session.webmcp.test.ts +326 -6
- 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,204 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import type { AgentWidgetPlugin } from "./plugins/types";
|
|
7
|
+
|
|
8
|
+
const createMount = () => {
|
|
9
|
+
const mount = document.createElement("div");
|
|
10
|
+
document.body.appendChild(mount);
|
|
11
|
+
return mount;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const injectApproval = (
|
|
15
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
16
|
+
{
|
|
17
|
+
id = "appr-1",
|
|
18
|
+
status = "pending" as "pending" | "approved" | "denied",
|
|
19
|
+
} = {}
|
|
20
|
+
) => {
|
|
21
|
+
controller.injectTestMessage({
|
|
22
|
+
type: "message",
|
|
23
|
+
message: {
|
|
24
|
+
id: `approval-${id}`,
|
|
25
|
+
role: "assistant",
|
|
26
|
+
content: "",
|
|
27
|
+
createdAt: "2026-04-24T00:00:00.000Z",
|
|
28
|
+
streaming: false,
|
|
29
|
+
variant: "approval",
|
|
30
|
+
approval: {
|
|
31
|
+
id,
|
|
32
|
+
status,
|
|
33
|
+
agentId: "agent_1",
|
|
34
|
+
executionId: "exec_1",
|
|
35
|
+
toolName: "Search documentation",
|
|
36
|
+
description: "Search the docs",
|
|
37
|
+
parameters: { query: "approval theming" },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const injectNoise = (
|
|
44
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
45
|
+
id = "noise"
|
|
46
|
+
) => {
|
|
47
|
+
controller.injectTestMessage({
|
|
48
|
+
type: "message",
|
|
49
|
+
message: {
|
|
50
|
+
id,
|
|
51
|
+
role: "user",
|
|
52
|
+
content: "noise",
|
|
53
|
+
createdAt: "2026-04-26T00:00:00.000Z",
|
|
54
|
+
streaming: false,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("renderApproval plugin hook", () => {
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
document.body.innerHTML = "";
|
|
62
|
+
if (typeof localStorage !== "undefined") localStorage.clear();
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders a plugin-returned element in place of the built-in bubble", () => {
|
|
67
|
+
const plugin: AgentWidgetPlugin = {
|
|
68
|
+
id: "custom-approval",
|
|
69
|
+
renderApproval: ({ message }) => {
|
|
70
|
+
const root = document.createElement("div");
|
|
71
|
+
root.setAttribute("data-test-id", "custom-approval");
|
|
72
|
+
root.textContent = message.approval?.toolName ?? "";
|
|
73
|
+
return root;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const mount = createMount();
|
|
78
|
+
const controller = createAgentExperience(mount, {
|
|
79
|
+
apiUrl: "https://api.example.com/chat",
|
|
80
|
+
launcher: { enabled: false },
|
|
81
|
+
plugins: [plugin],
|
|
82
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
83
|
+
|
|
84
|
+
injectApproval(controller);
|
|
85
|
+
|
|
86
|
+
const custom = mount.querySelector('[data-test-id="custom-approval"]');
|
|
87
|
+
expect(custom).not.toBeNull();
|
|
88
|
+
expect(custom?.textContent).toBe("Search documentation");
|
|
89
|
+
// The built-in bubble must NOT also render.
|
|
90
|
+
expect(mount.querySelector(".persona-approval-bubble")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("preserves plugin click listeners (e.g. an accordion) across morph re-renders", () => {
|
|
94
|
+
const toggleSpy = vi.fn();
|
|
95
|
+
const plugin: AgentWidgetPlugin = {
|
|
96
|
+
id: "accordion-approval",
|
|
97
|
+
renderApproval: ({ message }) => {
|
|
98
|
+
if (message.approval?.status !== "pending") return null;
|
|
99
|
+
const root = document.createElement("div");
|
|
100
|
+
root.setAttribute("data-test-id", "appr-root");
|
|
101
|
+
const head = document.createElement("button");
|
|
102
|
+
head.type = "button";
|
|
103
|
+
head.setAttribute("data-test-id", "appr-head");
|
|
104
|
+
head.setAttribute("data-action", "toggle");
|
|
105
|
+
const params = document.createElement("pre");
|
|
106
|
+
params.setAttribute("data-test-id", "appr-params");
|
|
107
|
+
// Single delegated listener at the root — the recommended pattern.
|
|
108
|
+
root.addEventListener("click", (e) => {
|
|
109
|
+
const target = (e.target as HTMLElement).closest("[data-action]");
|
|
110
|
+
if (target?.getAttribute("data-action") === "toggle") {
|
|
111
|
+
params.hidden = !params.hidden;
|
|
112
|
+
toggleSpy();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
root.append(head, params);
|
|
116
|
+
return root;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const mount = createMount();
|
|
121
|
+
const controller = createAgentExperience(mount, {
|
|
122
|
+
apiUrl: "https://api.example.com/chat",
|
|
123
|
+
launcher: { enabled: false },
|
|
124
|
+
plugins: [plugin],
|
|
125
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
126
|
+
|
|
127
|
+
injectApproval(controller);
|
|
128
|
+
// Force a re-render — the scenario where innerHTML morph drops listeners
|
|
129
|
+
// on a freshly-built plugin element unless it's hydrated post-morph.
|
|
130
|
+
injectNoise(controller);
|
|
131
|
+
|
|
132
|
+
const head = mount.querySelector<HTMLButtonElement>('[data-test-id="appr-head"]');
|
|
133
|
+
const params = mount.querySelector<HTMLPreElement>('[data-test-id="appr-params"]');
|
|
134
|
+
expect(head).not.toBeNull();
|
|
135
|
+
expect(params).not.toBeNull();
|
|
136
|
+
|
|
137
|
+
head!.click();
|
|
138
|
+
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(params!.hidden).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("resolves via the approve callback (with the remember option)", async () => {
|
|
143
|
+
const onDecision = vi.fn(async () => {});
|
|
144
|
+
const plugin: AgentWidgetPlugin = {
|
|
145
|
+
id: "approve-callback",
|
|
146
|
+
renderApproval: ({ message, approve }) => {
|
|
147
|
+
if (message.approval?.status !== "pending") return null;
|
|
148
|
+
const root = document.createElement("div");
|
|
149
|
+
const btn = document.createElement("button");
|
|
150
|
+
btn.setAttribute("data-test-id", "always-allow");
|
|
151
|
+
btn.addEventListener("click", () => approve({ remember: true }));
|
|
152
|
+
root.appendChild(btn);
|
|
153
|
+
return root;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const mount = createMount();
|
|
158
|
+
const controller = createAgentExperience(mount, {
|
|
159
|
+
apiUrl: "https://api.example.com/chat",
|
|
160
|
+
launcher: { enabled: false },
|
|
161
|
+
approval: { onDecision },
|
|
162
|
+
plugins: [plugin],
|
|
163
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
164
|
+
|
|
165
|
+
injectApproval(controller);
|
|
166
|
+
injectNoise(controller);
|
|
167
|
+
|
|
168
|
+
const btn = mount.querySelector<HTMLButtonElement>('[data-test-id="always-allow"]');
|
|
169
|
+
expect(btn).not.toBeNull();
|
|
170
|
+
btn!.click();
|
|
171
|
+
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
expect(onDecision).toHaveBeenCalledWith(
|
|
174
|
+
expect.objectContaining({ toolName: "Search documentation" }),
|
|
175
|
+
"approved",
|
|
176
|
+
{ remember: true }
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("falls through to the built-in bubble when the plugin returns null (resolved state)", () => {
|
|
181
|
+
const plugin: AgentWidgetPlugin = {
|
|
182
|
+
id: "pending-only-approval",
|
|
183
|
+
renderApproval: ({ message }) => {
|
|
184
|
+
if (message.approval?.status !== "pending") return null;
|
|
185
|
+
const root = document.createElement("div");
|
|
186
|
+
root.setAttribute("data-test-id", "custom-pending");
|
|
187
|
+
return root;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const mount = createMount();
|
|
192
|
+
const controller = createAgentExperience(mount, {
|
|
193
|
+
apiUrl: "https://api.example.com/chat",
|
|
194
|
+
launcher: { enabled: false },
|
|
195
|
+
plugins: [plugin],
|
|
196
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
197
|
+
|
|
198
|
+
// Inject directly in the resolved state — plugin opts out, built-in renders.
|
|
199
|
+
injectApproval(controller, { status: "approved" });
|
|
200
|
+
|
|
201
|
+
expect(mount.querySelector('[data-test-id="custom-pending"]')).toBeNull();
|
|
202
|
+
expect(mount.querySelector(".persona-approval-bubble")).not.toBeNull();
|
|
203
|
+
});
|
|
204
|
+
});
|
package/src/ui.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveSanitizer } from "./utils/sanitize";
|
|
|
3
3
|
import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
|
|
4
4
|
import {
|
|
5
5
|
AgentWidgetConfig,
|
|
6
|
+
AgentWidgetApprovalDecisionOptions,
|
|
6
7
|
AgentWidgetMessage,
|
|
7
8
|
AgentWidgetEvent,
|
|
8
9
|
AgentWidgetStorageAdapter,
|
|
@@ -85,7 +86,7 @@ import {
|
|
|
85
86
|
setCurrentAnswer,
|
|
86
87
|
} from "./components/ask-user-question-bubble";
|
|
87
88
|
import { formatElapsedMs } from "./utils/formatting";
|
|
88
|
-
import { createApprovalBubble } from "./components/approval-bubble";
|
|
89
|
+
import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
|
|
89
90
|
import { createSuggestions } from "./components/suggestions";
|
|
90
91
|
import { EventStreamBuffer } from "./utils/event-stream-buffer";
|
|
91
92
|
import { EventStreamStore } from "./utils/event-stream-store";
|
|
@@ -375,8 +376,14 @@ type Controller = {
|
|
|
375
376
|
* Programmatically resolve a pending approval.
|
|
376
377
|
* @param approvalId - The approval ID to resolve
|
|
377
378
|
* @param decision - "approved" or "denied"
|
|
379
|
+
* @param options - Optional decision context (e.g. `{ remember: true }`),
|
|
380
|
+
* forwarded to `config.approval.onDecision`.
|
|
378
381
|
*/
|
|
379
|
-
resolveApproval: (
|
|
382
|
+
resolveApproval: (
|
|
383
|
+
approvalId: string,
|
|
384
|
+
decision: 'approved' | 'denied',
|
|
385
|
+
options?: AgentWidgetApprovalDecisionOptions
|
|
386
|
+
) => Promise<void>;
|
|
380
387
|
};
|
|
381
388
|
|
|
382
389
|
const buildPostprocessor = (
|
|
@@ -1218,7 +1225,7 @@ export const createAgentExperience = (
|
|
|
1218
1225
|
if (!headerButton) return;
|
|
1219
1226
|
|
|
1220
1227
|
// Find the parent bubble element
|
|
1221
|
-
const bubble = headerButton.closest('.persona-reasoning-bubble, .persona-tool-bubble') as HTMLElement;
|
|
1228
|
+
const bubble = headerButton.closest('.persona-reasoning-bubble, .persona-tool-bubble, .persona-approval-bubble') as HTMLElement;
|
|
1222
1229
|
if (!bubble) return;
|
|
1223
1230
|
|
|
1224
1231
|
// Get message ID from bubble
|
|
@@ -1242,6 +1249,12 @@ export const createAgentExperience = (
|
|
|
1242
1249
|
toolExpansionState.add(messageId);
|
|
1243
1250
|
}
|
|
1244
1251
|
updateToolBubbleUI(messageId, bubble, config);
|
|
1252
|
+
} else if (bubbleType === 'approval') {
|
|
1253
|
+
const approvalConfig = config.approval !== false ? config.approval : undefined;
|
|
1254
|
+
const defaultExpanded = (approvalConfig?.detailsDisplay ?? 'collapsed') === 'expanded';
|
|
1255
|
+
const expanded = approvalDetailsExpansionState.get(messageId) ?? defaultExpanded;
|
|
1256
|
+
approvalDetailsExpansionState.set(messageId, !expanded);
|
|
1257
|
+
updateApprovalDetailsUI(messageId, bubble, config);
|
|
1245
1258
|
}
|
|
1246
1259
|
// Invalidate cached wrapper so next render rebuilds with current expansion state
|
|
1247
1260
|
messageCache.delete(messageId);
|
|
@@ -2599,6 +2612,13 @@ export const createAgentExperience = (
|
|
|
2599
2612
|
// expensive rebuild on fingerprint change so user state inside the rendered
|
|
2600
2613
|
// component (e.g. partially-filled form inputs) is not wiped on every pass.
|
|
2601
2614
|
const lastComponentDirectiveFingerprint = new Map<string, string>();
|
|
2615
|
+
// Same idea for plugin-rendered approval bubbles (`renderApproval`). The
|
|
2616
|
+
// custom element is injected into the live DOM post-morph so its event
|
|
2617
|
+
// listeners (Approve/Deny, an expandable parameters accordion, etc.) survive;
|
|
2618
|
+
// this map gates the rebuild on fingerprint change so interactive state (e.g.
|
|
2619
|
+
// a collapsed accordion) is not reset on every pass while the approval is
|
|
2620
|
+
// pending.
|
|
2621
|
+
const lastApprovalBubbleFingerprint = new Map<string, string>();
|
|
2602
2622
|
let configVersion = 0;
|
|
2603
2623
|
const autoFollow = createFollowStateController();
|
|
2604
2624
|
let lastScrollTop = 0;
|
|
@@ -2977,10 +2997,26 @@ export const createAgentExperience = (
|
|
|
2977
2997
|
const componentDirectiveHydrate: ComponentDirectiveHydrate[] = [];
|
|
2978
2998
|
const componentStreamingEnabled = config.enableComponentStreaming !== false;
|
|
2979
2999
|
|
|
3000
|
+
// Plugin-rendered approval bubbles use the same stub-and-hydrate pattern:
|
|
3001
|
+
// `renderApproval` may attach listeners (the built-in bubble resolves via
|
|
3002
|
+
// delegation on `messagesWrapper`, but a custom element owns its own
|
|
3003
|
+
// interactivity), and idiomorph imports nodes via `document.importNode`,
|
|
3004
|
+
// which strips them. So we build the live element, append a stub during
|
|
3005
|
+
// morph, and inject the live element afterward.
|
|
3006
|
+
const hasApprovalPlugin = plugins.some((p) => p.renderApproval) && config.approval !== false;
|
|
3007
|
+
type ApprovalPluginHydrate = {
|
|
3008
|
+
messageId: string;
|
|
3009
|
+
fingerprint: string;
|
|
3010
|
+
bubble: HTMLElement | null;
|
|
3011
|
+
};
|
|
3012
|
+
const approvalPluginHydrate: ApprovalPluginHydrate[] = [];
|
|
3013
|
+
|
|
2980
3014
|
messages.forEach((message) => {
|
|
2981
3015
|
activeMessageIds.add(message.id);
|
|
2982
3016
|
|
|
2983
3017
|
const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
|
|
3018
|
+
const approvalWithPlugin =
|
|
3019
|
+
hasApprovalPlugin && message.variant === "approval" && !!message.approval;
|
|
2984
3020
|
const hasDirectiveBubble =
|
|
2985
3021
|
!askWithPlugin &&
|
|
2986
3022
|
message.role === "assistant" &&
|
|
@@ -2988,6 +3024,14 @@ export const createAgentExperience = (
|
|
|
2988
3024
|
componentStreamingEnabled &&
|
|
2989
3025
|
hasComponentDirective(message);
|
|
2990
3026
|
|
|
3027
|
+
// If a message stops being an approval-plugin bubble, strip
|
|
3028
|
+
// `data-preserve-runtime` so the next morph can replace the live wrapper.
|
|
3029
|
+
if (!approvalWithPlugin && lastApprovalBubbleFingerprint.has(message.id)) {
|
|
3030
|
+
const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
|
|
3031
|
+
existing?.removeAttribute("data-preserve-runtime");
|
|
3032
|
+
lastApprovalBubbleFingerprint.delete(message.id);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
2991
3035
|
// If a message previously rendered as a directive bubble but no longer
|
|
2992
3036
|
// does (e.g. content was rewritten), strip `data-preserve-runtime` from
|
|
2993
3037
|
// the live wrapper so the next morph can replace it.
|
|
@@ -3009,7 +3053,7 @@ export const createAgentExperience = (
|
|
|
3009
3053
|
}`
|
|
3010
3054
|
: "";
|
|
3011
3055
|
const fingerprint = computeMessageFingerprint(message, configVersion) + askMeta;
|
|
3012
|
-
const cachedWrapper = (askWithPlugin || hasDirectiveBubble)
|
|
3056
|
+
const cachedWrapper = (askWithPlugin || approvalWithPlugin || hasDirectiveBubble)
|
|
3013
3057
|
? null
|
|
3014
3058
|
: getCachedWrapper(messageCache, message.id, fingerprint);
|
|
3015
3059
|
if (cachedWrapper) {
|
|
@@ -3043,9 +3087,9 @@ export const createAgentExperience = (
|
|
|
3043
3087
|
if (message.variant === "tool" && p.renderToolCall) {
|
|
3044
3088
|
return true;
|
|
3045
3089
|
}
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3090
|
+
// Approval plugins are handled via the stub-and-hydrate path below
|
|
3091
|
+
// (see `approvalWithPlugin`), not this inline morph path, so their
|
|
3092
|
+
// listeners survive — so they are intentionally excluded here.
|
|
3049
3093
|
if (!message.variant && p.renderMessage) {
|
|
3050
3094
|
return true;
|
|
3051
3095
|
}
|
|
@@ -3159,6 +3203,74 @@ export const createAgentExperience = (
|
|
|
3159
3203
|
}
|
|
3160
3204
|
return;
|
|
3161
3205
|
}
|
|
3206
|
+
} else if (approvalWithPlugin) {
|
|
3207
|
+
// Plugin-rendered approval bubble. Build the live element with its
|
|
3208
|
+
// listeners, append a stub for the morph pass, and hydrate the live
|
|
3209
|
+
// element into the morphed wrapper afterward (same trick as
|
|
3210
|
+
// `renderAskUserQuestion` / component directives) so Approve/Deny and
|
|
3211
|
+
// any accordion listeners survive idiomorph's `importNode`. Gate the
|
|
3212
|
+
// rebuild on fingerprint so interactive state (e.g. a collapsed
|
|
3213
|
+
// accordion) is preserved while the approval stays pending.
|
|
3214
|
+
const approvalPlugin = plugins.find((p) => typeof p.renderApproval === "function");
|
|
3215
|
+
const lastFp = lastApprovalBubbleFingerprint.get(message.id);
|
|
3216
|
+
const needsRebuild = lastFp !== fingerprint;
|
|
3217
|
+
let liveBubble: HTMLElement | null = null;
|
|
3218
|
+
|
|
3219
|
+
if (needsRebuild && approvalPlugin?.renderApproval) {
|
|
3220
|
+
// Re-find the live message at decision time so we resolve against
|
|
3221
|
+
// current state, and route WebMCP gate approvals to the local
|
|
3222
|
+
// resolver — mirroring the built-in delegated handler.
|
|
3223
|
+
const approvalMessageId = message.id;
|
|
3224
|
+
const resolveDecision = (
|
|
3225
|
+
decision: "approved" | "denied",
|
|
3226
|
+
options?: AgentWidgetApprovalDecisionOptions
|
|
3227
|
+
): void => {
|
|
3228
|
+
const live = sessionRef.current
|
|
3229
|
+
?.getMessages()
|
|
3230
|
+
.find((m) => m.id === approvalMessageId);
|
|
3231
|
+
if (!live?.approval) return;
|
|
3232
|
+
if (live.approval.toolType === "webmcp") {
|
|
3233
|
+
sessionRef.current?.resolveWebMcpApproval(live.id, decision);
|
|
3234
|
+
} else {
|
|
3235
|
+
sessionRef.current?.resolveApproval(live.approval, decision, options);
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
liveBubble = approvalPlugin.renderApproval({
|
|
3239
|
+
message,
|
|
3240
|
+
defaultRenderer: () => createApprovalBubble(message, config),
|
|
3241
|
+
config,
|
|
3242
|
+
approve: (options) => resolveDecision("approved", options),
|
|
3243
|
+
deny: (options) => resolveDecision("denied", options)
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
if (needsRebuild && liveBubble === null) {
|
|
3248
|
+
// Plugin opted out for this state (e.g. a resolved approval, where the
|
|
3249
|
+
// demo plugin defers to the built-in approved/denied bubble). Render
|
|
3250
|
+
// the built-in bubble — it resolves via the delegated `messagesWrapper`
|
|
3251
|
+
// handler and morphs normally — and drop any preserved live wrapper so
|
|
3252
|
+
// morph can replace the now-stale pending bubble.
|
|
3253
|
+
const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
|
|
3254
|
+
existing?.removeAttribute("data-preserve-runtime");
|
|
3255
|
+
lastApprovalBubbleFingerprint.delete(message.id);
|
|
3256
|
+
bubble = createApprovalBubble(message, config);
|
|
3257
|
+
} else {
|
|
3258
|
+
// A fresh live bubble to hydrate (needsRebuild), or fingerprint
|
|
3259
|
+
// unchanged so we reuse the preserved live wrapper (`bubble: null`).
|
|
3260
|
+
const stub = document.createElement("div");
|
|
3261
|
+
stub.className = "persona-flex";
|
|
3262
|
+
stub.id = `wrapper-${message.id}`;
|
|
3263
|
+
stub.setAttribute("data-wrapper-id", message.id);
|
|
3264
|
+
stub.setAttribute("data-approval-plugin-stub", "true");
|
|
3265
|
+
stub.setAttribute("data-preserve-runtime", "true");
|
|
3266
|
+
tempContainer.appendChild(stub);
|
|
3267
|
+
approvalPluginHydrate.push({
|
|
3268
|
+
messageId: message.id,
|
|
3269
|
+
fingerprint,
|
|
3270
|
+
bubble: liveBubble
|
|
3271
|
+
});
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3162
3274
|
} else if (matchingPlugin) {
|
|
3163
3275
|
if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
|
|
3164
3276
|
if (!showReasoning) return;
|
|
@@ -3174,13 +3286,6 @@ export const createAgentExperience = (
|
|
|
3174
3286
|
defaultRenderer: () => createToolBubble(message, config),
|
|
3175
3287
|
config
|
|
3176
3288
|
});
|
|
3177
|
-
} else if (message.variant === "approval" && message.approval && matchingPlugin.renderApproval) {
|
|
3178
|
-
if (config.approval === false) return;
|
|
3179
|
-
bubble = matchingPlugin.renderApproval({
|
|
3180
|
-
message,
|
|
3181
|
-
defaultRenderer: () => createApprovalBubble(message, config),
|
|
3182
|
-
config
|
|
3183
|
-
});
|
|
3184
3289
|
} else if (matchingPlugin.renderMessage) {
|
|
3185
3290
|
bubble = matchingPlugin.renderMessage({
|
|
3186
3291
|
message,
|
|
@@ -3663,6 +3768,30 @@ export const createAgentExperience = (
|
|
|
3663
3768
|
if (!activeMessageIds.has(id)) lastComponentDirectiveFingerprint.delete(id);
|
|
3664
3769
|
}
|
|
3665
3770
|
}
|
|
3771
|
+
|
|
3772
|
+
// Hydrate plugin-rendered approval bubbles into their stub wrappers,
|
|
3773
|
+
// mirroring the ask-question / component-directive hydration above.
|
|
3774
|
+
if (approvalPluginHydrate.length > 0) {
|
|
3775
|
+
for (const { messageId, fingerprint, bubble } of approvalPluginHydrate) {
|
|
3776
|
+
const wrapper = container.querySelector(`#wrapper-${messageId}`);
|
|
3777
|
+
if (!wrapper) continue;
|
|
3778
|
+
if (bubble === null) {
|
|
3779
|
+
// Fingerprint matched the previous pass (or the plugin opted out
|
|
3780
|
+
// after a prior render) — the live wrapper, kept alive by
|
|
3781
|
+
// `data-preserve-runtime`, still holds the listener-bearing bubble.
|
|
3782
|
+
continue;
|
|
3783
|
+
}
|
|
3784
|
+
wrapper.replaceChildren(bubble);
|
|
3785
|
+
wrapper.setAttribute("data-bubble-fp", fingerprint);
|
|
3786
|
+
lastApprovalBubbleFingerprint.set(messageId, fingerprint);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
if (lastApprovalBubbleFingerprint.size > 0) {
|
|
3791
|
+
for (const id of lastApprovalBubbleFingerprint.keys()) {
|
|
3792
|
+
if (!activeMessageIds.has(id)) lastApprovalBubbleFingerprint.delete(id);
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3666
3795
|
};
|
|
3667
3796
|
|
|
3668
3797
|
// Alias for clarity - the implementation handles flicker prevention via typing indicator logic
|
|
@@ -7258,7 +7387,11 @@ export const createAgentExperience = (
|
|
|
7258
7387
|
textarea.focus();
|
|
7259
7388
|
return true;
|
|
7260
7389
|
},
|
|
7261
|
-
async resolveApproval(
|
|
7390
|
+
async resolveApproval(
|
|
7391
|
+
approvalId: string,
|
|
7392
|
+
decision: 'approved' | 'denied',
|
|
7393
|
+
options?: AgentWidgetApprovalDecisionOptions
|
|
7394
|
+
): Promise<void> {
|
|
7262
7395
|
const messages = session.getMessages();
|
|
7263
7396
|
const approvalMessage = messages.find(
|
|
7264
7397
|
m => m.variant === "approval" && m.approval?.id === approvalId
|
|
@@ -7274,7 +7407,7 @@ export const createAgentExperience = (
|
|
|
7274
7407
|
session.resolveWebMcpApproval(approvalMessage.id, decision);
|
|
7275
7408
|
return;
|
|
7276
7409
|
}
|
|
7277
|
-
return session.resolveApproval(approvalMessage.approval, decision);
|
|
7410
|
+
return session.resolveApproval(approvalMessage.approval, decision, options);
|
|
7278
7411
|
},
|
|
7279
7412
|
getMessages() {
|
|
7280
7413
|
return session.getMessages();
|
package/src/utils/theme.test.ts
CHANGED
|
@@ -288,7 +288,7 @@ describe('theme utils', () => {
|
|
|
288
288
|
);
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
it('
|
|
291
|
+
it('drives --persona-tool-bubble-shadow from the theme token (config.toolCall.shadow is applied inline on the bubble, not the root var)', () => {
|
|
292
292
|
const el = document.createElement('div');
|
|
293
293
|
applyThemeVariables(el, {
|
|
294
294
|
colorScheme: 'light',
|
|
@@ -297,8 +297,12 @@ describe('theme utils', () => {
|
|
|
297
297
|
toolBubble: { shadow: '0 1px 2px rgba(255,0,0,0.5)' },
|
|
298
298
|
},
|
|
299
299
|
},
|
|
300
|
+
// config.toolCall.shadow no longer rewrites the root variable — the
|
|
301
|
+
// override is applied inline by createToolBubble (see tool-bubble tests).
|
|
300
302
|
toolCall: { shadow: 'none' },
|
|
301
303
|
});
|
|
302
|
-
expect(el.style.getPropertyValue('--persona-tool-bubble-shadow').trim()).toBe(
|
|
304
|
+
expect(el.style.getPropertyValue('--persona-tool-bubble-shadow').trim()).toBe(
|
|
305
|
+
'0 1px 2px rgba(255,0,0,0.5)'
|
|
306
|
+
);
|
|
303
307
|
});
|
|
304
308
|
});
|
package/src/utils/theme.ts
CHANGED
|
@@ -194,14 +194,6 @@ export const applyThemeVariables = (
|
|
|
194
194
|
for (const [name, value] of Object.entries(cssVars)) {
|
|
195
195
|
element.style.setProperty(name, value);
|
|
196
196
|
}
|
|
197
|
-
|
|
198
|
-
const toolCallShadow = (config as AgentWidgetConfig | undefined)?.toolCall?.shadow;
|
|
199
|
-
if (toolCallShadow !== undefined) {
|
|
200
|
-
element.style.setProperty(
|
|
201
|
-
'--persona-tool-bubble-shadow',
|
|
202
|
-
toolCallShadow.trim() === '' ? 'none' : toolCallShadow
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
197
|
};
|
|
206
198
|
|
|
207
199
|
export const createThemeObserver = (
|
package/src/utils/tokens.ts
CHANGED
|
@@ -333,7 +333,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
|
|
|
333
333
|
shadow: 'palette.shadows.sm',
|
|
334
334
|
},
|
|
335
335
|
composer: {
|
|
336
|
-
shadow: 'none',
|
|
336
|
+
shadow: 'palette.shadows.none',
|
|
337
337
|
},
|
|
338
338
|
markdown: {
|
|
339
339
|
inlineCode: {
|
|
@@ -389,6 +389,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
|
|
|
389
389
|
background: 'palette.colors.warning.50',
|
|
390
390
|
border: 'palette.colors.warning.200',
|
|
391
391
|
text: 'palette.colors.gray.900',
|
|
392
|
+
shadow: '0 5px 15px rgba(15, 23, 42, 0.08)',
|
|
392
393
|
},
|
|
393
394
|
approve: {
|
|
394
395
|
background: 'palette.colors.success.500',
|
|
@@ -683,6 +684,7 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
|
|
|
683
684
|
cssVars['--persona-approval-bg'] = cssVars['--persona-components-approval-requested-background'] ?? cssVars['--persona-palette-colors-warning-50'];
|
|
684
685
|
cssVars['--persona-approval-border'] = cssVars['--persona-components-approval-requested-border'] ?? cssVars['--persona-palette-colors-warning-200'];
|
|
685
686
|
cssVars['--persona-approval-text'] = cssVars['--persona-components-approval-requested-text'] ?? cssVars['--persona-palette-colors-gray-900'];
|
|
687
|
+
cssVars['--persona-approval-shadow'] = cssVars['--persona-components-approval-requested-shadow'] ?? '0 5px 15px rgba(15, 23, 42, 0.08)';
|
|
686
688
|
cssVars['--persona-approval-approve-bg'] = cssVars['--persona-components-approval-approve-background'] ?? cssVars['--persona-palette-colors-success-500'];
|
|
687
689
|
cssVars['--persona-approval-deny-bg'] = cssVars['--persona-components-approval-deny-background'] ?? cssVars['--persona-palette-colors-error-500'];
|
|
688
690
|
|
|
@@ -737,6 +739,9 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
|
|
|
737
739
|
cssVars['--persona-components-panel-shadow'] ??
|
|
738
740
|
cssVars['--persona-palette-shadows-xl'] ??
|
|
739
741
|
'0 25px 50px -12px rgba(0, 0, 0, 0.25)';
|
|
742
|
+
cssVars['--persona-launcher-shadow'] =
|
|
743
|
+
cssVars['--persona-components-launcher-shadow'] ??
|
|
744
|
+
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)';
|
|
740
745
|
cssVars['--persona-input-radius'] =
|
|
741
746
|
cssVars['--persona-components-input-borderRadius'] ??
|
|
742
747
|
cssVars['--persona-radius-lg'] ??
|
|
@@ -27,6 +27,7 @@ vi.mock("@mcp-b/webmcp-polyfill", () => ({
|
|
|
27
27
|
// Import AFTER vi.mock so the bridge's dynamic import resolves to the mock.
|
|
28
28
|
import {
|
|
29
29
|
WebMcpBridge,
|
|
30
|
+
getWebMcpToolDisplayTitle,
|
|
30
31
|
isWebMcpToolName,
|
|
31
32
|
stripWebMcpPrefix,
|
|
32
33
|
computeClientToolsFingerprint,
|
|
@@ -39,6 +40,7 @@ type MockTool = {
|
|
|
39
40
|
name: string;
|
|
40
41
|
description?: string;
|
|
41
42
|
inputSchema?: object;
|
|
43
|
+
title?: string;
|
|
42
44
|
execute: (args: Record<string, unknown>, client: MockClient) => unknown;
|
|
43
45
|
};
|
|
44
46
|
|
|
@@ -47,10 +49,13 @@ const registry: { tools: MockTool[] } = { tools: [] };
|
|
|
47
49
|
/** A fake `document.modelContext` exposing the strict consumer surface. */
|
|
48
50
|
const makeModelContext = () => ({
|
|
49
51
|
async getTools() {
|
|
52
|
+
// Mirrors the real polyfill's getRegisteredToolInfos(): `title` is always
|
|
53
|
+
// present, "" when the tool didn't declare one; annotations are absent.
|
|
50
54
|
return registry.tools.map((t) => ({
|
|
51
55
|
name: t.name,
|
|
52
56
|
description: t.description ?? `mock ${t.name}`,
|
|
53
57
|
inputSchema: JSON.stringify(t.inputSchema ?? { type: "object" }),
|
|
58
|
+
title: t.title ?? "",
|
|
54
59
|
}));
|
|
55
60
|
},
|
|
56
61
|
async executeTool(
|
|
@@ -199,6 +204,67 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
|
|
|
199
204
|
});
|
|
200
205
|
});
|
|
201
206
|
|
|
207
|
+
describe("WebMCP display titles", () => {
|
|
208
|
+
it("records declared titles on snapshot and exposes them via getWebMcpToolDisplayTitle", async () => {
|
|
209
|
+
registry.tools = [
|
|
210
|
+
fakeTool({ name: "add_to_cart", title: "Add to Cart" }),
|
|
211
|
+
fakeTool({ name: "search_products" }),
|
|
212
|
+
];
|
|
213
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
214
|
+
await bridge.snapshotForDispatch();
|
|
215
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBe("Add to Cart");
|
|
216
|
+
expect(getWebMcpToolDisplayTitle("webmcp:add_to_cart")).toBe("Add to Cart");
|
|
217
|
+
expect(getWebMcpToolDisplayTitle("search_products")).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("evicts a stale title when the tool re-registers without one", async () => {
|
|
221
|
+
registry.tools = [fakeTool({ name: "add_to_cart", title: "Add to Cart" })];
|
|
222
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
223
|
+
await bridge.snapshotForDispatch();
|
|
224
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBe("Add to Cart");
|
|
225
|
+
|
|
226
|
+
registry.tools = [fakeTool({ name: "add_to_cart" })];
|
|
227
|
+
await bridge.snapshotForDispatch();
|
|
228
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("evicts the title when the tool is removed from the registry entirely", async () => {
|
|
232
|
+
registry.tools = [
|
|
233
|
+
fakeTool({ name: "add_to_cart", title: "Add to Cart" }),
|
|
234
|
+
fakeTool({ name: "search_products", title: "Search the catalog" }),
|
|
235
|
+
];
|
|
236
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
237
|
+
await bridge.snapshotForDispatch();
|
|
238
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBe("Add to Cart");
|
|
239
|
+
|
|
240
|
+
registry.tools = [fakeTool({ name: "search_products", title: "Search the catalog" })];
|
|
241
|
+
await bridge.snapshotForDispatch();
|
|
242
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBeUndefined();
|
|
243
|
+
expect(getWebMcpToolDisplayTitle("search_products")).toBe("Search the catalog");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("passes the declared title to the confirm gate", async () => {
|
|
247
|
+
registry.tools = [fakeTool({ name: "add_to_cart", title: "Add to Cart" })];
|
|
248
|
+
const onConfirm = vi.fn(async () => true);
|
|
249
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm });
|
|
250
|
+
const r = await bridge.executeToolCall("webmcp:add_to_cart", {});
|
|
251
|
+
expect(r.isError).toBeFalsy();
|
|
252
|
+
expect(onConfirm).toHaveBeenCalledWith(
|
|
253
|
+
expect.objectContaining({ toolName: "add_to_cart", title: "Add to Cart" })
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("omits title from the confirm gate when the tool didn't declare one", async () => {
|
|
258
|
+
registry.tools = [fakeTool({ name: "add_to_cart" })];
|
|
259
|
+
const onConfirm = vi.fn(async () => true);
|
|
260
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm });
|
|
261
|
+
await bridge.executeToolCall("webmcp:add_to_cart", {});
|
|
262
|
+
expect(onConfirm).toHaveBeenCalledWith(
|
|
263
|
+
expect.not.objectContaining({ title: expect.anything() })
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
202
268
|
describe("WebMcpBridge.executeToolCall", () => {
|
|
203
269
|
it("returns isError when WebMCP is not enabled", async () => {
|
|
204
270
|
const bridge = new WebMcpBridge({} as AgentWidgetWebMcpConfig);
|