@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.
Files changed (63) hide show
  1. package/README.md +55 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CxvHw0X6.d.cts → types-B_Qazlm4.d.cts} +6 -0
  5. package/dist/animations/{types-CxvHw0X6.d.ts → types-B_Qazlm4.d.ts} +6 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +6 -6
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +47 -47
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +127 -7
  13. package/dist/index.d.ts +127 -7
  14. package/dist/index.global.js +60 -60
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +47 -47
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js +2 -2
  19. package/dist/launcher.global.js.map +1 -1
  20. package/dist/plugin-kit.cjs +1 -0
  21. package/dist/plugin-kit.d.cts +98 -0
  22. package/dist/plugin-kit.d.ts +98 -0
  23. package/dist/plugin-kit.js +1 -0
  24. package/dist/smart-dom-reader.d.cts +113 -4
  25. package/dist/smart-dom-reader.d.ts +113 -4
  26. package/dist/theme-editor.cjs +38 -38
  27. package/dist/theme-editor.d.cts +117 -6
  28. package/dist/theme-editor.d.ts +117 -6
  29. package/dist/theme-editor.js +38 -38
  30. package/dist/theme-reference.cjs +1 -1
  31. package/dist/theme-reference.d.cts +2 -0
  32. package/dist/theme-reference.d.ts +2 -0
  33. package/dist/theme-reference.js +1 -1
  34. package/package.json +8 -2
  35. package/src/client.test.ts +40 -0
  36. package/src/client.ts +18 -4
  37. package/src/components/approval-bubble.test.ts +268 -0
  38. package/src/components/approval-bubble.ts +170 -20
  39. package/src/components/launcher.ts +6 -3
  40. package/src/components/tool-bubble.test.ts +39 -0
  41. package/src/components/tool-bubble.ts +4 -0
  42. package/src/generated/runtype-openapi-contract.ts +12 -0
  43. package/src/index-core.ts +1 -0
  44. package/src/plugin-kit.test.ts +230 -0
  45. package/src/plugin-kit.ts +294 -0
  46. package/src/plugins/types.ts +49 -2
  47. package/src/session.test.ts +99 -0
  48. package/src/session.ts +204 -32
  49. package/src/session.webmcp.test.ts +326 -6
  50. package/src/theme-editor/preview-utils.test.ts +10 -0
  51. package/src/theme-editor/preview-utils.ts +29 -1
  52. package/src/theme-editor/sections.test.ts +17 -0
  53. package/src/theme-editor/sections.ts +31 -0
  54. package/src/theme-reference.ts +1 -1
  55. package/src/types/theme.ts +2 -0
  56. package/src/types.ts +59 -2
  57. package/src/ui.approval-plugin.test.ts +204 -0
  58. package/src/ui.ts +149 -16
  59. package/src/utils/theme.test.ts +6 -2
  60. package/src/utils/theme.ts +0 -8
  61. package/src/utils/tokens.ts +6 -1
  62. package/src/webmcp-bridge.test.ts +66 -0
  63. 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: (approvalId: string, decision: 'approved' | 'denied') => Promise<void>;
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
- if (message.variant === "approval" && p.renderApproval) {
3047
- return true;
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(approvalId: string, decision: 'approved' | 'denied'): Promise<void> {
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();
@@ -288,7 +288,7 @@ describe('theme utils', () => {
288
288
  );
289
289
  });
290
290
 
291
- it('lets config.toolCall.shadow override theme tool bubble shadow on the root element', () => {
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('none');
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
  });
@@ -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 = (
@@ -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);