@runtypelabs/persona 3.30.0 → 3.31.1

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 (50) hide show
  1. package/README.md +15 -2602
  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 +4 -4
  9. package/dist/codegen.js +3 -3
  10. package/dist/index.cjs +46 -46
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +52 -0
  13. package/dist/index.d.ts +52 -0
  14. package/dist/index.global.js +77 -80
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +45 -45
  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/smart-dom-reader.d.cts +50 -0
  21. package/dist/smart-dom-reader.d.ts +50 -0
  22. package/dist/theme-editor.cjs +39 -39
  23. package/dist/theme-editor.d.cts +50 -0
  24. package/dist/theme-editor.d.ts +50 -0
  25. package/dist/theme-editor.js +39 -39
  26. package/dist/theme-reference.cjs +1 -1
  27. package/dist/theme-reference.js +1 -1
  28. package/dist/webmcp-polyfill.js +4 -0
  29. package/dist/widget.css +19 -0
  30. package/package.json +4 -3
  31. package/src/client.ts +6 -0
  32. package/src/components/approval-bubble.test.ts +52 -0
  33. package/src/components/approval-bubble.ts +20 -0
  34. package/src/components/panel.ts +7 -1
  35. package/src/defaults.ts +14 -0
  36. package/src/generated/runtype-openapi-contract.ts +4 -0
  37. package/src/index-global.ts +39 -0
  38. package/src/session.test.ts +62 -0
  39. package/src/session.ts +27 -0
  40. package/src/styles/widget.css +19 -0
  41. package/src/theme-reference.ts +1 -1
  42. package/src/types.ts +50 -0
  43. package/src/ui.scroll.test.ts +383 -0
  44. package/src/ui.ts +390 -40
  45. package/src/utils/auto-follow.test.ts +91 -0
  46. package/src/utils/auto-follow.ts +68 -0
  47. package/src/version.ts +5 -2
  48. package/src/webmcp-bridge.test.ts +60 -0
  49. package/src/webmcp-bridge.ts +34 -1
  50. package/src/webmcp-polyfill.ts +16 -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
+ }
package/src/version.ts CHANGED
@@ -1,7 +1,10 @@
1
- import packageJson from "../package.json";
1
+ // Named import (not default) so esbuild tree-shakes the JSON module down to
2
+ // the one field we read — a default import inlines the entire package.json
3
+ // (~4.8 kB minified) into every bundle.
4
+ import { version } from "../package.json";
2
5
 
3
6
  /**
4
7
  * The current version of the @runtypelabs/persona package.
5
8
  * This is automatically derived from package.json.
6
9
  */
7
- export const VERSION = packageJson.version;
10
+ export const VERSION = version;
@@ -31,6 +31,7 @@ import {
31
31
  isWebMcpToolName,
32
32
  stripWebMcpPrefix,
33
33
  computeClientToolsFingerprint,
34
+ setWebMcpPolyfillLoader,
34
35
  } from "./webmcp-bridge";
35
36
  import type { ClientToolDefinition } from "./types";
36
37
 
@@ -114,6 +115,9 @@ beforeEach(() => {
114
115
 
115
116
  afterEach(() => {
116
117
  vi.unstubAllGlobals();
118
+ // The loader is module-global (page-global in production); reset so a test
119
+ // that registers one can't leak into the default-import tests.
120
+ setWebMcpPolyfillLoader(null);
117
121
  });
118
122
 
119
123
  describe("stripWebMcpPrefix", () => {
@@ -150,6 +154,9 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
150
154
  });
151
155
 
152
156
  it("returns empty when initializeWebMCPPolyfill() throws", async () => {
157
+ // No pre-existing modelContext — otherwise install() short-circuits
158
+ // before importing the polyfill and the throwing init never runs.
159
+ vi.stubGlobal("document", {});
153
160
  polyfillMock.initThrows = true;
154
161
  const bridge = new WebMcpBridge({ enabled: true });
155
162
  expect(await bridge.snapshotForDispatch()).toEqual([]);
@@ -204,6 +211,59 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
204
211
  });
205
212
  });
206
213
 
214
+ describe("setWebMcpPolyfillLoader", () => {
215
+ it("uses the registered loader instead of the bare import when no modelContext exists", async () => {
216
+ // Start with no modelContext so install() actually loads the polyfill;
217
+ // the loader's init then installs the registry, like the real one would.
218
+ const doc: { modelContext?: ReturnType<typeof makeModelContext> } = {};
219
+ vi.stubGlobal("document", doc);
220
+ registry.tools = [fakeTool({ name: "search" })];
221
+ const loader = vi.fn(async () => ({
222
+ initializeWebMCPPolyfill: () => {
223
+ doc.modelContext = makeModelContext();
224
+ },
225
+ }));
226
+ setWebMcpPolyfillLoader(loader);
227
+
228
+ const bridge = new WebMcpBridge({ enabled: true });
229
+ const snap = await bridge.snapshotForDispatch();
230
+
231
+ expect(loader).toHaveBeenCalledTimes(1);
232
+ expect(snap.map((t) => t.name)).toEqual(["search"]);
233
+ expect(bridge.isOperational()).toBe(true);
234
+ });
235
+
236
+ it("disables WebMCP (with a warning) when the loader rejects", async () => {
237
+ vi.stubGlobal("document", {});
238
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
239
+ setWebMcpPolyfillLoader(() => Promise.reject(new Error("chunk 404")));
240
+
241
+ const bridge = new WebMcpBridge({ enabled: true });
242
+ expect(await bridge.snapshotForDispatch()).toEqual([]);
243
+ expect(bridge.isOperational()).toBe(false);
244
+ expect(warn).toHaveBeenCalledWith(
245
+ expect.stringContaining("Failed to load @mcp-b/webmcp-polyfill"),
246
+ expect.any(Error),
247
+ );
248
+ warn.mockRestore();
249
+ });
250
+
251
+ it("never invokes the loader when a compatible modelContext is already present", async () => {
252
+ // beforeEach stubs a compatible document.modelContext — the install
253
+ // short-circuit must win, so a failing loader is irrelevant.
254
+ registry.tools = [fakeTool({ name: "search" })];
255
+ const loader = vi.fn(() => Promise.reject(new Error("should not load")));
256
+ setWebMcpPolyfillLoader(loader);
257
+
258
+ const bridge = new WebMcpBridge({ enabled: true });
259
+ const snap = await bridge.snapshotForDispatch();
260
+
261
+ expect(loader).not.toHaveBeenCalled();
262
+ expect(snap.map((t) => t.name)).toEqual(["search"]);
263
+ expect(bridge.isOperational()).toBe(true);
264
+ });
265
+ });
266
+
207
267
  describe("WebMCP display titles", () => {
208
268
  it("records declared titles on snapshot and exposes them via getWebMcpToolDisplayTitle", async () => {
209
269
  registry.tools = [
@@ -131,6 +131,28 @@ const log = {
131
131
  },
132
132
  };
133
133
 
134
+ /** The slice of `@mcp-b/webmcp-polyfill` the bridge consumes on install. */
135
+ export type WebMcpPolyfillModule = {
136
+ initializeWebMCPPolyfill: () => void;
137
+ };
138
+
139
+ /**
140
+ * Override how the polyfill module is obtained. By default the bridge does
141
+ * `import("@mcp-b/webmcp-polyfill")`, which bundlers resolve for npm
142
+ * consumers. The IIFE/CDN build can't resolve a bare specifier at runtime, so
143
+ * its entry (`index-global.ts`) registers a loader that imports the
144
+ * self-contained `webmcp-polyfill.js` chunk from a URL derived from the
145
+ * widget script's own `src`. Page-global, like `document.modelContext`
146
+ * itself. Pass `null` to restore the default (used by tests).
147
+ */
148
+ let polyfillLoader: (() => Promise<WebMcpPolyfillModule>) | null = null;
149
+
150
+ export const setWebMcpPolyfillLoader = (
151
+ loader: (() => Promise<WebMcpPolyfillModule>) | null,
152
+ ): void => {
153
+ polyfillLoader = loader;
154
+ };
155
+
134
156
  /**
135
157
  * Compute a stable, order-independent fingerprint of a `ClientToolDefinition[]`
136
158
  * snapshot, for the diff-only / send-once dispatch path (client-token mode).
@@ -449,7 +471,18 @@ export class WebMcpBridge {
449
471
 
450
472
  private async install(): Promise<void> {
451
473
  try {
452
- const mod = await import("@mcp-b/webmcp-polyfill");
474
+ // A compatible registry is already on the page (the host installed the
475
+ // polyfill, or a native impl) — initialize would no-op against it, so
476
+ // skip loading the module entirely. Pages that register tools before
477
+ // Persona's first dispatch always land here, because registering
478
+ // requires `document.modelContext` to exist.
479
+ if (this.getModelContext()) {
480
+ this.installed = true;
481
+ return;
482
+ }
483
+ const mod = polyfillLoader
484
+ ? await polyfillLoader()
485
+ : await import("@mcp-b/webmcp-polyfill");
453
486
  // Idempotent: no-ops if `document.modelContext` already exists (native or
454
487
  // a prior install by the host page).
455
488
  mod.initializeWebMCPPolyfill();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Standalone WebMCP polyfill chunk (`dist/webmcp-polyfill.js`).
3
+ *
4
+ * The IIFE/CDN widget bundle marks `@mcp-b/webmcp-polyfill` external so the
5
+ * ~35 kB (minified) polyfill + its transitive `@cfworker/json-schema` never
6
+ * ship to consumers who don't enable WebMCP. When `config.webmcp.enabled` is
7
+ * true and no `document.modelContext` exists yet, the bridge dynamically
8
+ * imports this chunk from a URL derived from the widget script's own `src`
9
+ * (see the loader registered in `index-global.ts`).
10
+ *
11
+ * Built self-contained (`--no-external`) — it must work standalone on a CDN
12
+ * with no module resolution. npm/bundler consumers never load this file;
13
+ * their bundlers resolve the bare `import("@mcp-b/webmcp-polyfill")` in
14
+ * `webmcp-bridge.ts` directly.
15
+ */
16
+ export { initializeWebMCPPolyfill } from "@mcp-b/webmcp-polyfill";