@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
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
+ computeAnchorScrollState,
5
+ computeShrunkSpacerHeight,
4
6
  createFollowStateController,
5
7
  getScrollBottomOffset,
8
+ hasSelectionWithin,
6
9
  isElementNearBottom,
7
10
  resolveFollowStateFromScroll,
8
11
  resolveFollowStateFromWheel
@@ -90,6 +93,94 @@ describe("auto-follow utilities", () => {
90
93
  expect(resume.action).toBe("resume");
91
94
  });
92
95
 
96
+ it("detects an active selection touching the container", () => {
97
+ const inside = { name: "inside" } as unknown as Node;
98
+ const outside = { name: "outside" } as unknown as Node;
99
+ const container = {
100
+ contains: (node: Node | null) => node === inside
101
+ };
102
+
103
+ expect(hasSelectionWithin(null, container)).toBe(false);
104
+ expect(
105
+ hasSelectionWithin(
106
+ { isCollapsed: true, anchorNode: inside, focusNode: inside },
107
+ container
108
+ )
109
+ ).toBe(false);
110
+ expect(
111
+ hasSelectionWithin(
112
+ { isCollapsed: false, anchorNode: outside, focusNode: outside },
113
+ container
114
+ )
115
+ ).toBe(false);
116
+ expect(
117
+ hasSelectionWithin(
118
+ { isCollapsed: false, anchorNode: inside, focusNode: outside },
119
+ container
120
+ )
121
+ ).toBe(true);
122
+ expect(
123
+ hasSelectionWithin(
124
+ { isCollapsed: false, anchorNode: outside, focusNode: inside },
125
+ container
126
+ )
127
+ ).toBe(true);
128
+ });
129
+
130
+ it("computes anchor-top scroll geometry", () => {
131
+ // Anchored message deep in the transcript: spacer fills the shortfall so
132
+ // the target position is reachable.
133
+ expect(
134
+ computeAnchorScrollState({
135
+ anchorOffsetTop: 700,
136
+ topOffset: 16,
137
+ viewportHeight: 400,
138
+ contentHeight: 1000
139
+ })
140
+ ).toEqual({ targetScrollTop: 684, spacerHeight: 84 });
141
+
142
+ // Message near the top of the transcript: target clamps to 0.
143
+ expect(
144
+ computeAnchorScrollState({
145
+ anchorOffsetTop: 10,
146
+ topOffset: 16,
147
+ viewportHeight: 400,
148
+ contentHeight: 1000
149
+ }).targetScrollTop
150
+ ).toBe(0);
151
+
152
+ // Already enough content below: no spacer needed.
153
+ expect(
154
+ computeAnchorScrollState({
155
+ anchorOffsetTop: 500,
156
+ topOffset: 16,
157
+ viewportHeight: 400,
158
+ contentHeight: 2000
159
+ }).spacerHeight
160
+ ).toBe(0);
161
+ });
162
+
163
+ it("shrinks the anchor spacer as content grows, never below zero or above initial", () => {
164
+ const base = {
165
+ initialSpacerHeight: 100,
166
+ contentHeightAtAnchor: 1000
167
+ };
168
+
169
+ expect(
170
+ computeShrunkSpacerHeight({ ...base, currentContentHeight: 1000 })
171
+ ).toBe(100);
172
+ expect(
173
+ computeShrunkSpacerHeight({ ...base, currentContentHeight: 1040 })
174
+ ).toBe(60);
175
+ expect(
176
+ computeShrunkSpacerHeight({ ...base, currentContentHeight: 1500 })
177
+ ).toBe(0);
178
+ // Content shrank (e.g. a collapsed tool row): spacer never grows back.
179
+ expect(
180
+ computeShrunkSpacerHeight({ ...base, currentContentHeight: 900 })
181
+ ).toBe(100);
182
+ });
183
+
93
184
  it("resolves wheel intent for pause and resume", () => {
94
185
  expect(
95
186
  resolveFollowStateFromWheel({
@@ -110,3 +110,71 @@ export function resolveFollowStateFromWheel(
110
110
 
111
111
  return "none";
112
112
  }
113
+
114
+ export type SelectionLike = Pick<Selection, "isCollapsed"> & {
115
+ anchorNode: Node | null;
116
+ focusNode: Node | null;
117
+ };
118
+
119
+ /**
120
+ * True when the user has a non-collapsed text selection that touches the
121
+ * given container. Used to pause auto-follow while the user is selecting
122
+ * transcript text, so the streaming scroll doesn't drag content out from
123
+ * under an in-progress drag-selection.
124
+ */
125
+ export function hasSelectionWithin(
126
+ selection: SelectionLike | null,
127
+ container: Pick<Node, "contains">
128
+ ): boolean {
129
+ if (!selection || selection.isCollapsed) return false;
130
+ return (
131
+ container.contains(selection.anchorNode) ||
132
+ container.contains(selection.focusNode)
133
+ );
134
+ }
135
+
136
+ export type AnchorScrollInput = {
137
+ /** Top of the anchored user message, relative to the scroll content. */
138
+ anchorOffsetTop: number;
139
+ /** Desired gap kept between the anchored message and the viewport top. */
140
+ topOffset: number;
141
+ /** clientHeight of the scroll container. */
142
+ viewportHeight: number;
143
+ /** scrollHeight of the scroll container, excluding any anchor spacer. */
144
+ contentHeight: number;
145
+ };
146
+
147
+ /**
148
+ * Geometry for anchor-top ("pin the sent user message near the viewport
149
+ * top") scrolling. The spacer height is the extra scrollable room needed so
150
+ * the target scroll position is reachable before the streamed response has
151
+ * grown tall enough to provide it naturally.
152
+ */
153
+ export function computeAnchorScrollState(input: AnchorScrollInput): {
154
+ targetScrollTop: number;
155
+ spacerHeight: number;
156
+ } {
157
+ const targetScrollTop = Math.max(0, input.anchorOffsetTop - input.topOffset);
158
+ const spacerHeight = Math.max(
159
+ 0,
160
+ targetScrollTop + input.viewportHeight - input.contentHeight
161
+ );
162
+ return { targetScrollTop, spacerHeight };
163
+ }
164
+
165
+ /**
166
+ * Shrink-only spacer update: as streamed content grows beneath the anchored
167
+ * message, the spacer gives back exactly that much room so total scroll
168
+ * height stays constant and nothing jumps. Never grows after anchoring.
169
+ */
170
+ export function computeShrunkSpacerHeight(input: {
171
+ initialSpacerHeight: number;
172
+ contentHeightAtAnchor: number;
173
+ currentContentHeight: number;
174
+ }): number {
175
+ const growth = Math.max(
176
+ 0,
177
+ input.currentContentHeight - input.contentHeightAtAnchor
178
+ );
179
+ return Math.max(0, input.initialSpacerHeight - growth);
180
+ }
@@ -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);
@@ -66,6 +66,14 @@ interface ModelContextToolInfo {
66
66
  description: string;
67
67
  /** JSON-encoded JSON Schema for the tool's input. */
68
68
  inputSchema?: string;
69
+ /**
70
+ * Display title declared on the tool (`ToolDescriptor.title` in the WebMCP
71
+ * spec). The polyfill returns `""` when the tool didn't declare one. Note:
72
+ * `annotations` (incl. the legacy `annotations.title`) are NOT exposed on
73
+ * this strict consumer surface — top-level `title` is the only display-name
74
+ * channel available to us.
75
+ */
76
+ title?: string;
69
77
  }
70
78
 
71
79
  interface ModelContextCoreLike {
@@ -77,6 +85,43 @@ interface ModelContextCoreLike {
77
85
  ): Promise<string | null>;
78
86
  }
79
87
 
88
+ /**
89
+ * Page-global map of bare tool name → declared display title
90
+ * (`ToolDescriptor.title`). `document.modelContext` is page-global, so a
91
+ * single map shared across widget/bridge instances is semantically correct.
92
+ * Refreshed on every registry read (`snapshotForDispatch` / `executeToolCall`)
93
+ * and consumed by the approval bubble's summary line via
94
+ * `getWebMcpToolDisplayTitle`.
95
+ */
96
+ const webMcpToolDisplayTitles = new Map<string, string>();
97
+
98
+ /**
99
+ * Record declared display titles from a fresh `getTools()` read. The map is
100
+ * rebuilt from scratch — callers always pass the FULL registry snapshot — so
101
+ * a tool that unregistered or dropped its title can't leave a stale label
102
+ * behind. Exported for tests; production callers are the bridge's registry
103
+ * reads.
104
+ */
105
+ export const recordWebMcpToolDisplayTitles = (
106
+ infos: ModelContextToolInfo[],
107
+ ): void => {
108
+ webMcpToolDisplayTitles.clear();
109
+ for (const info of infos) {
110
+ const title = info.title?.trim();
111
+ if (title) webMcpToolDisplayTitles.set(info.name, title);
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Look up the display title a page tool declared via the WebMCP spec's
117
+ * `ToolDescriptor.title`. Accepts wire (`webmcp:add_to_cart`) or bare
118
+ * (`add_to_cart`) names. Returns `undefined` when the tool didn't declare
119
+ * one (callers fall back to humanizing the tool name).
120
+ */
121
+ export const getWebMcpToolDisplayTitle = (
122
+ toolName: string,
123
+ ): string | undefined => webMcpToolDisplayTitles.get(stripWebMcpPrefix(toolName));
124
+
80
125
  const log = {
81
126
  warn(message: string, ...rest: unknown[]): void {
82
127
  if (typeof console !== "undefined" && typeof console.warn === "function") {
@@ -220,6 +265,7 @@ export class WebMcpBridge {
220
265
  log.warn("getTools() threw — shipping an empty WebMCP snapshot.", err);
221
266
  return [];
222
267
  }
268
+ recordWebMcpToolDisplayTitles(infos);
223
269
 
224
270
  const pageOrigin = typeof location !== "undefined" ? location.origin : "";
225
271
 
@@ -295,6 +341,7 @@ export class WebMcpBridge {
295
341
  const message = err instanceof Error ? err.message : String(err);
296
342
  return errorResult(`Failed to read WebMCP registry: ${message}`);
297
343
  }
344
+ recordWebMcpToolDisplayTitles(infos);
298
345
  const info = infos.find((candidate) => candidate.name === bareName);
299
346
 
300
347
  if (!info) {
@@ -323,10 +370,12 @@ export class WebMcpBridge {
323
370
 
324
371
  // Confirm-by-default gate. Every `webmcp:*` call routes through here,
325
372
  // regardless of `annotations.readOnlyHint`.
373
+ const displayTitle = getWebMcpToolDisplayTitle(bareName);
326
374
  const gateInfo: WebMcpConfirmInfo = {
327
375
  toolName: bareName,
328
376
  args,
329
377
  description: info.description,
378
+ ...(displayTitle ? { title: displayTitle } : {}),
330
379
  reason: "gate",
331
380
  };
332
381
  if (!(await this.requestConfirm(gateInfo))) {