@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.
Files changed (53) hide show
  1. package/README.md +55 -0
  2. package/dist/codegen.cjs +6 -6
  3. package/dist/codegen.js +4 -4
  4. package/dist/index.cjs +47 -47
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +112 -7
  7. package/dist/index.d.ts +112 -7
  8. package/dist/index.global.js +59 -59
  9. package/dist/index.global.js.map +1 -1
  10. package/dist/index.js +47 -47
  11. package/dist/index.js.map +1 -1
  12. package/dist/launcher.global.js +2 -2
  13. package/dist/launcher.global.js.map +1 -1
  14. package/dist/plugin-kit.cjs +1 -0
  15. package/dist/plugin-kit.d.cts +98 -0
  16. package/dist/plugin-kit.d.ts +98 -0
  17. package/dist/plugin-kit.js +1 -0
  18. package/dist/smart-dom-reader.d.cts +107 -4
  19. package/dist/smart-dom-reader.d.ts +107 -4
  20. package/dist/theme-editor.cjs +38 -38
  21. package/dist/theme-editor.d.cts +111 -6
  22. package/dist/theme-editor.d.ts +111 -6
  23. package/dist/theme-editor.js +38 -38
  24. package/dist/theme-reference.cjs +1 -1
  25. package/dist/theme-reference.d.cts +2 -0
  26. package/dist/theme-reference.d.ts +2 -0
  27. package/dist/theme-reference.js +1 -1
  28. package/package.json +8 -2
  29. package/src/components/approval-bubble.test.ts +268 -0
  30. package/src/components/approval-bubble.ts +170 -20
  31. package/src/components/launcher.ts +6 -3
  32. package/src/components/tool-bubble.test.ts +39 -0
  33. package/src/components/tool-bubble.ts +4 -0
  34. package/src/index-core.ts +1 -0
  35. package/src/plugin-kit.test.ts +230 -0
  36. package/src/plugin-kit.ts +294 -0
  37. package/src/plugins/types.ts +49 -2
  38. package/src/session.test.ts +99 -0
  39. package/src/session.ts +14 -3
  40. package/src/theme-editor/preview-utils.test.ts +10 -0
  41. package/src/theme-editor/preview-utils.ts +29 -1
  42. package/src/theme-editor/sections.test.ts +17 -0
  43. package/src/theme-editor/sections.ts +31 -0
  44. package/src/theme-reference.ts +1 -1
  45. package/src/types/theme.ts +2 -0
  46. package/src/types.ts +59 -2
  47. package/src/ui.approval-plugin.test.ts +204 -0
  48. package/src/ui.ts +149 -16
  49. package/src/utils/theme.test.ts +6 -2
  50. package/src/utils/theme.ts +0 -8
  51. package/src/utils/tokens.ts +6 -1
  52. package/src/webmcp-bridge.test.ts +66 -0
  53. package/src/webmcp-bridge.ts +49 -0
package/src/types.ts CHANGED
@@ -255,6 +255,12 @@ export type WebMcpConfirmInfo = {
255
255
  toolName: string;
256
256
  args: unknown;
257
257
  description?: string;
258
+ /**
259
+ * Display title the tool declared via the WebMCP spec's
260
+ * `ToolDescriptor.title` (e.g. `"Add to Cart"`). Absent when the tool
261
+ * didn't declare one.
262
+ */
263
+ title?: string;
258
264
  annotations?: {
259
265
  readOnlyHint?: boolean;
260
266
  untrustedContentHint?: boolean;
@@ -1816,6 +1822,23 @@ export interface VoiceProvider {
1816
1822
  stopPlayback?(): void;
1817
1823
  }
1818
1824
 
1825
+ /**
1826
+ * Extra context for an approval decision, surfaced to `onDecision` and to the
1827
+ * `approve`/`deny` callbacks passed to the `renderApproval` plugin hook.
1828
+ */
1829
+ export type AgentWidgetApprovalDecisionOptions = {
1830
+ /**
1831
+ * The user chose a "remember this" affordance (e.g. an "Always allow"
1832
+ * button) rather than a one-time decision. The widget resolves the *current*
1833
+ * approval identically whether or not this is set — an approval bubble is a
1834
+ * single binary gate (`approved`/`denied`). Persisting a don't-ask-again
1835
+ * policy for *future* approvals (auto-resolving them, or not surfacing them)
1836
+ * is the integrator's responsibility, typically inside `onDecision`.
1837
+ * Defaults to absent/`false`.
1838
+ */
1839
+ remember?: boolean;
1840
+ };
1841
+
1819
1842
  /**
1820
1843
  * Configuration for tool approval bubbles.
1821
1844
  * Controls styling, labels, and behavior of the approval UI.
@@ -1825,6 +1848,8 @@ export type AgentWidgetApprovalConfig = {
1825
1848
  backgroundColor?: string;
1826
1849
  /** Border color of the approval bubble */
1827
1850
  borderColor?: string;
1851
+ /** Box-shadow for the approval bubble; pass `"none"` to remove it. Overrides the default `persona-shadow-sm`. */
1852
+ shadow?: string;
1828
1853
  /** Color for the title text */
1829
1854
  titleColor?: string;
1830
1855
  /** Color for the description text */
@@ -1847,19 +1872,51 @@ export type AgentWidgetApprovalConfig = {
1847
1872
  approveLabel?: string;
1848
1873
  /** Label for the deny button */
1849
1874
  denyLabel?: string;
1875
+ /**
1876
+ * How the technical details (the tool's agent-facing description and the
1877
+ * raw parameters JSON) are presented:
1878
+ * - `"collapsed"` (default) — hidden behind a "Show details" toggle
1879
+ * - `"expanded"` — visible, with the toggle available to hide them
1880
+ * - `"hidden"` — never rendered
1881
+ */
1882
+ detailsDisplay?: "collapsed" | "expanded" | "hidden";
1883
+ /** Label for the toggle that reveals the technical details */
1884
+ showDetailsLabel?: string;
1885
+ /** Label for the toggle that hides the technical details */
1886
+ hideDetailsLabel?: string;
1887
+ /**
1888
+ * Build the user-facing summary line for an approval request. Overrides the
1889
+ * default "The assistant wants to use “Tool name”." copy. Return a falsy
1890
+ * value to fall back to the default for that approval. `displayTitle` is
1891
+ * the display name the tool declared via the WebMCP spec's
1892
+ * `ToolDescriptor.title`, when one exists.
1893
+ */
1894
+ formatDescription?: (approval: {
1895
+ toolName: string;
1896
+ toolType?: string;
1897
+ description: string;
1898
+ parameters?: unknown;
1899
+ displayTitle?: string;
1900
+ }) => string | undefined;
1850
1901
  /**
1851
1902
  * Custom handler for approval decisions.
1852
1903
  * Return void to let the SDK auto-resolve via the API,
1853
1904
  * or return a Response/ReadableStream for custom handling.
1905
+ *
1906
+ * `options.remember` is `true` when the decision came from a "remember this"
1907
+ * affordance (e.g. an "Always allow" button in a custom `renderApproval`
1908
+ * plugin). Use it to persist a don't-ask-again policy for future approvals;
1909
+ * the current approval resolves the same way regardless.
1854
1910
  */
1855
1911
  onDecision?: (
1856
1912
  data: { approvalId: string; executionId: string; agentId: string; toolName: string },
1857
- decision: 'approved' | 'denied'
1913
+ decision: 'approved' | 'denied',
1914
+ options?: AgentWidgetApprovalDecisionOptions
1858
1915
  ) => Promise<Response | ReadableStream<Uint8Array> | void>;
1859
1916
  };
1860
1917
 
1861
1918
  export type AgentWidgetToolCallConfig = {
1862
- /** Box-shadow for tool-call bubbles; overrides `theme.toolBubbleShadow` when set. */
1919
+ /** Box-shadow for tool-call bubbles; pass `"none"` to remove it. Overrides the `components.toolBubble.shadow` token / `--persona-tool-bubble-shadow`. */
1863
1920
  shadow?: string;
1864
1921
  /** Background color of the tool call bubble container. */
1865
1922
  backgroundColor?: string;
@@ -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'] ??