@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.
- package/README.md +15 -2602
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
- package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +3 -3
- package/dist/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.global.js +77 -80
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +50 -0
- package/dist/smart-dom-reader.d.ts +50 -0
- package/dist/theme-editor.cjs +39 -39
- package/dist/theme-editor.d.cts +50 -0
- package/dist/theme-editor.d.ts +50 -0
- package/dist/theme-editor.js +39 -39
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-polyfill.js +4 -0
- package/dist/widget.css +19 -0
- package/package.json +4 -3
- package/src/client.ts +6 -0
- package/src/components/approval-bubble.test.ts +52 -0
- package/src/components/approval-bubble.ts +20 -0
- package/src/components/panel.ts +7 -1
- package/src/defaults.ts +14 -0
- package/src/generated/runtype-openapi-contract.ts +4 -0
- package/src/index-global.ts +39 -0
- package/src/session.test.ts +62 -0
- package/src/session.ts +27 -0
- package/src/styles/widget.css +19 -0
- package/src/theme-reference.ts +1 -1
- package/src/types.ts +50 -0
- package/src/ui.scroll.test.ts +383 -0
- package/src/ui.ts +390 -40
- package/src/utils/auto-follow.test.ts +91 -0
- package/src/utils/auto-follow.ts +68 -0
- package/src/version.ts +5 -2
- package/src/webmcp-bridge.test.ts +60 -0
- package/src/webmcp-bridge.ts +34 -1
- 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({
|
package/src/utils/auto-follow.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 = [
|
package/src/webmcp-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|