@hyperframes/studio 0.8.16 → 0.8.18
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/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DzRNJZAz.js} +1 -1
- package/dist/assets/{index-Cf-mbMRL.js → index-B4qse6wy.js} +1 -1
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-D8o3ZIo2.js → index-F-PUkOVc.js} +128 -128
- package/dist/assets/{index-YmetcS6L.js → index-SGl0bb71.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1640 -1282
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
- package/src/components/editor/DomEditCropHandles.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
- package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
- package/src/components/nle/useCompositionStack.test.tsx +69 -0
- package/src/components/nle/useCompositionStack.ts +8 -2
- package/src/hooks/domEditCommitRunner.ts +47 -0
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
- package/src/hooks/useDomEditTextCommits.ts +17 -9
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +10 -2
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
- package/src/hooks/useElementLifecycleOps.ts +11 -5
- package/src/hooks/useGsapSelectionHandlers.ts +4 -2
- package/src/utils/studioUiPreferences.ts +11 -0
- package/src/webmcp/StudioAgentTools.tsx +46 -0
- package/src/webmcp/handles.test.ts +130 -0
- package/src/webmcp/handles.ts +129 -0
- package/src/webmcp/polyfill.test.ts +98 -0
- package/src/webmcp/polyfill.ts +60 -0
- package/src/webmcp/registrar.test.ts +150 -0
- package/src/webmcp/registrar.ts +115 -0
- package/src/webmcp/toolResult.ts +67 -0
- package/src/webmcp/tools/lookTools.test.ts +225 -0
- package/src/webmcp/tools/lookTools.ts +201 -0
- package/src/webmcp/types.ts +90 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
- package/src/webmcp/useStudioAgentTools.ts +119 -0
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type LayerRevealCommitOwnership,
|
|
21
21
|
} from "../components/editor/useLayerRevealOverride";
|
|
22
22
|
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
|
23
|
+
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
|
|
23
24
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
24
25
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
25
26
|
|
|
@@ -87,11 +88,11 @@ export function useElementLifecycleOps({
|
|
|
87
88
|
// fallow-ignore-next-line complexity
|
|
88
89
|
const handleDomEditElementsDelete = useCallback(
|
|
89
90
|
// fallow-ignore-next-line complexity
|
|
90
|
-
async (selections: DomEditSelection[]) => {
|
|
91
|
+
async (selections: DomEditSelection[]): Promise<DomEditCommitOutcome> => {
|
|
91
92
|
const pid = projectIdRef.current;
|
|
92
|
-
if (!pid) return;
|
|
93
|
+
if (!pid) return domEditCommitDeclined("no-project");
|
|
93
94
|
const [selection] = selections;
|
|
94
|
-
if (!selection) return;
|
|
95
|
+
if (!selection) return domEditCommitDeclined("no-selection");
|
|
95
96
|
const label =
|
|
96
97
|
selections.length === 1
|
|
97
98
|
? selection.label || selection.id || selection.selector || selection.tagName
|
|
@@ -141,7 +142,7 @@ export function useElementLifecycleOps({
|
|
|
141
142
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
142
143
|
"info",
|
|
143
144
|
);
|
|
144
|
-
return;
|
|
145
|
+
return { ok: true } as const;
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -174,7 +175,8 @@ export function useElementLifecycleOps({
|
|
|
174
175
|
// matching at all means the preview is describing a document the file
|
|
175
176
|
// does not have — say so rather than reporting a delete that happened.
|
|
176
177
|
reloadPreview();
|
|
177
|
-
|
|
178
|
+
showToast("Nothing to delete, the preview was out of date. Try again.");
|
|
179
|
+
return domEditCommitDeclined("preview-stale");
|
|
178
180
|
}
|
|
179
181
|
const patchedContent =
|
|
180
182
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
@@ -208,9 +210,13 @@ export function useElementLifecycleOps({
|
|
|
208
210
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
209
211
|
"info",
|
|
210
212
|
);
|
|
213
|
+
return { ok: true } as const;
|
|
211
214
|
} catch (error) {
|
|
212
215
|
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
213
216
|
showToast(message);
|
|
217
|
+
// The toast is what tells the human. The returned outcome is what tells
|
|
218
|
+
// a caller that has no screen to read.
|
|
219
|
+
return domEditCommitDeclined("persist-failed");
|
|
214
220
|
}
|
|
215
221
|
},
|
|
216
222
|
[
|
|
@@ -111,7 +111,7 @@ export function useGsapSelectionHandlers({
|
|
|
111
111
|
) => Promise<void>;
|
|
112
112
|
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
|
113
113
|
|
|
114
|
-
handleDomManualEditsReset: (sel: DomEditSelection) => void
|
|
114
|
+
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
|
|
115
115
|
selectedGsapAnimations: GsapAnimation[];
|
|
116
116
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
117
117
|
}) {
|
|
@@ -230,7 +230,9 @@ export function useGsapSelectionHandlers({
|
|
|
230
230
|
},
|
|
231
231
|
);
|
|
232
232
|
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
|
233
|
-
|
|
233
|
+
// The reset owns rollback and the position commit already owns user and
|
|
234
|
+
// telemetry reporting. This is only the fire-and-forget UI boundary.
|
|
235
|
+
void handleDomManualEditsReset(domEditSelection).catch(() => undefined);
|
|
234
236
|
}
|
|
235
237
|
},
|
|
236
238
|
[domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
|
|
@@ -34,6 +34,14 @@ export interface StudioUiPreferences {
|
|
|
34
34
|
timelineZoomMode?: "fit" | "manual";
|
|
35
35
|
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
|
36
36
|
timelineManualZoomPercent?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Expose Studio's editing capabilities to an agentic browser as WebMCP tools.
|
|
39
|
+
* Absent means on: the browser still gates every actual call behind its own
|
|
40
|
+
* permission prompt, so "registered" is not "reachable without consent".
|
|
41
|
+
* Changes take effect on the next Studio reload because registration is
|
|
42
|
+
* intentionally scoped to one mount.
|
|
43
|
+
*/
|
|
44
|
+
agentToolsEnabled?: boolean;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
|
@@ -140,6 +148,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
140
148
|
) {
|
|
141
149
|
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
|
142
150
|
}
|
|
151
|
+
if (typeof parsed.agentToolsEnabled === "boolean") {
|
|
152
|
+
preferences.agentToolsEnabled = parsed.agentToolsEnabled;
|
|
153
|
+
}
|
|
143
154
|
return preferences;
|
|
144
155
|
} catch {
|
|
145
156
|
return {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
3
|
+
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
4
|
+
import { usePlayerStore } from "../player";
|
|
5
|
+
import { useStudioAgentTools } from "./useStudioAgentTools";
|
|
6
|
+
import type { StudioLookSnapshot } from "./tools/lookTools";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mounts Studio's WebMCP tool surface. Renders nothing.
|
|
10
|
+
*
|
|
11
|
+
* Lives inside `EditorShell` rather than `App` for two reasons: the DomEdit
|
|
12
|
+
* contexts are only readable below `DomEditProvider`, which `App` renders, and
|
|
13
|
+
* `App.tsx` sits three lines under the 600-line cap.
|
|
14
|
+
*
|
|
15
|
+
* The player store is read IMPERATIVELY through `getState()` inside the
|
|
16
|
+
* snapshot callback rather than subscribed to. Subscribing to `currentTime`
|
|
17
|
+
* would re-render this component on every animation frame during playback for
|
|
18
|
+
* a value nothing here displays.
|
|
19
|
+
*/
|
|
20
|
+
export function StudioAgentTools() {
|
|
21
|
+
const { projectId, activeCompPath, editHistory } = useStudioShellContext();
|
|
22
|
+
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
|
|
23
|
+
|
|
24
|
+
const getSnapshot = useCallback((): StudioLookSnapshot => {
|
|
25
|
+
const player = usePlayerStore.getState();
|
|
26
|
+
return {
|
|
27
|
+
projectId,
|
|
28
|
+
compositionPath: activeCompPath,
|
|
29
|
+
currentTime: player.currentTime,
|
|
30
|
+
duration: player.duration,
|
|
31
|
+
isPlaying: player.isPlaying,
|
|
32
|
+
elements: player.elements,
|
|
33
|
+
selection: domEditSelection,
|
|
34
|
+
selectionAnimationCount: selectedGsapAnimations.length,
|
|
35
|
+
history: {
|
|
36
|
+
canUndo: editHistory.canUndo,
|
|
37
|
+
canRedo: editHistory.canRedo,
|
|
38
|
+
undoLabel: editHistory.undoLabel ?? null,
|
|
39
|
+
redoLabel: editHistory.redoLabel ?? null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
|
|
43
|
+
|
|
44
|
+
useStudioAgentTools({ getSnapshot });
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
4
|
+
import {
|
|
5
|
+
mintElementHandle,
|
|
6
|
+
parseElementHandle,
|
|
7
|
+
resolveElementHandle,
|
|
8
|
+
timelineElementAddress,
|
|
9
|
+
} from "./handles";
|
|
10
|
+
|
|
11
|
+
function timelineElement(overrides: Partial<TimelineElement>): TimelineElement {
|
|
12
|
+
return { id: "synthetic-id", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A separate document, standing in for the preview iframe's realm. */
|
|
16
|
+
function previewDoc(html: string): Document {
|
|
17
|
+
const iframe = document.createElement("iframe");
|
|
18
|
+
document.body.append(iframe);
|
|
19
|
+
const doc = iframe.contentDocument;
|
|
20
|
+
if (!doc) throw new Error("expected iframe document");
|
|
21
|
+
doc.body.innerHTML = html;
|
|
22
|
+
return doc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("mintElementHandle", () => {
|
|
26
|
+
it("prefers data-hf-id, the stable patch target", () => {
|
|
27
|
+
const handle = mintElementHandle(
|
|
28
|
+
timelineElementAddress(
|
|
29
|
+
timelineElement({ hfId: "abc123", domId: "headline", selector: ".title" }),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
expect(handle).toBe("hf:abc123");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to the DOM id when there is no hf id", () => {
|
|
36
|
+
expect(
|
|
37
|
+
mintElementHandle(
|
|
38
|
+
timelineElementAddress(timelineElement({ domId: "headline", selector: ".title" })),
|
|
39
|
+
),
|
|
40
|
+
).toBe("dom:headline");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to a selector with its occurrence index", () => {
|
|
44
|
+
expect(
|
|
45
|
+
mintElementHandle(
|
|
46
|
+
timelineElementAddress(timelineElement({ selector: ".card", selectorIndex: 2 })),
|
|
47
|
+
),
|
|
48
|
+
).toBe("sel:.card#2");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("defaults a missing occurrence index to the first match", () => {
|
|
52
|
+
expect(mintElementHandle(timelineElementAddress(timelineElement({ selector: ".card" })))).toBe(
|
|
53
|
+
"sel:.card#0",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null when the element carries no way to address it", () => {
|
|
58
|
+
// The synthesised `id` is deliberately NOT used: it cannot resolve.
|
|
59
|
+
expect(mintElementHandle(timelineElementAddress(timelineElement({})))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("parseElementHandle", () => {
|
|
64
|
+
it("splits the index off the LAST hash, so id selectors survive", () => {
|
|
65
|
+
expect(parseElementHandle("sel:#card > .title#3")).toEqual({
|
|
66
|
+
scheme: "sel",
|
|
67
|
+
value: "#card > .title",
|
|
68
|
+
index: 3,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("treats a selector with no index as the first match", () => {
|
|
73
|
+
expect(parseElementHandle("sel:.card")).toEqual({ scheme: "sel", value: ".card", index: 0 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects an unknown scheme", () => {
|
|
77
|
+
expect(parseElementHandle("xpath://div")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects a handle with no value", () => {
|
|
81
|
+
expect(parseElementHandle("dom:")).toBeNull();
|
|
82
|
+
expect(parseElementHandle("")).toBeNull();
|
|
83
|
+
expect(parseElementHandle(":headline")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("resolveElementHandle", () => {
|
|
88
|
+
it("round-trips every handle scheme a read can mint", () => {
|
|
89
|
+
const doc = previewDoc(
|
|
90
|
+
`<div id="headline" data-hf-id="abc123">A</div>
|
|
91
|
+
<div class="card">first</div>
|
|
92
|
+
<div class="card">second</div>`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(resolveElementHandle(doc, "hf:abc123")?.id).toBe("headline");
|
|
96
|
+
expect(resolveElementHandle(doc, "dom:headline")?.id).toBe("headline");
|
|
97
|
+
expect(resolveElementHandle(doc, "sel:.card#1")?.textContent).toBe("second");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("resolves across realms, where a naive instanceof check fails", () => {
|
|
101
|
+
const doc = previewDoc('<div id="headline">A</div>');
|
|
102
|
+
const resolved = resolveElementHandle(doc, "dom:headline");
|
|
103
|
+
|
|
104
|
+
expect(resolved).not.toBeNull();
|
|
105
|
+
// The preview element is NOT an instance of Studio's own HTMLElement.
|
|
106
|
+
expect(resolved instanceof HTMLElement).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null for a handle that no longer matches", () => {
|
|
110
|
+
const doc = previewDoc('<div id="headline">A</div>');
|
|
111
|
+
expect(resolveElementHandle(doc, "dom:deleted")).toBeNull();
|
|
112
|
+
expect(resolveElementHandle(doc, "hf:missing")).toBeNull();
|
|
113
|
+
expect(resolveElementHandle(doc, "sel:.card#0")).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns null for an out-of-range occurrence rather than the wrong element", () => {
|
|
117
|
+
const doc = previewDoc('<div class="card">only</div>');
|
|
118
|
+
expect(resolveElementHandle(doc, "sel:.card#4")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns null for a selector that is invalid in this document", () => {
|
|
122
|
+
const doc = previewDoc('<div class="card">only</div>');
|
|
123
|
+
expect(resolveElementHandle(doc, "sel:>>>broken#0")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null for a malformed handle", () => {
|
|
127
|
+
const doc = previewDoc('<div id="headline">A</div>');
|
|
128
|
+
expect(resolveElementHandle(doc, "nonsense")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque element handles: the one thing reads mint and writes consume.
|
|
3
|
+
*
|
|
4
|
+
* `TimelineElement.id` cannot do this job. It is a SYNTHESISED identity built
|
|
5
|
+
* from label, index, selector and source file when the clip has no authored id
|
|
6
|
+
* (`timelineElementHelpers.buildTimelineElementIdentity`), so
|
|
7
|
+
* `getElementById(element.id)` misses most elements. The real addressing fields
|
|
8
|
+
* are separate: `hfId` (the `data-hf-id` the codebase calls the stable primary
|
|
9
|
+
* patch target), `domId`, and a `selector` plus occurrence index.
|
|
10
|
+
*
|
|
11
|
+
* Handles are strings so they survive a JSON round trip through the agent
|
|
12
|
+
* untouched. The agent never builds one; it passes back what a read gave it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
16
|
+
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
17
|
+
|
|
18
|
+
const SEPARATOR = ":";
|
|
19
|
+
const INDEX_SEPARATOR = "#";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How to find one element. `TimelineElement` calls the DOM id `domId` and
|
|
23
|
+
* `PatchTarget` calls it `id`, so both adapt into this rather than the minter
|
|
24
|
+
* knowing about either.
|
|
25
|
+
*/
|
|
26
|
+
export interface ElementAddress {
|
|
27
|
+
hfId?: string;
|
|
28
|
+
domId?: string | null;
|
|
29
|
+
selector?: string;
|
|
30
|
+
selectorIndex?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Address an element the same way Studio's own patcher does, most stable first.
|
|
35
|
+
* `data-hf-id` survives edits that renumber or reorder; a bare selector does not.
|
|
36
|
+
*/
|
|
37
|
+
export function mintElementHandle(address: ElementAddress): string | null {
|
|
38
|
+
if (address.hfId) return `hf${SEPARATOR}${address.hfId}`;
|
|
39
|
+
if (address.domId) return `dom${SEPARATOR}${address.domId}`;
|
|
40
|
+
if (address.selector) {
|
|
41
|
+
const index = address.selectorIndex ?? 0;
|
|
42
|
+
return `sel${SEPARATOR}${address.selector}${INDEX_SEPARATOR}${index}`;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function timelineElementAddress(element: TimelineElement): ElementAddress {
|
|
48
|
+
return {
|
|
49
|
+
hfId: element.hfId,
|
|
50
|
+
domId: element.domId,
|
|
51
|
+
selector: element.selector,
|
|
52
|
+
selectorIndex: element.selectorIndex,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function patchTargetAddress(target: PatchTarget): ElementAddress {
|
|
57
|
+
return {
|
|
58
|
+
hfId: target.hfId,
|
|
59
|
+
domId: target.id,
|
|
60
|
+
selector: target.selector,
|
|
61
|
+
selectorIndex: target.selectorIndex,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ParsedHandle {
|
|
66
|
+
scheme: "hf" | "dom" | "sel";
|
|
67
|
+
value: string;
|
|
68
|
+
index: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseElementHandle(handle: string): ParsedHandle | null {
|
|
72
|
+
const separatorAt = handle.indexOf(SEPARATOR);
|
|
73
|
+
if (separatorAt <= 0) return null;
|
|
74
|
+
const scheme = handle.slice(0, separatorAt);
|
|
75
|
+
const rest = handle.slice(separatorAt + 1);
|
|
76
|
+
if (!rest) return null;
|
|
77
|
+
if (scheme === "hf" || scheme === "dom") return { scheme, value: rest, index: 0 };
|
|
78
|
+
if (scheme !== "sel") return null;
|
|
79
|
+
|
|
80
|
+
// Only the LAST `#` splits the index off: CSS selectors contain `#` themselves.
|
|
81
|
+
const indexAt = rest.lastIndexOf(INDEX_SEPARATOR);
|
|
82
|
+
if (indexAt <= 0) return { scheme, value: rest, index: 0 };
|
|
83
|
+
const index = Number(rest.slice(indexAt + 1));
|
|
84
|
+
if (!Number.isInteger(index) || index < 0) return { scheme, value: rest, index: 0 };
|
|
85
|
+
return { scheme, value: rest.slice(0, indexAt), index };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a handle against the preview document.
|
|
90
|
+
*
|
|
91
|
+
* Always re-resolve per call rather than holding an element across calls: a
|
|
92
|
+
* preview reload replaces the document, and a node from the destroyed one is
|
|
93
|
+
* detached but still looks like an element.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveElementHandle(doc: Document, handle: string): HTMLElement | null {
|
|
96
|
+
const parsed = parseElementHandle(handle);
|
|
97
|
+
if (!parsed) return null;
|
|
98
|
+
|
|
99
|
+
if (parsed.scheme === "dom") return asHtmlElement(doc, doc.getElementById(parsed.value));
|
|
100
|
+
if (parsed.scheme === "hf") {
|
|
101
|
+
return asHtmlElement(doc, doc.querySelector(`[data-hf-id="${cssEscape(parsed.value)}"]`));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let matches: NodeListOf<Element>;
|
|
105
|
+
try {
|
|
106
|
+
matches = doc.querySelectorAll(parsed.value);
|
|
107
|
+
} catch {
|
|
108
|
+
// A selector minted from a previous document can be invalid in this one.
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return asHtmlElement(doc, matches.item(parsed.index));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cssEscape(value: string): string {
|
|
115
|
+
// ponytail: happy-dom and jsdom don't always ship CSS.escape; quoting the two
|
|
116
|
+
// characters that can break out of an attribute selector covers this use.
|
|
117
|
+
return typeof CSS?.escape === "function" ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `instanceof HTMLElement` is checked against the OWNING document's realm.
|
|
122
|
+
* The preview lives in an iframe, so Studio's own `HTMLElement` is a different
|
|
123
|
+
* constructor and the naive check fails on every real preview element.
|
|
124
|
+
*/
|
|
125
|
+
function asHtmlElement(doc: Document, node: Element | null): HTMLElement | null {
|
|
126
|
+
if (!node) return null;
|
|
127
|
+
const ctor = doc.defaultView?.HTMLElement;
|
|
128
|
+
return ctor && node instanceof ctor ? node : null;
|
|
129
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ModelContext } from "./types";
|
|
4
|
+
|
|
5
|
+
// The real package defines `document.modelContext` as an import side effect.
|
|
6
|
+
// A mock cannot do that, so tests stand the object up themselves to represent
|
|
7
|
+
// the import having happened.
|
|
8
|
+
const trackEvent = vi.hoisted(() => vi.fn());
|
|
9
|
+
vi.mock("@mcp-b/global", () => ({}));
|
|
10
|
+
vi.mock("../telemetry/client", () => ({ trackEvent }));
|
|
11
|
+
|
|
12
|
+
let loadModelContextPolyfill: typeof import("./polyfill").loadModelContextPolyfill;
|
|
13
|
+
|
|
14
|
+
function installModelContext(): ModelContext {
|
|
15
|
+
const modelContext: ModelContext = { registerTool: vi.fn().mockResolvedValue(undefined) };
|
|
16
|
+
Object.defineProperty(document, "modelContext", {
|
|
17
|
+
value: modelContext,
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
});
|
|
21
|
+
return modelContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
vi.resetModules();
|
|
26
|
+
({ loadModelContextPolyfill } = await import("./polyfill"));
|
|
27
|
+
trackEvent.mockReset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
Reflect.deleteProperty(document, "modelContext");
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("loadModelContextPolyfill", () => {
|
|
36
|
+
it("returns the model context the package defines", async () => {
|
|
37
|
+
const modelContext = installModelContext();
|
|
38
|
+
|
|
39
|
+
await expect(loadModelContextPolyfill()).resolves.toBe(modelContext);
|
|
40
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_loaded");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("shares one load between callers that race", async () => {
|
|
44
|
+
installModelContext();
|
|
45
|
+
|
|
46
|
+
// Identity, not a call count: the guard being tested is the module-level
|
|
47
|
+
// promise, and the ESM registry would dedupe the import either way.
|
|
48
|
+
const first = loadModelContextPolyfill();
|
|
49
|
+
const second = loadModelContextPolyfill();
|
|
50
|
+
|
|
51
|
+
expect(first).toBe(second);
|
|
52
|
+
await expect(first).resolves.toBe(await second);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reuses the settled load rather than starting another", async () => {
|
|
56
|
+
installModelContext();
|
|
57
|
+
|
|
58
|
+
const first = loadModelContextPolyfill();
|
|
59
|
+
await first;
|
|
60
|
+
|
|
61
|
+
expect(loadModelContextPolyfill()).toBe(first);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns null when the package loads but defines nothing", async () => {
|
|
65
|
+
// Studio must still boot. A missing agent surface is not a broken editor.
|
|
66
|
+
const first = loadModelContextPolyfill();
|
|
67
|
+
await expect(first).resolves.toBeNull();
|
|
68
|
+
|
|
69
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
|
|
70
|
+
error_name: "ModelContextMissingError",
|
|
71
|
+
});
|
|
72
|
+
const retry = loadModelContextPolyfill();
|
|
73
|
+
expect(retry).not.toBe(first);
|
|
74
|
+
await expect(retry).resolves.toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reports a polyfill failure and lets a later mount retry", async () => {
|
|
78
|
+
const failure = new TypeError("blocked by policy");
|
|
79
|
+
Object.defineProperty(document, "modelContext", {
|
|
80
|
+
configurable: true,
|
|
81
|
+
get: () => {
|
|
82
|
+
throw failure;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const first = loadModelContextPolyfill();
|
|
87
|
+
await expect(first).resolves.toBeNull();
|
|
88
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
|
|
89
|
+
error_name: "TypeError",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
Reflect.deleteProperty(document, "modelContext");
|
|
93
|
+
const modelContext = installModelContext();
|
|
94
|
+
const retry = loadModelContextPolyfill();
|
|
95
|
+
expect(retry).not.toBe(first);
|
|
96
|
+
await expect(retry).resolves.toBe(modelContext);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fallback for browsers that have not shipped WebMCP.
|
|
3
|
+
*
|
|
4
|
+
* `@mcp-b/global` does two things: it defines `document.modelContext`, and it
|
|
5
|
+
* stands up an in-page MCP server for a bridge extension to attach to. The
|
|
6
|
+
* second is the reason this is the chosen package over the bare
|
|
7
|
+
* `@mcp-b/webmcp-polyfill`: without the server there is nothing for an
|
|
8
|
+
* out-of-browser agent to connect to, which is the only case the fallback
|
|
9
|
+
* exists to serve.
|
|
10
|
+
*
|
|
11
|
+
* It is a DYNAMIC import so a browser with native support never downloads it,
|
|
12
|
+
* and so it lands in its own chunk rather than the entry bundle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { makeStudioDebugLogger } from "../utils/studioDebug";
|
|
16
|
+
import { trackEvent } from "../telemetry/client";
|
|
17
|
+
import { getModelContext, type ModelContext } from "./types";
|
|
18
|
+
|
|
19
|
+
const log = makeStudioDebugLogger("webmcp");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Module-level, so two mounts racing (React StrictMode, or a remount during
|
|
23
|
+
* the import) share one load instead of pulling the package twice.
|
|
24
|
+
*/
|
|
25
|
+
let pending: Promise<ModelContext | null> | null = null;
|
|
26
|
+
|
|
27
|
+
async function importPolyfill(): Promise<ModelContext | null> {
|
|
28
|
+
try {
|
|
29
|
+
await import("@mcp-b/global");
|
|
30
|
+
const modelContext = getModelContext();
|
|
31
|
+
if (!modelContext) {
|
|
32
|
+
// The package loaded but did not define what it promises to define.
|
|
33
|
+
log("polyfill", { loaded: true, modelContext: false });
|
|
34
|
+
trackEvent("webmcp.polyfill_failed", { error_name: "ModelContextMissingError" });
|
|
35
|
+
} else {
|
|
36
|
+
trackEvent("webmcp.polyfill_loaded");
|
|
37
|
+
}
|
|
38
|
+
return modelContext;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// A missing agent surface must never break Studio's boot.
|
|
41
|
+
log("polyfill", { failed: error instanceof Error ? error.message : String(error) });
|
|
42
|
+
trackEvent("webmcp.polyfill_failed", {
|
|
43
|
+
error_name: error instanceof Error ? error.name : "NonError",
|
|
44
|
+
});
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadModelContextPolyfill(): Promise<ModelContext | null> {
|
|
50
|
+
if (pending) return pending;
|
|
51
|
+
|
|
52
|
+
const attempt = importPolyfill();
|
|
53
|
+
pending = attempt;
|
|
54
|
+
// A transient chunk/CSP failure must not disable WebMCP for the rest of the
|
|
55
|
+
// tab. Concurrent callers still share this attempt; a later mount may retry.
|
|
56
|
+
void attempt.then((modelContext) => {
|
|
57
|
+
if (modelContext === null && pending === attempt) pending = null;
|
|
58
|
+
});
|
|
59
|
+
return pending;
|
|
60
|
+
}
|