@runtypelabs/persona 3.29.1 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +15 -2547
  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-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
  5. package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -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 +50 -50
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +164 -7
  13. package/dist/index.d.ts +164 -7
  14. package/dist/index.global.js +60 -60
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +49 -49
  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 +157 -4
  25. package/dist/smart-dom-reader.d.ts +157 -4
  26. package/dist/theme-editor.cjs +40 -40
  27. package/dist/theme-editor.d.cts +161 -6
  28. package/dist/theme-editor.d.ts +161 -6
  29. package/dist/theme-editor.js +40 -40
  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/dist/widget.css +19 -0
  35. package/package.json +8 -2
  36. package/src/client.ts +6 -0
  37. package/src/components/approval-bubble.test.ts +320 -0
  38. package/src/components/approval-bubble.ts +190 -20
  39. package/src/components/launcher.ts +6 -3
  40. package/src/components/panel.ts +7 -1
  41. package/src/components/tool-bubble.test.ts +39 -0
  42. package/src/components/tool-bubble.ts +4 -0
  43. package/src/defaults.ts +14 -0
  44. package/src/generated/runtype-openapi-contract.ts +4 -0
  45. package/src/index-core.ts +1 -0
  46. package/src/plugin-kit.test.ts +230 -0
  47. package/src/plugin-kit.ts +294 -0
  48. package/src/plugins/types.ts +49 -2
  49. package/src/session.test.ts +161 -0
  50. package/src/session.ts +41 -3
  51. package/src/styles/widget.css +19 -0
  52. package/src/theme-editor/preview-utils.test.ts +10 -0
  53. package/src/theme-editor/preview-utils.ts +29 -1
  54. package/src/theme-editor/sections.test.ts +17 -0
  55. package/src/theme-editor/sections.ts +31 -0
  56. package/src/theme-reference.ts +2 -2
  57. package/src/types/theme.ts +2 -0
  58. package/src/types.ts +109 -2
  59. package/src/ui.approval-plugin.test.ts +204 -0
  60. package/src/ui.scroll.test.ts +383 -0
  61. package/src/ui.ts +539 -56
  62. package/src/utils/auto-follow.test.ts +91 -0
  63. package/src/utils/auto-follow.ts +68 -0
  64. package/src/utils/theme.test.ts +6 -2
  65. package/src/utils/theme.ts +0 -8
  66. package/src/utils/tokens.ts +6 -1
  67. package/src/webmcp-bridge.test.ts +66 -0
  68. package/src/webmcp-bridge.ts +49 -0
@@ -206,7 +206,8 @@ export type PreviewTranscriptEntryPreset =
206
206
  | 'reasoning-streaming'
207
207
  | 'reasoning-complete'
208
208
  | 'tool-running'
209
- | 'tool-complete';
209
+ | 'tool-complete'
210
+ | 'approval-request';
210
211
 
211
212
  const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, string> = {
212
213
  'user-message': 'User message',
@@ -218,6 +219,7 @@ const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, str
218
219
  'reasoning-complete': 'Reasoning (complete)',
219
220
  'tool-running': 'Tool call (running)',
220
221
  'tool-complete': 'Tool call (complete)',
222
+ 'approval-request': 'Approval request',
221
223
  };
222
224
 
223
225
  export function getPreviewTranscriptPresetLabel(preset: PreviewTranscriptEntryPreset): string {
@@ -325,6 +327,32 @@ export function createPreviewTranscriptEntry(
325
327
  durationMs: 1200,
326
328
  },
327
329
  };
330
+ case 'approval-request': {
331
+ // Use the `approval-<approvalId>` id convention so the optimistic status
332
+ // flip in `session.resolveApproval` updates this bubble in place when the
333
+ // Approve/Deny buttons are clicked.
334
+ const approvalId = `preview-approval-${suffix}`;
335
+ return {
336
+ id: `approval-${approvalId}`,
337
+ role: 'assistant',
338
+ content: '',
339
+ createdAt,
340
+ streaming: false,
341
+ variant: 'approval',
342
+ approval: {
343
+ id: approvalId,
344
+ status: 'pending',
345
+ agentId: 'preview-agent',
346
+ executionId: `preview-exec-${suffix}`,
347
+ toolName: 'add_to_cart',
348
+ description:
349
+ 'Add the selected item to the shopping cart. Approve to let the assistant continue.',
350
+ parameters: {
351
+ items: [{ productEntityId: 129, quantity: 1 }],
352
+ },
353
+ },
354
+ };
355
+ }
328
356
  case 'tool-complete':
329
357
  return {
330
358
  id: `preview-seq-tool-complete-${suffix}`,
@@ -27,6 +27,23 @@ describe("theme editor scroll-to-bottom controls", () => {
27
27
  expect(fieldPaths).toContain("theme.components.scrollToBottom.iconSize");
28
28
  });
29
29
 
30
+ it("exposes a shadow control for every themeable component", () => {
31
+ const fieldPaths = COMPONENTS_SECTIONS.flatMap((section) => section.fields.map((field) => field.path));
32
+
33
+ // Pre-existing shadow controls.
34
+ expect(fieldPaths).toContain("theme.components.launcher.shadow");
35
+ expect(fieldPaths).toContain("theme.components.panel.shadow");
36
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.shadow");
37
+ // Newly added component shadow controls.
38
+ expect(fieldPaths).toContain("theme.components.message.user.shadow");
39
+ expect(fieldPaths).toContain("theme.components.message.assistant.shadow");
40
+ expect(fieldPaths).toContain("theme.components.toolBubble.shadow");
41
+ expect(fieldPaths).toContain("theme.components.reasoningBubble.shadow");
42
+ expect(fieldPaths).toContain("theme.components.approval.requested.shadow");
43
+ expect(fieldPaths).toContain("theme.components.introCard.shadow");
44
+ expect(fieldPaths).toContain("theme.components.composer.shadow");
45
+ });
46
+
30
47
  it("adds a scroll-to-bottom interface role mapping", () => {
31
48
  const role = ALL_ROLES.find((entry) => entry.roleId === "role-scroll-to-bottom");
32
49
 
@@ -455,6 +455,36 @@ const scrollToBottomSectionDef: SectionDef = {
455
455
  ],
456
456
  };
457
457
 
458
+ // Reusable shadow-scale options matching the launcher / panel / scroll-to-bottom selects.
459
+ const SHADOW_SCALE_OPTIONS: { value: string; label: string }[] = [
460
+ { value: 'palette.shadows.none', label: 'None' },
461
+ { value: 'palette.shadows.sm', label: 'Small' },
462
+ { value: 'palette.shadows.md', label: 'Medium' },
463
+ { value: 'palette.shadows.lg', label: 'Large' },
464
+ { value: 'palette.shadows.xl', label: 'Extra Large' },
465
+ ];
466
+
467
+ // The approval and intro-card defaults are a bespoke "card" shadow rather than a
468
+ // palette step, so expose it as a leading "Default" option users can keep.
469
+ const CARD_SHADOW = '0 5px 15px rgba(15, 23, 42, 0.08)';
470
+ const CARD_SHADOW_OPTIONS = [{ value: CARD_SHADOW, label: 'Default' }, ...SHADOW_SCALE_OPTIONS];
471
+
472
+ const componentShadowsSectionDef: SectionDef = {
473
+ id: 'comp-shadows',
474
+ title: 'Component Shadows',
475
+ description: 'Box-shadow for chat bubbles and surfaces.',
476
+ collapsed: true,
477
+ fields: [
478
+ { id: 'shadow-msg-user', label: 'User Message', type: 'select', path: 'theme.components.message.user.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
479
+ { id: 'shadow-msg-assistant', label: 'Assistant Message', type: 'select', path: 'theme.components.message.assistant.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
480
+ { id: 'shadow-tool-bubble', label: 'Tool Call Bubble', type: 'select', path: 'theme.components.toolBubble.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
481
+ { id: 'shadow-reasoning-bubble', label: 'Reasoning Bubble', type: 'select', path: 'theme.components.reasoningBubble.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
482
+ { id: 'shadow-approval', label: 'Approval Bubble', type: 'select', path: 'theme.components.approval.requested.shadow', defaultValue: CARD_SHADOW, options: CARD_SHADOW_OPTIONS },
483
+ { id: 'shadow-intro-card', label: 'Intro Card', type: 'select', path: 'theme.components.introCard.shadow', defaultValue: CARD_SHADOW, options: CARD_SHADOW_OPTIONS },
484
+ { id: 'shadow-composer', label: 'Composer', type: 'select', path: 'theme.components.composer.shadow', defaultValue: 'palette.shadows.none', options: SHADOW_SCALE_OPTIONS },
485
+ ],
486
+ };
487
+
458
488
  /** Shared shape sections (not scoped to light/dark) */
459
489
  export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
460
490
  panelLayoutSectionDef,
@@ -462,6 +492,7 @@ export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
462
492
  messageShapeSectionDef,
463
493
  inputShapeSectionDef,
464
494
  buttonShapeSectionDef,
495
+ componentShadowsSectionDef,
465
496
  ];
466
497
 
467
498
  /** Component color sections (can be scoped for light/dark) */
@@ -136,7 +136,7 @@ export const THEME_TOKEN_DOCS = {
136
136
  voice:
137
137
  'recording (indicator, background, border), processing (icon, background), speaking (icon).',
138
138
  approval:
139
- 'requested (background, border, text), approve (background, foreground), deny (background, foreground).',
139
+ 'requested (background, border, text, shadow), approve (background, foreground), deny (background, foreground).',
140
140
  attachment: 'image (background, border).',
141
141
  scrollToBottom:
142
142
  'Floating scroll-to-bottom affordance shared by transcript and event stream: background, foreground, border, size, borderRadius, shadow, padding, gap, fontSize, iconSize.',
@@ -229,7 +229,7 @@ export const THEME_TOKEN_DOCS = {
229
229
  description:
230
230
  'Tool approval bubble styling and behavior. Set to false to disable.',
231
231
  properties:
232
- 'backgroundColor, borderColor, titleColor, descriptionColor, approveButtonColor, approveButtonTextColor, denyButtonColor, denyButtonTextColor, parameterBackgroundColor, parameterTextColor, title, approveLabel, denyLabel.',
232
+ 'backgroundColor, borderColor, titleColor, descriptionColor, reasonColor, reasonLabel, approveButtonColor, approveButtonTextColor, denyButtonColor, denyButtonTextColor, parameterBackgroundColor, parameterTextColor, title, approveLabel, denyLabel.',
233
233
  },
234
234
  copy: {
235
235
  description: 'Widget text content.',
@@ -330,6 +330,8 @@ export interface ApprovalTokens {
330
330
  background: TokenReference<'color'>;
331
331
  border: TokenReference<'color'>;
332
332
  text: TokenReference<'color'>;
333
+ /** Box-shadow for the approval bubble (token ref or raw CSS, e.g. `none`). */
334
+ shadow: string;
333
335
  };
334
336
  approve: ComponentTokenSet;
335
337
  deny: ComponentTokenSet;
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;
@@ -751,6 +757,31 @@ export type AgentWidgetArtifactsFeature = {
751
757
  }) => HTMLElement | null;
752
758
  };
753
759
 
760
+ /**
761
+ * How the transcript scrolls while an assistant response streams in.
762
+ *
763
+ * - `"follow"` (default): keep the newest content pinned to the bottom of the
764
+ * viewport, pausing when the user scrolls up and resuming when they return
765
+ * to the bottom.
766
+ * - `"anchor-top"`: on send, scroll the user's message near the top of the
767
+ * viewport and hold it there while the response streams in beneath it
768
+ * (ChatGPT-style). The transcript never auto-scrolls during streaming.
769
+ * - `"none"`: never auto-scroll; the scroll-to-bottom affordance is the only
770
+ * way back to the latest content.
771
+ */
772
+ export type AgentWidgetScrollMode = "follow" | "anchor-top" | "none";
773
+
774
+ export type AgentWidgetScrollBehaviorFeature = {
775
+ /** Scroll behavior during streamed responses. @default "follow" */
776
+ mode?: AgentWidgetScrollMode;
777
+ /**
778
+ * Gap (px) kept between the anchored user message and the top of the
779
+ * viewport in `"anchor-top"` mode.
780
+ * @default 16
781
+ */
782
+ anchorTopOffset?: number;
783
+ };
784
+
754
785
  export type AgentWidgetScrollToBottomFeature = {
755
786
  /**
756
787
  * When true, Persona shows a scroll-to-bottom affordance when the user breaks
@@ -1043,6 +1074,8 @@ export type AgentWidgetFeatureFlags = {
1043
1074
  composerHistory?: boolean;
1044
1075
  /** Shared transcript + event stream scroll-to-bottom affordance. */
1045
1076
  scrollToBottom?: AgentWidgetScrollToBottomFeature;
1077
+ /** Transcript scroll behavior during streamed responses. */
1078
+ scrollBehavior?: AgentWidgetScrollBehaviorFeature;
1046
1079
  /** Collapsed transcript behavior for tool call rows. */
1047
1080
  toolCallDisplay?: AgentWidgetToolCallDisplayFeature;
1048
1081
  /** Collapsed transcript behavior for reasoning rows. */
@@ -1816,6 +1849,23 @@ export interface VoiceProvider {
1816
1849
  stopPlayback?(): void;
1817
1850
  }
1818
1851
 
1852
+ /**
1853
+ * Extra context for an approval decision, surfaced to `onDecision` and to the
1854
+ * `approve`/`deny` callbacks passed to the `renderApproval` plugin hook.
1855
+ */
1856
+ export type AgentWidgetApprovalDecisionOptions = {
1857
+ /**
1858
+ * The user chose a "remember this" affordance (e.g. an "Always allow"
1859
+ * button) rather than a one-time decision. The widget resolves the *current*
1860
+ * approval identically whether or not this is set — an approval bubble is a
1861
+ * single binary gate (`approved`/`denied`). Persisting a don't-ask-again
1862
+ * policy for *future* approvals (auto-resolving them, or not surfacing them)
1863
+ * is the integrator's responsibility, typically inside `onDecision`.
1864
+ * Defaults to absent/`false`.
1865
+ */
1866
+ remember?: boolean;
1867
+ };
1868
+
1819
1869
  /**
1820
1870
  * Configuration for tool approval bubbles.
1821
1871
  * Controls styling, labels, and behavior of the approval UI.
@@ -1825,6 +1875,8 @@ export type AgentWidgetApprovalConfig = {
1825
1875
  backgroundColor?: string;
1826
1876
  /** Border color of the approval bubble */
1827
1877
  borderColor?: string;
1878
+ /** Box-shadow for the approval bubble; pass `"none"` to remove it. Overrides the default `persona-shadow-sm`. */
1879
+ shadow?: string;
1828
1880
  /** Color for the title text */
1829
1881
  titleColor?: string;
1830
1882
  /** Color for the description text */
@@ -1847,19 +1899,67 @@ export type AgentWidgetApprovalConfig = {
1847
1899
  approveLabel?: string;
1848
1900
  /** Label for the deny button */
1849
1901
  denyLabel?: string;
1902
+ /**
1903
+ * Color for the agent-authored reason line (the agent's per-call
1904
+ * justification, shown attributed below the summary when present).
1905
+ */
1906
+ reasonColor?: string;
1907
+ /**
1908
+ * Label prefix for the agent-authored reason line.
1909
+ * Defaults to "Agent's stated reason:".
1910
+ */
1911
+ reasonLabel?: string;
1912
+ /**
1913
+ * How the technical details (the tool's agent-facing description and the
1914
+ * raw parameters JSON) are presented:
1915
+ * - `"collapsed"` (default) — hidden behind a "Show details" toggle
1916
+ * - `"expanded"` — visible, with the toggle available to hide them
1917
+ * - `"hidden"` — never rendered
1918
+ */
1919
+ detailsDisplay?: "collapsed" | "expanded" | "hidden";
1920
+ /** Label for the toggle that reveals the technical details */
1921
+ showDetailsLabel?: string;
1922
+ /** Label for the toggle that hides the technical details */
1923
+ hideDetailsLabel?: string;
1924
+ /**
1925
+ * Build the user-facing summary line for an approval request. Overrides the
1926
+ * default "The assistant wants to use “Tool name”." copy. Return a falsy
1927
+ * value to fall back to the default for that approval. `displayTitle` is
1928
+ * the display name the tool declared via the WebMCP spec's
1929
+ * `ToolDescriptor.title`, when one exists.
1930
+ */
1931
+ formatDescription?: (approval: {
1932
+ toolName: string;
1933
+ toolType?: string;
1934
+ description: string;
1935
+ parameters?: unknown;
1936
+ displayTitle?: string;
1937
+ /**
1938
+ * Agent-authored justification for this specific call, when the agent
1939
+ * provided one. It is the agent's own claim — if you fold it into the
1940
+ * summary, keep it attributed to the agent.
1941
+ */
1942
+ reason?: string;
1943
+ }) => string | undefined;
1850
1944
  /**
1851
1945
  * Custom handler for approval decisions.
1852
1946
  * Return void to let the SDK auto-resolve via the API,
1853
1947
  * or return a Response/ReadableStream for custom handling.
1948
+ *
1949
+ * `options.remember` is `true` when the decision came from a "remember this"
1950
+ * affordance (e.g. an "Always allow" button in a custom `renderApproval`
1951
+ * plugin). Use it to persist a don't-ask-again policy for future approvals;
1952
+ * the current approval resolves the same way regardless.
1854
1953
  */
1855
1954
  onDecision?: (
1856
1955
  data: { approvalId: string; executionId: string; agentId: string; toolName: string },
1857
- decision: 'approved' | 'denied'
1956
+ decision: 'approved' | 'denied',
1957
+ options?: AgentWidgetApprovalDecisionOptions
1858
1958
  ) => Promise<Response | ReadableStream<Uint8Array> | void>;
1859
1959
  };
1860
1960
 
1861
1961
  export type AgentWidgetToolCallConfig = {
1862
- /** Box-shadow for tool-call bubbles; overrides `theme.toolBubbleShadow` when set. */
1962
+ /** Box-shadow for tool-call bubbles; pass `"none"` to remove it. Overrides the `components.toolBubble.shadow` token / `--persona-tool-bubble-shadow`. */
1863
1963
  shadow?: string;
1864
1964
  /** Background color of the tool call bubble container. */
1865
1965
  backgroundColor?: string;
@@ -3799,6 +3899,13 @@ export type AgentWidgetApproval = {
3799
3899
  toolName: string;
3800
3900
  toolType?: string;
3801
3901
  description: string;
3902
+ /**
3903
+ * Agent-authored justification for this specific call (the agent's own
3904
+ * claim about its intent, extracted server-side from the reserved
3905
+ * `_approvalReason` parameter). Render it attributed to the agent and as
3906
+ * plain text — it is approver context, not a system statement.
3907
+ */
3908
+ reason?: string;
3802
3909
  parameters?: unknown;
3803
3910
  resolvedAt?: number;
3804
3911
  };
@@ -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
+ });