@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,294 @@
1
+ // Plugin Kit — small, dependency-free utilities for authoring Persona plugins.
2
+ //
3
+ // Plugin render hooks (`renderApproval`, `renderAskUserQuestion`, `renderMessage`,
4
+ // …) return a detached `HTMLElement` that the widget morphs into the transcript.
5
+ // Two needs come up again and again when that element is more than static markup:
6
+ //
7
+ // 1. Injecting the plugin's own CSS so it survives the widget's Shadow-DOM mode
8
+ // (a plain `document.head` <style> does NOT pierce a shadow root).
9
+ // 2. Floating UI — dropdowns, menus, tooltips — that must overlay the rest of
10
+ // the widget and not be clipped by the transcript's scroll container.
11
+ //
12
+ // Both are easy to get subtly wrong, so they live here as a supported, optional
13
+ // subpath: `@runtypelabs/persona/plugin-kit`. Importing it costs nothing unless
14
+ // you use it, and nothing here touches the widget's core bundle.
15
+ //
16
+ // ```ts
17
+ // import { injectStyles, createPopover, isEditableEventTarget } from
18
+ // "@runtypelabs/persona/plugin-kit";
19
+ // ```
20
+
21
+ /* ============================================================
22
+ Shadow-safe style injection
23
+ ============================================================ */
24
+
25
+ /**
26
+ * Resolve the root a node's styles should live in.
27
+ *
28
+ * Returns the node's `ShadowRoot` when it (or an ancestor) is shadowed, the
29
+ * owning `Document` otherwise. A `<style>` placed in this root reaches the node
30
+ * regardless of whether the widget runs in light or shadow DOM. For a detached
31
+ * node (one not yet mounted) this falls back to the owning document.
32
+ */
33
+ export function getStyleRoot(node: Node): Document | ShadowRoot {
34
+ const root = node.getRootNode?.();
35
+ if (root instanceof ShadowRoot) return root;
36
+ if (root instanceof Document) return root;
37
+ return node.ownerDocument ?? document;
38
+ }
39
+
40
+ function injectInto(root: Document | ShadowRoot, id: string, css: string): void {
41
+ const container: Document | ShadowRoot | HTMLHeadElement =
42
+ root instanceof Document ? root.head : root;
43
+ const escaped = id.replace(/["\\]/g, "\\$&");
44
+ if (container.querySelector(`style[data-persona-plugin-style="${escaped}"]`)) {
45
+ return; // Already present in this root — idempotent.
46
+ }
47
+ const doc = root instanceof Document ? root : root.ownerDocument ?? document;
48
+ const style = doc.createElement("style");
49
+ style.setAttribute("data-persona-plugin-style", id);
50
+ style.textContent = css;
51
+ container.appendChild(style);
52
+ }
53
+
54
+ /**
55
+ * Inject a plugin's CSS once into the correct root — the widget's shadow root
56
+ * when shadowed, the document head otherwise. Idempotent: keyed by `id`, so it
57
+ * is safe to call on every render.
58
+ *
59
+ * Pass the element you're about to return from a render hook. While building, an
60
+ * element is detached and its eventual root is unknown, so this injects into the
61
+ * owning document immediately (covering the default light-DOM case with no
62
+ * flash) and then, on the next microtask — after the widget has mounted the
63
+ * element — re-resolves and also injects into its shadow root if it landed in
64
+ * one. You may also pass an explicit `Document` or `ShadowRoot`.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * renderApproval: ({ message, approve, deny }) => {
69
+ * const card = buildCard(message.approval, approve, deny);
70
+ * injectStyles(card, "my-approval-plugin", CSS);
71
+ * return card;
72
+ * }
73
+ * ```
74
+ */
75
+ export function injectStyles(
76
+ target: Node | Document | ShadowRoot,
77
+ id: string,
78
+ css: string
79
+ ): void {
80
+ if (target instanceof Document || target instanceof ShadowRoot) {
81
+ injectInto(target, id, css);
82
+ return;
83
+ }
84
+ const node = target;
85
+ if (node.isConnected) {
86
+ injectInto(getStyleRoot(node), id, css);
87
+ return;
88
+ }
89
+ // Detached (built but not yet mounted): inject into the owning document now,
90
+ // then re-resolve after mount so shadow-DOM widgets also get the <style>.
91
+ const doc = node.ownerDocument ?? document;
92
+ injectInto(doc, id, css);
93
+ queueMicrotask(() => {
94
+ const root = getStyleRoot(node);
95
+ if (root !== doc) injectInto(root, id, css);
96
+ });
97
+ }
98
+
99
+ /* ============================================================
100
+ Floating popover
101
+ ============================================================ */
102
+
103
+ export type PopoverPlacement =
104
+ | "bottom-start"
105
+ | "bottom-end"
106
+ | "top-start"
107
+ | "top-end";
108
+
109
+ export interface PopoverOptions {
110
+ /** Element the popover is positioned against. */
111
+ anchor: HTMLElement;
112
+ /** The floating element (menu, tooltip, panel). Built detached; mounted on `open`. */
113
+ content: HTMLElement;
114
+ /** Where to place `content` relative to `anchor`. Default `"bottom-start"`. */
115
+ placement?: PopoverPlacement;
116
+ /** Gap in px between anchor and content. Default `6`. */
117
+ offset?: number;
118
+ /** Set `content`'s `min-width` to the anchor's width. Default `false`. */
119
+ matchAnchorWidth?: boolean;
120
+ /**
121
+ * Inline `z-index` for `content`. Default `2147483000` so it overlays the rest
122
+ * of the widget. Pass `null` to leave z-index to your own CSS.
123
+ */
124
+ zIndex?: number | null;
125
+ /**
126
+ * Where to mount `content`. Defaults to the anchor's shadow root (when
127
+ * shadowed) or `document.body` — keeping it inside the same style + stacking
128
+ * scope as the anchor while escaping the transcript's scroll clipping.
129
+ */
130
+ container?: HTMLElement | ShadowRoot;
131
+ onOpen?: () => void;
132
+ /** Fired when the popover closes by itself (outside click or the anchor leaving the DOM). */
133
+ onDismiss?: (reason: "outside" | "anchor-removed") => void;
134
+ }
135
+
136
+ export interface PopoverHandle {
137
+ readonly isOpen: boolean;
138
+ open(): void;
139
+ close(): void;
140
+ toggle(): void;
141
+ /** Recompute position from the current anchor rect (called automatically on scroll/resize). */
142
+ reposition(): void;
143
+ /** Close and release all listeners; the handle is inert afterward. */
144
+ destroy(): void;
145
+ }
146
+
147
+ function defaultContainer(anchor: HTMLElement): HTMLElement | ShadowRoot {
148
+ const root = anchor.getRootNode?.();
149
+ if (root instanceof ShadowRoot) return root;
150
+ return (anchor.ownerDocument ?? document).body;
151
+ }
152
+
153
+ /**
154
+ * A floating popover anchored to an element: `fixed`-positioned (so it overlays
155
+ * the rest of the widget and isn't clipped by scroll containers), dismissed on
156
+ * outside pointerdown, repositioned on scroll/resize, and auto-closed if the
157
+ * anchor leaves the DOM. Mount your styles with {@link injectStyles}.
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * const popover = createPopover({
162
+ * anchor: splitButton,
163
+ * content: menu,
164
+ * placement: "bottom-start",
165
+ * matchAnchorWidth: true,
166
+ * });
167
+ * caret.addEventListener("click", () => popover.toggle());
168
+ * // on teardown: popover.destroy();
169
+ * ```
170
+ */
171
+ export function createPopover(options: PopoverOptions): PopoverHandle {
172
+ const {
173
+ anchor,
174
+ content,
175
+ placement = "bottom-start",
176
+ offset = 6,
177
+ matchAnchorWidth = false,
178
+ zIndex = 2147483000,
179
+ onOpen,
180
+ onDismiss,
181
+ } = options;
182
+
183
+ const container = options.container ?? defaultContainer(anchor);
184
+ let open = false;
185
+ let detach: (() => void) | null = null;
186
+
187
+ const reposition = (): void => {
188
+ if (!open) return;
189
+ const rect = anchor.getBoundingClientRect();
190
+ content.style.position = "fixed";
191
+ if (matchAnchorWidth) content.style.minWidth = `${rect.width}px`;
192
+
193
+ const top =
194
+ placement === "top-start" || placement === "top-end"
195
+ ? rect.top - offset - content.getBoundingClientRect().height
196
+ : rect.bottom + offset;
197
+
198
+ const left =
199
+ placement === "bottom-end" || placement === "top-end"
200
+ ? rect.right - content.getBoundingClientRect().width
201
+ : rect.left;
202
+
203
+ content.style.top = `${top}px`;
204
+ content.style.left = `${left}px`;
205
+ };
206
+
207
+ const close = (): void => {
208
+ if (!open) return;
209
+ open = false;
210
+ if (detach) {
211
+ detach();
212
+ detach = null;
213
+ }
214
+ content.remove();
215
+ };
216
+
217
+ const doOpen = (): void => {
218
+ if (open) return;
219
+ open = true;
220
+ if (zIndex != null) content.style.zIndex = String(zIndex);
221
+ container.appendChild(content);
222
+ reposition();
223
+
224
+ const ownerWindow = (anchor.ownerDocument ?? document).defaultView ?? window;
225
+ const ownerDocument = anchor.ownerDocument ?? document;
226
+
227
+ const onReposition = (): void => {
228
+ if (!anchor.isConnected) {
229
+ close();
230
+ onDismiss?.("anchor-removed");
231
+ return;
232
+ }
233
+ reposition();
234
+ };
235
+ const onOutside = (event: Event): void => {
236
+ const path =
237
+ typeof event.composedPath === "function" ? event.composedPath() : [];
238
+ if (path.includes(content) || path.includes(anchor)) return;
239
+ close();
240
+ onDismiss?.("outside");
241
+ };
242
+
243
+ // Defer arming so the click that opened the popover doesn't dismiss it.
244
+ const armId = ownerWindow.setTimeout(() => {
245
+ ownerDocument.addEventListener("pointerdown", onOutside, true);
246
+ }, 0);
247
+ ownerWindow.addEventListener("scroll", onReposition, true);
248
+ ownerWindow.addEventListener("resize", onReposition);
249
+
250
+ detach = () => {
251
+ ownerWindow.clearTimeout(armId);
252
+ ownerDocument.removeEventListener("pointerdown", onOutside, true);
253
+ ownerWindow.removeEventListener("scroll", onReposition, true);
254
+ ownerWindow.removeEventListener("resize", onReposition);
255
+ };
256
+
257
+ onOpen?.();
258
+ };
259
+
260
+ return {
261
+ get isOpen() {
262
+ return open;
263
+ },
264
+ open: doOpen,
265
+ close,
266
+ toggle: () => (open ? close() : doOpen()),
267
+ reposition,
268
+ destroy: close,
269
+ };
270
+ }
271
+
272
+ /* ============================================================
273
+ Keyboard helpers
274
+ ============================================================ */
275
+
276
+ /**
277
+ * Whether an event originated from an editable element (`<input>`, `<textarea>`,
278
+ * or `contenteditable`). Use it to avoid hijacking keys like Enter/Escape while
279
+ * the user is typing in the composer.
280
+ *
281
+ * Inspects the composed path, so it works for events that cross the widget's
282
+ * Shadow-DOM boundary (where `event.target` is retargeted to the host).
283
+ */
284
+ export function isEditableEventTarget(event: Event): boolean {
285
+ const path =
286
+ typeof event.composedPath === "function" ? event.composedPath() : [];
287
+ return path.some(
288
+ (el) =>
289
+ el instanceof HTMLElement &&
290
+ (el.tagName === "INPUT" ||
291
+ el.tagName === "TEXTAREA" ||
292
+ el.isContentEditable)
293
+ );
294
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AgentWidgetMessage,
3
3
  AgentWidgetConfig,
4
+ AgentWidgetApprovalDecisionOptions,
4
5
  AskUserQuestionPayload,
5
6
  LoadingIndicatorRenderContext,
6
7
  IdleIndicatorRenderContext,
@@ -165,13 +166,59 @@ export interface AgentWidgetPlugin {
165
166
  }) => HTMLElement | null;
166
167
 
167
168
  /**
168
- * Custom renderer for approval bubbles
169
- * Return null to use default renderer
169
+ * Custom renderer for approval bubbles.
170
+ *
171
+ * Return an `HTMLElement` to fully own the approval UI, `defaultRenderer()`
172
+ * to render (or wrap) the built-in bubble, or `null` to fall through to the
173
+ * default. Unlike the built-in bubble — whose Approve/Deny buttons are wired
174
+ * via delegation — a fully custom element resolves the approval by calling
175
+ * the `approve`/`deny` callbacks. Both route through the same path the
176
+ * built-in buttons use (optimistic update, `onDecision`, in-place anchoring).
177
+ *
178
+ * An approval is a single binary gate, so there are exactly two outcomes.
179
+ * Pass `{ remember: true }` to flag a "remember this" affordance (e.g. an
180
+ * "Always allow" button); the current approval resolves identically, but the
181
+ * flag is forwarded to `config.approval.onDecision` so you can persist a
182
+ * don't-ask-again policy for future approvals.
183
+ *
184
+ * `renderApproval` is called again whenever the approval's status changes, so
185
+ * branch on `message.approval?.status` to render the resolved state (and tear
186
+ * down any global listeners you added while pending).
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * // An alternative prompt: "Always allow" / "Allow once" / "Deny".
191
+ * renderApproval: ({ message, approve, deny }) => {
192
+ * const approval = message.approval;
193
+ * if (!approval || approval.status !== "pending") return null; // default renders resolved state
194
+ * const root = document.createElement("div");
195
+ * root.textContent = `${approval.toolName} requires approval`;
196
+ *
197
+ * const always = document.createElement("button");
198
+ * always.textContent = "Always allow";
199
+ * always.addEventListener("click", () => approve({ remember: true }));
200
+ *
201
+ * const once = document.createElement("button");
202
+ * once.textContent = "Allow once";
203
+ * once.addEventListener("click", () => approve());
204
+ *
205
+ * const no = document.createElement("button");
206
+ * no.textContent = "Deny";
207
+ * no.addEventListener("click", () => deny());
208
+ *
209
+ * root.append(always, once, no);
210
+ * return root;
211
+ * }
212
+ * ```
170
213
  */
171
214
  renderApproval?: (context: {
172
215
  message: AgentWidgetMessage;
173
216
  defaultRenderer: () => HTMLElement;
174
217
  config: AgentWidgetConfig;
218
+ /** Resolve this approval as approved. Pass `{ remember: true }` for an "Always allow" affordance. */
219
+ approve: (options?: AgentWidgetApprovalDecisionOptions) => void;
220
+ /** Resolve this approval as denied. Pass `{ remember: true }` for an "Always deny" affordance. */
221
+ deny: (options?: AgentWidgetApprovalDecisionOptions) => void;
175
222
  }) => HTMLElement | null;
176
223
 
177
224
  /**
@@ -793,6 +793,105 @@ describe('AgentWidgetSession.resolveApproval', () => {
793
793
  expect(session.isStreaming()).toBe(false);
794
794
  expect(errors.length).toBe(1);
795
795
  });
796
+
797
+ it('keeps a resolved approval anchored in its original transcript position', async () => {
798
+ const earlier = new Date('2020-01-01T00:00:00.000Z').toISOString();
799
+ const later = new Date('2020-01-01T00:05:00.000Z').toISOString();
800
+ const approval = makeApproval();
801
+ // The bubble id follows the `approval-<approval.id>` convention used by
802
+ // resolveApproval's optimistic upsert.
803
+ const messageId = `approval-${approval.id}`;
804
+
805
+ const session = new AgentWidgetSession(
806
+ {
807
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
808
+ // Resolve locally (return void) so no network round-trip is needed.
809
+ approval: { onDecision: async () => {} },
810
+ initialMessages: [
811
+ {
812
+ id: messageId,
813
+ role: 'assistant',
814
+ content: '',
815
+ createdAt: earlier,
816
+ variant: 'approval',
817
+ approval,
818
+ },
819
+ {
820
+ id: 'after-1',
821
+ role: 'assistant',
822
+ content: 'A message created after the approval was requested.',
823
+ createdAt: later,
824
+ },
825
+ ],
826
+ },
827
+ {
828
+ onMessagesChanged: () => {},
829
+ onStatusChanged: () => {},
830
+ onStreamingChanged: () => {},
831
+ }
832
+ );
833
+
834
+ await session.resolveApproval(approval, 'approved');
835
+
836
+ const messages = session.getMessages();
837
+ const approvalMsg = messages.find((m) => m.id === messageId);
838
+ const approvalIdx = messages.findIndex((m) => m.id === messageId);
839
+ const afterIdx = messages.findIndex((m) => m.id === 'after-1');
840
+
841
+ expect(approvalMsg?.approval?.status).toBe('approved');
842
+ // createdAt is preserved (not re-stamped to "now"), so the resolved bubble
843
+ // stays before the message that was created after it.
844
+ expect(approvalMsg?.createdAt).toBe(earlier);
845
+ expect(approvalIdx).toBeGreaterThanOrEqual(0);
846
+ expect(approvalIdx).toBeLessThan(afterIdx);
847
+ });
848
+
849
+ it('forwards the decision options (e.g. remember) to onDecision', async () => {
850
+ const onDecision = vi.fn(async () => {});
851
+ const session = new AgentWidgetSession(
852
+ {
853
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
854
+ approval: { onDecision },
855
+ },
856
+ {
857
+ onMessagesChanged: () => {},
858
+ onStatusChanged: () => {},
859
+ onStreamingChanged: () => {},
860
+ }
861
+ );
862
+
863
+ await session.resolveApproval(makeApproval(), 'approved', { remember: true });
864
+
865
+ expect(onDecision).toHaveBeenCalledTimes(1);
866
+ expect(onDecision).toHaveBeenCalledWith(
867
+ expect.objectContaining({ toolName: 'send_email', approvalId: 'approval-1' }),
868
+ 'approved',
869
+ { remember: true }
870
+ );
871
+ });
872
+
873
+ it('passes undefined options to onDecision when none are given', async () => {
874
+ const onDecision = vi.fn(async () => {});
875
+ const session = new AgentWidgetSession(
876
+ {
877
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
878
+ approval: { onDecision },
879
+ },
880
+ {
881
+ onMessagesChanged: () => {},
882
+ onStatusChanged: () => {},
883
+ onStreamingChanged: () => {},
884
+ }
885
+ );
886
+
887
+ await session.resolveApproval(makeApproval(), 'denied');
888
+
889
+ expect(onDecision).toHaveBeenCalledWith(
890
+ expect.objectContaining({ toolName: 'send_email' }),
891
+ 'denied',
892
+ undefined
893
+ );
894
+ });
796
895
  });
797
896
 
798
897
  describe('AgentWidgetSession - dispatch error fallback', () => {