feat(cli): expose Studio selection through preview (#1777)

Add a small Studio selection channel so agents can ask a running preview server
for the element the user selected in Studio. This keeps the UX on the existing
npx hyperframes preview surface while giving agents a stable source file,
target selector, timeline time, and thumbnail URL for follow-up edits.
This commit is contained in:
Miguel Ángel
2026-06-28 15:15:57 -04:00
committed by GitHub
parent fc0f8c3151
commit 9983f37c13
17 changed files with 1511 additions and 205 deletions
+562 -199
View File
@@ -1,9 +1,12 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { spawn } from "node:child_process";
import { spawn, type ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
export const examples: Example[] = [
["Preview the current project", "hyperframes preview"],
["Print the current Studio selection as JSON", "hyperframes preview --selection --json"],
["Print current Studio context as JSON", "hyperframes preview --context --json"],
["Preview a specific project directory", "hyperframes preview ./my-video"],
["Use a custom port", "hyperframes preview --port 8080"],
["Force a new server even if one is already running", "hyperframes preview --force-new"],
@@ -24,6 +27,7 @@ import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
import {
openBrowser,
parseRemoteDebuggingPort,
@@ -40,6 +44,40 @@ import {
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
import { resolveProject } from "../utils/project.js";
interface BrowserLaunchOptions {
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
}
interface StudioLaunchOptions extends BrowserLaunchOptions {
projectName?: string;
}
interface EmbeddedStudioOptions extends StudioLaunchOptions {
forceNew?: boolean;
}
type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
type ContextField = "server" | "selection" | "lint" | "capabilities";
type CompactSelectionPayload = Pick<
StudioSelectionSnapshot,
| "schemaVersion"
| "projectId"
| "compositionPath"
| "sourceFile"
| "currentTime"
| "target"
| "label"
| "tagName"
| "boundingBox"
| "textContent"
| "thumbnailUrl"
>;
const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];
export default defineCommand({
meta: { name: "preview", description: "Start the studio for previewing compositions" },
args: {
@@ -65,6 +103,32 @@ export default defineCommand({
default: true,
description: "Open browser automatically",
},
selection: {
type: "boolean",
description: "Print the current element selected in a running Studio preview and exit",
default: false,
},
json: {
type: "boolean",
description: "Output preview selection/context as JSON (only with --selection or --context)",
default: false,
},
context: {
type: "boolean",
description:
"Print the current agent-readable context from a running Studio preview and exit",
default: false,
},
"context-fields": {
type: "string",
description:
"Comma-separated context fields to include: server,selection,lint,capabilities (only with --context)",
},
"context-detail": {
type: "string",
description: "Context payload detail: compact or full (only with --context)",
default: "compact",
},
"browser-path": {
type: "string",
description: "Path to the browser executable to open",
@@ -80,6 +144,7 @@ export default defineCommand({
},
async run({ args }) {
const startPort = parseInt(args.port ?? "3002", 10);
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
// --list: scan and display active servers
if (args.list) {
@@ -111,6 +176,26 @@ export default defineCommand({
return;
}
if (args.context) {
const project = resolveProject(args.dir);
return printCurrentContext(project.dir, startPort, {
json: Boolean(args.json),
fields: args["context-fields"] as string | undefined,
detail: args["context-detail"] as string | undefined,
...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
});
}
if (args.selection) {
const project = resolveProject(args.dir);
return printCurrentSelection(
project.dir,
startPort,
Boolean(args.json),
preferredContextPort,
);
}
// Kill orphaned chrome-headless-shell processes from previous crashed sessions.
const orphansKilled = killOrphanedProcesses();
if (orphansKilled > 0) {
@@ -198,28 +283,390 @@ export default defineCommand({
},
});
/**
* Dev mode: spawn the studio dev server from the monorepo.
*/
async function runDevMode(
dir: string,
options?: {
projectName?: string;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
function previewBaseUrl(port: number): string {
return `http://127.0.0.1:${port}`;
}
function absolutePreviewUrl(port: number, path: string): string {
if (/^https?:\/\//.test(path)) return path;
return `${previewBaseUrl(port)}${path.startsWith("/") ? path : `/${path}`}`;
}
function hasExplicitPreviewPort(argv: string[]): boolean {
return argv.some((arg) => arg === "--port" || arg.startsWith("--port="));
}
function printSelectionFailure(code: string, message: string, json: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2));
} else {
clack.log.error(message);
}
process.exitCode = 1;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function previewServerPayload(server: { port: number; projectName: string; projectDir: string }): {
port: number;
projectName: string;
projectDir: string;
url: string;
} {
return {
port: server.port,
projectName: server.projectName,
projectDir: server.projectDir,
url: previewBaseUrl(server.port),
};
}
function parseContextFields(value: string | undefined): ContextField[] {
if (value === undefined) return DEFAULT_CONTEXT_FIELDS;
if (!value.trim()) throw new Error("--context-fields cannot be empty");
const allowed = new Set<ContextField>(DEFAULT_CONTEXT_FIELDS);
const fields = value
.split(",")
.map((field) => field.trim())
.filter(Boolean);
const invalid = fields.filter((field) => !allowed.has(field as ContextField));
if (invalid.length > 0) {
throw new Error(
`Unknown context field${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
);
}
return [...new Set(fields)] as ContextField[];
}
function contextIncludes(fields: ContextField[], field: ContextField): boolean {
return fields.includes(field);
}
function addContextError(
payload: Record<string, unknown>,
field: ContextField,
error: { code: string; message: string },
): void {
payload.errors = {
...((payload.errors as Record<string, unknown> | undefined) ?? {}),
[field]: error,
};
}
async function printCurrentSelection(
projectDir: string,
startPort: number,
json: boolean,
preferredPort?: number,
): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
const {
AmbiguousPreviewServerError,
PreviewServerPortMismatchError,
fetchStudioSelection,
findPreviewServerForProject,
} = await import("../utils/studioSelectionClient.js");
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
try {
server = await findPreviewServerForProject(
projectDir,
startPort,
undefined,
undefined,
preferredPort === undefined ? undefined : { preferredPort },
);
} catch (err) {
if (err instanceof AmbiguousPreviewServerError) {
printSelectionFailure("ambiguous-preview-server", err.message, json);
return;
}
if (err instanceof PreviewServerPortMismatchError) {
printSelectionFailure("preview-port-mismatch", err.message, json);
return;
}
throw err;
}
if (!server) {
printSelectionFailure(
"preview-not-running",
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
json,
);
return;
}
// Symlink project into the studio's data directory
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
const pName = options?.projectName ?? basename(dir);
const symlinkPath = join(projectsDir, pName);
let response: Awaited<ReturnType<typeof fetchStudioSelection>>;
try {
response = await fetchStudioSelection(server);
} catch (err) {
printSelectionFailure("selection-unavailable", errorMessage(err), json);
return;
}
if (!response.selection) {
printSelectionFailure(
"no-selection",
"Studio is running, but no element is selected. Select an element in Studio and rerun this command.",
json,
);
return;
}
const selection = {
...response.selection,
thumbnailUrl: absolutePreviewUrl(server.port, response.selection.thumbnailUrl),
};
if (json) {
console.log(
JSON.stringify(
{
ok: true,
server: previewServerPayload(server),
selection,
updatedAt: response.updatedAt,
},
null,
2,
),
);
return;
}
console.log(`${c.success("◇")} ${c.accent(selection.label)} selected in Studio`);
console.log(` ${c.dim("Source")} ${selection.sourceFile}`);
console.log(
` ${c.dim("Target")} ${selection.target.hfId ?? selection.target.id ?? selection.target.selector ?? "(none)"}`,
);
console.log(` ${c.dim("Time")} ${selection.currentTime.toFixed(3)}s`);
console.log(` ${c.dim("Thumbnail")} ${selection.thumbnailUrl}`);
console.log();
console.log(c.dim("Use --json for the full agent-readable selection payload."));
}
function countLintFindings(findings: Array<{ severity: string }>): {
errors: number;
warnings: number;
} {
return {
errors: findings.filter((finding) => finding.severity === "error").length,
warnings: findings.filter((finding) => finding.severity === "warning").length,
};
}
async function printCurrentContext(
projectDir: string,
startPort: number,
options: { json: boolean; fields?: string; detail?: string; preferredPort?: number },
): Promise<void> {
let fields: ContextField[];
try {
fields = parseContextFields(options.fields);
} catch (err) {
printSelectionFailure(
"invalid-context-fields",
err instanceof Error ? err.message : String(err),
options.json,
);
return;
}
const fullDetail = options.detail === "full";
if (options.detail !== undefined && !["compact", "full"].includes(options.detail)) {
printSelectionFailure(
"invalid-context-detail",
"--context-detail must be compact or full",
options.json,
);
return;
}
const {
AmbiguousPreviewServerError,
PreviewServerPortMismatchError,
fetchStudioLint,
fetchStudioSelection,
findPreviewServerForProject,
} = await import("../utils/studioSelectionClient.js");
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
try {
server = await findPreviewServerForProject(
projectDir,
startPort,
undefined,
undefined,
options.preferredPort === undefined ? undefined : { preferredPort: options.preferredPort },
);
} catch (err) {
if (err instanceof AmbiguousPreviewServerError) {
printSelectionFailure("ambiguous-preview-server", err.message, options.json);
return;
}
if (err instanceof PreviewServerPortMismatchError) {
printSelectionFailure("preview-port-mismatch", err.message, options.json);
return;
}
throw err;
}
if (!server) {
printSelectionFailure(
"preview-not-running",
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
options.json,
);
return;
}
const wantsSelection = contextIncludes(fields, "selection");
const wantsLint = contextIncludes(fields, "lint");
const [selectionResult, lintResult] = await Promise.allSettled([
wantsSelection ? fetchStudioSelection(server) : Promise.resolve(null),
wantsLint ? fetchStudioLint(server) : Promise.resolve(null),
]);
const selection =
selectionResult.status === "fulfilled" && selectionResult.value?.selection
? {
ok: true as const,
value: fullDetail
? {
...selectionResult.value.selection,
thumbnailUrl: absolutePreviewUrl(
server.port,
selectionResult.value.selection.thumbnailUrl,
),
}
: compactSelectionPayload({
...selectionResult.value.selection,
thumbnailUrl: absolutePreviewUrl(
server.port,
selectionResult.value.selection.thumbnailUrl,
),
}),
updatedAt: selectionResult.value.updatedAt,
}
: {
ok: false as const,
error:
selectionResult.status === "rejected"
? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) }
: {
code: "no-selection",
message: "Studio is running, but no element is selected.",
},
};
const lint =
lintResult.status === "fulfilled" && lintResult.value
? {
ok: true as const,
summary: countLintFindings(lintResult.value.findings),
findings: lintResult.value.findings,
}
: {
ok: false as const,
error:
lintResult.status === "rejected"
? { code: "lint-unavailable", message: errorMessage(lintResult.reason) }
: { code: "lint-not-requested", message: "Lint was not requested." },
};
const payload: Record<string, unknown> = { ok: true };
if (contextIncludes(fields, "server")) payload.server = previewServerPayload(server);
if (contextIncludes(fields, "selection")) {
payload.selection = selection.ok ? selection.value : null;
payload.selectionUpdatedAt = selection.ok ? selection.updatedAt : null;
if (!selection.ok) addContextError(payload, "selection", selection.error);
}
if (contextIncludes(fields, "lint")) payload.lint = lint;
if (contextIncludes(fields, "capabilities")) {
payload.capabilities = {
selection: true,
lint: true,
frame: false,
visibleElements: false,
lastAction: false,
};
}
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
return;
}
console.log(`${c.success("◇")} Studio context`);
if (contextIncludes(fields, "server")) {
console.log(` ${c.dim("Project")} ${server.projectName}`);
console.log(` ${c.dim("Studio")} ${previewBaseUrl(server.port)}`);
}
if (contextIncludes(fields, "selection")) {
if (selection.ok) {
console.log(` ${c.dim("Selection")} ${selection.value.label}`);
} else {
console.log(` ${c.dim("Selection")} ${selection.error.message}`);
}
}
if (contextIncludes(fields, "lint")) {
if (lint.ok) {
console.log(
` ${c.dim("Lint")} ${lint.summary.errors} error(s), ${lint.summary.warnings} warning(s)`,
);
} else {
console.log(` ${c.dim("Lint")} ${lint.error.message}`);
}
}
console.log();
console.log(c.dim("Use --json for the full agent-readable context payload."));
}
function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSelectionPayload {
return {
schemaVersion: selection.schemaVersion,
projectId: selection.projectId,
compositionPath: selection.compositionPath,
sourceFile: selection.sourceFile,
currentTime: selection.currentTime,
target: selection.target,
label: selection.label,
tagName: selection.tagName,
boundingBox: selection.boundingBox,
textContent: selection.textContent,
thumbnailUrl: selection.thumbnailUrl,
};
}
function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
if (options?.noOpen) return;
openBrowser(`${url}#project/${projectName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
function printStudioSummary(
projectName: string,
url: string,
opts: { details?: string[]; footer?: string } = {},
): void {
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
for (const detail of opts.details ?? []) {
console.log(` ${c.dim(detail)}`);
}
if (opts.details?.length && opts.footer) console.log();
if (opts.footer) console.log(` ${c.dim(opts.footer)}`);
console.log();
}
function linkProjectIntoStudioData(
dir: string,
projectsDir: string,
projectName: string,
): { symlinkPath: string; createdSymlink: boolean } {
const symlinkPath = join(projectsDir, projectName);
mkdirSync(projectsDir, { recursive: true });
let createdSymlink = false;
@@ -227,24 +674,88 @@ async function runDevMode(
if (existsSync(symlinkPath)) {
try {
const stat = lstatSync(symlinkPath);
if (stat.isSymbolicLink()) {
const target = readlinkSync(symlinkPath);
if (resolve(target) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
if (stat.isSymbolicLink() && resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
// If it's a real directory, leave it alone
} catch {
// Not a symlink — don't touch it
// Real directories or unreadable paths are left untouched.
}
}
if (!existsSync(symlinkPath)) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}
return { symlinkPath, createdSymlink };
}
function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void {
if (!createdSymlink) return;
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
});
}
function registerChildTreeShutdown(child: StudioChildProcess): void {
const shutdown = (): void => {
if (child.pid) killProcessTree(child.pid);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
function waitForChildClose(child: StudioChildProcess): Promise<void> {
return new Promise<void>((resolveClose) => {
child.on("close", () => resolveClose());
});
}
function attachStudioReadyHandler(
child: StudioChildProcess,
spinner: ReturnType<typeof clack.spinner>,
projectName: string,
options?: BrowserLaunchOptions,
): void {
let detected = false;
function handleOutput(data: Buffer): void {
const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1];
if (!url || detected) return;
detected = true;
spinner.stop(c.success("Studio running"));
printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
openStudioBrowser(url, projectName, options);
child.stdout.removeListener("data", handleOutput);
child.stderr.removeListener("data", handleOutput);
}
child.stdout.on("data", handleOutput);
child.stderr.on("data", handleOutput);
child.on("error", (err) => {
spinner.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
}
/**
* Dev mode: spawn the studio dev server from the monorepo.
*/
async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
// Find monorepo root by navigating from packages/cli/src/commands/
const thisFile = fileURLToPath(import.meta.url);
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
// Symlink project into the studio's data directory
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
const pName = options?.projectName ?? basename(dir);
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
clack.intro(c.bold("hyperframes preview"));
const s = clack.spinner();
@@ -257,55 +768,8 @@ async function runDevMode(
stdio: ["ignore", "pipe", "pipe"],
});
let frontendUrl = "";
function handleOutput(data: Buffer): void {
const text = data.toString();
// Detect Vite URL
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch && !frontendUrl) {
frontendUrl = localMatch[1] ?? "";
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
if (!options?.noOpen) {
const urlToOpen = `${frontendUrl}#project/${pName}`;
openBrowser(urlToOpen, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
child.stdout?.removeListener("data", handleOutput);
child.stderr?.removeListener("data", handleOutput);
}
}
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
// If child exits before we detect readiness, show what we have
child.on("error", (err) => {
s.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
if (createdSymlink) {
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
});
}
attachStudioReadyHandler(child, s, pName, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);
// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
// SIGINT to the foreground process group (covers the common case), but
@@ -313,15 +777,8 @@ async function runDevMode(
// would survive without explicit cleanup.
// On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
// propagates via the console process group instead.
const shutdown = () => {
if (child.pid) killProcessTree(child.pid);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
return new Promise<void>((resolve) => {
child.on("close", () => resolve());
});
registerChildTreeShutdown(child);
return waitForChildClose(child);
}
/**
@@ -341,37 +798,14 @@ function hasLocalStudio(dir: string): boolean {
* Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
* Provides full Vite HMR and the complete studio experience.
*/
async function runLocalStudioMode(
dir: string,
options?: {
projectName?: string;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
): Promise<void> {
async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
const req = createRequire(join(dir, "package.json"));
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
const pName = options?.projectName ?? basename(dir);
// Symlink project into studio's data directory
const projectsDir = join(studioPkgPath, "data", "projects");
const symlinkPath = join(projectsDir, pName);
mkdirSync(projectsDir, { recursive: true });
let createdSymlink = false;
if (dir !== symlinkPath) {
if (existsSync(symlinkPath) && lstatSync(symlinkPath).isSymbolicLink()) {
if (resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
}
if (!existsSync(symlinkPath)) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)"));
const s = clack.spinner();
@@ -383,58 +817,12 @@ async function runLocalStudioMode(
stdio: ["ignore", "pipe", "pipe"],
});
let detected = false;
function handleOutput(data: Buffer): void {
const text = data.toString();
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch && !detected) {
detected = true;
const url = localMatch[1] ?? "";
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
if (!options?.noOpen) {
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
}
}
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", (err) => {
s.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
if (createdSymlink) {
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
});
}
attachStudioReadyHandler(child, s, pName, options);
removeSymlinkOnExit(createdSymlink, symlinkPath);
// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
const shutdown = () => {
if (child.pid) killProcessTree(child.pid);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
return new Promise<void>((resolve) => {
child.on("close", () => resolve());
});
registerChildTreeShutdown(child);
return waitForChildClose(child);
}
/**
@@ -447,14 +835,7 @@ async function runLocalStudioMode(
async function runEmbeddedMode(
dir: string,
startPort: number,
options?: {
projectName?: string;
forceNew?: boolean;
noOpen?: boolean;
browserPath?: string;
userDataDir?: string;
remoteDebuggingPort?: number;
},
options?: EmbeddedStudioOptions,
): Promise<void> {
const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
await import("../server/studioServer.js");
@@ -504,21 +885,10 @@ async function runEmbeddedMode(
if (result.type === "already-running") {
const url = `http://localhost:${result.port}`;
s.stop(c.success("Already running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(
` ${c.dim("Reusing existing server. Use --force-new to start a fresh instance.")}`,
);
console.log();
if (!options?.noOpen) {
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
printStudioSummary(pName, url, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
openStudioBrowser(url, pName, options);
return;
}
@@ -529,21 +899,14 @@ async function runEmbeddedMode(
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
console.log();
}
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Edit with your AI agent — it has HyperFrames skills installed.")}`);
console.log(` ${c.dim("Changes reload automatically in the studio.")}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
if (!options?.noOpen) {
openBrowser(`${url}#project/${pName}`, {
browserPath: options?.browserPath,
userDataDir: options?.userDataDir,
remoteDebuggingPort: options?.remoteDebuggingPort,
});
}
printStudioSummary(pName, url, {
details: [
"Edit with your AI agent — it has HyperFrames skills installed.",
"Changes reload automatically in the studio.",
],
footer: "Press Ctrl+C to stop",
});
openStudioBrowser(url, pName, options);
// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
// HTTP server keeps handles open, so the event loop stays alive after the
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from "vitest";
import { resolve } from "node:path";
import {
AmbiguousPreviewServerError,
fetchStudioLint,
fetchStudioSelection,
studioApiUrl,
findPreviewServerForProject,
PreviewServerPortMismatchError,
studioSelectionUrl,
} from "./studioSelectionClient";
import type { ActiveServer } from "../server/portUtils";
const servers: ActiveServer[] = [
{
port: 3002,
projectName: "other",
projectDir: "/tmp/other",
version: "0.7.17",
pid: null,
},
{
port: 3003,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "0.7.17",
pid: "123",
},
];
function mockProjectsFetch(port = 5190): typeof fetch {
return vi.fn(async (url: string | URL | Request) => {
expect(String(url)).toBe(`http://127.0.0.1:${port}/api/projects`);
return new Response(
JSON.stringify({
projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
}
describe("studioSelectionClient", () => {
it("finds the active preview server for a project directory", async () => {
const scan = vi.fn(async () => servers);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan);
expect(server?.port).toBe(3003);
expect(scan).toHaveBeenCalledWith(3002);
});
it("matches by project directory when multiple projects are open", async () => {
const scan = vi.fn(async () => [
...servers,
{
port: 3004,
projectName: "third",
projectDir: "/tmp/third",
version: "0.7.17",
pid: null,
},
]);
const server = await findPreviewServerForProject(resolve("/tmp/third"), 3002, scan);
expect(server?.port).toBe(3004);
});
it("rejects ambiguous duplicate servers for the same project", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan),
).rejects.toMatchObject({
name: "AmbiguousPreviewServerError",
ports: [3003, 3004],
} satisfies Partial<AmbiguousPreviewServerError>);
});
it("uses an explicit preferred port to disambiguate duplicate project servers", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, undefined, {
preferredPort: 3004,
});
expect(server?.port).toBe(3004);
});
it("rejects an explicit preferred port that does not match the only project server", async () => {
const scan = vi.fn(async () => [servers[1]!]);
const fetchImpl = vi.fn(async () => new Response("missing", { status: 404 }));
await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 3999,
}),
).rejects.toMatchObject({
name: "PreviewServerPortMismatchError",
requestedPort: 3999,
ports: [3003],
} satisfies Partial<PreviewServerPortMismatchError>);
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:3999/api/projects");
});
it("falls back to Vite Studio project discovery on port 5190", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch();
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);
expect(server).toEqual({
port: 5190,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "studio-dev",
pid: null,
});
});
it("checks an explicit preferred port for Vite Studio discovery", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch(5191);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 5191,
});
expect(server?.port).toBe(5191);
expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects");
});
it("builds a URL to the existing preview server's selection endpoint", () => {
expect(studioSelectionUrl(servers[1]!)).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/selection",
);
});
it("builds URLs to other preview server API routes", () => {
expect(studioApiUrl(servers[1]!, "lint")).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/lint",
);
});
it("fetches the current selection snapshot from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
selection: {
schemaVersion: 1,
projectId: "demo project",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 2,
target: { hfId: "cta" },
label: "CTA",
tagName: "button",
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
textContent: "Go",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: { canSelect: true },
thumbnailUrl: "/api/projects/demo%20project/thumbnail/index.html?t=2&format=png",
},
updatedAt: "2026-06-28T16:00:00.000Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await fetchStudioSelection(servers[1]!, fetchImpl);
expect(result.selection?.target.hfId).toBe("cta");
expect(result.updatedAt).toBe("2026-06-28T16:00:00.000Z");
expect(fetchImpl).toHaveBeenCalledWith(studioSelectionUrl(servers[1]!));
});
it("throws when the preview server returns a failed response", async () => {
await expect(
fetchStudioSelection(
servers[1]!,
vi.fn(async () => new Response("missing", { status: 404 })),
),
).rejects.toThrow("selection endpoint returned 404");
});
it("fetches lint findings from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
findings: [{ severity: "error", message: "Missing timeline", file: "index.html" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await fetchStudioLint(servers[1]!, fetchImpl);
expect(result.findings).toHaveLength(1);
expect(result.findings[0]?.message).toBe("Missing timeline");
expect(fetchImpl).toHaveBeenCalledWith(studioApiUrl(servers[1]!, "lint"));
});
});
@@ -0,0 +1,149 @@
import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import type {
LintResult,
ResolvedProject,
StudioSelectionResponse,
} from "@hyperframes/studio-server";
export type StudioLintResponse = LintResult;
const VITE_STUDIO_DISCOVERY_PORTS = [5190] as const;
interface StudioProjectsResponse {
projects?: ResolvedProject[];
}
interface FindPreviewServerOptions {
preferredPort?: number;
}
export class AmbiguousPreviewServerError extends Error {
readonly ports: number[];
constructor(servers: ActiveServer[]) {
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
super(
`Multiple Studio preview servers match this project (${ports.join(", ")}). Pass --port <port> to choose one.`,
);
this.name = "AmbiguousPreviewServerError";
this.ports = ports;
}
}
export class PreviewServerPortMismatchError extends Error {
readonly requestedPort: number;
readonly ports: number[];
constructor(requestedPort: number, servers: ActiveServer[]) {
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
super(
`No Studio preview server for this project is running on port ${requestedPort}. Matching server port${ports.length === 1 ? "" : "s"}: ${ports.join(", ")}. Rerun with --port ${ports[0]}${ports.length > 1 ? " or omit --port to see all candidates" : ""}.`,
);
this.name = "PreviewServerPortMismatchError";
this.requestedPort = requestedPort;
this.ports = ports;
}
}
function normalizePath(path: string): string {
const resolved = resolve(path);
try {
if (existsSync(resolved)) {
return realpathSync(resolved).replace(/\\/g, "/").toLowerCase();
}
} catch {
// Fall through to resolved-path normalization.
}
return resolved.replace(/\\/g, "/").toLowerCase();
}
export async function findPreviewServerForProject(
projectDir: string,
startPort = 3002,
scan: (startPort?: number) => Promise<ActiveServer[]> = scanActiveServers,
fetchImpl: typeof fetch = fetch,
options: FindPreviewServerOptions = {},
): Promise<ActiveServer | null> {
const normalizedProjectDir = normalizePath(projectDir);
const servers = await scan(startPort);
const embeddedServers = servers.filter(
(server) => normalizePath(server.projectDir) === normalizedProjectDir,
);
if (options.preferredPort !== undefined) {
const preferred = embeddedServers.find((server) => server.port === options.preferredPort);
if (preferred) return preferred;
const viteServer = await findViteStudioServerForProject(normalizedProjectDir, fetchImpl, [
options.preferredPort,
]);
if (viteServer) return viteServer;
if (embeddedServers.length > 0) {
throw new PreviewServerPortMismatchError(options.preferredPort, embeddedServers);
}
return null;
}
if (embeddedServers.length === 1) return embeddedServers[0]!;
if (embeddedServers.length > 1) throw new AmbiguousPreviewServerError(embeddedServers);
return findViteStudioServerForProject(normalizedProjectDir, fetchImpl);
}
export function studioSelectionUrl(server: ActiveServer): string {
return studioApiUrl(server, "selection");
}
export function studioApiUrl(server: ActiveServer, route: string): string {
return `http://127.0.0.1:${server.port}/api/projects/${encodeURIComponent(server.projectName)}/${route}`;
}
async function findViteStudioServerForProject(
normalizedProjectDir: string,
fetchImpl: typeof fetch,
ports: readonly number[] = VITE_STUDIO_DISCOVERY_PORTS,
): Promise<ActiveServer | null> {
for (const port of ports) {
try {
const response = await fetchImpl(`http://127.0.0.1:${port}/api/projects`);
if (!response.ok) continue;
const payload = (await response.json()) as StudioProjectsResponse;
const project = payload.projects?.find(
(candidate) => normalizePath(candidate.dir) === normalizedProjectDir,
);
if (!project) continue;
return {
port,
projectName: project.id,
projectDir: project.dir,
version: "studio-dev",
pid: null,
};
} catch {
// Port is not a Vite-served Studio, or the dev server is not reachable.
}
}
return null;
}
export async function fetchStudioSelection(
server: ActiveServer,
fetchImpl: typeof fetch = fetch,
): Promise<StudioSelectionResponse> {
const url = studioSelectionUrl(server);
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`selection endpoint returned ${response.status}`);
}
return (await response.json()) as StudioSelectionResponse;
}
export async function fetchStudioLint(
server: ActiveServer,
fetchImpl: typeof fetch = fetch,
): Promise<StudioLintResponse> {
const url = studioApiUrl(server, "lint");
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`lint endpoint returned ${response.status}`);
}
return (await response.json()) as StudioLintResponse;
}
@@ -10,6 +10,7 @@ import { registerThumbnailRoutes } from "./routes/thumbnail.js";
import { registerWaveformRoutes } from "./routes/waveform.js";
import { registerFontRoutes } from "./routes/fonts.js";
import { registerRegistryRoutes } from "./routes/registry.js";
import { registerSelectionRoutes } from "./routes/selection.js";
/**
* Create a Hono sub-app with all studio API routes.
@@ -27,6 +28,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerLintRoutes(api, adapter);
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
registerSelectionRoutes(api, adapter);
registerWaveformRoutes(api, adapter);
registerFontRoutes(api);
registerRegistryRoutes(api, adapter);
+9 -1
View File
@@ -1,6 +1,14 @@
export { createStudioApi } from "./createStudioApi.js";
export { createProjectSignature } from "./helpers/projectSignature.js";
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js";
export type {
StudioApiAdapter,
ResolvedProject,
RenderJobState,
LintResult,
StudioSelectionResponse,
StudioSelectionSnapshot,
StudioSelectionTextField,
} from "./types.js";
export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { Hono } from "hono";
import { registerSelectionRoutes } from "./selection";
import type { StudioApiAdapter, StudioSelectionSnapshot } from "../types";
function createAdapter(): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) =>
id === "demo" ? { id, dir: "/tmp/demo", title: "Demo" } : null,
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
};
}
const selection = {
schemaVersion: 1,
projectId: "demo",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 1.25,
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
label: "Hero title",
tagName: "h1",
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
textContent: "Launch faster",
dataAttributes: { "data-hf-id": "hero-title" },
inlineStyles: { color: "white" },
computedStyles: { "font-size": "48px" },
textFields: [
{
key: "self",
label: "Text",
value: "Launch faster",
tagName: "h1",
source: "self",
},
],
capabilities: { canSelect: true, canEditStyles: true },
thumbnailUrl:
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
} satisfies StudioSelectionSnapshot;
describe("registerSelectionRoutes", () => {
it("stores and returns the latest Studio selection snapshot", async () => {
const app = new Hono();
registerSelectionRoutes(app, createAdapter());
const put = await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection }),
});
expect(put.status).toBe(200);
const response = await app.request("http://localhost/projects/demo/selection");
const payload = (await response.json()) as {
selection?: StudioSelectionSnapshot | null;
updatedAt?: string | null;
};
expect(response.status).toBe(200);
expect(payload.selection).toMatchObject({
projectId: "demo",
sourceFile: "index.html",
target: { hfId: "hero-title" },
thumbnailUrl: expect.stringContaining("/thumbnail/index.html"),
});
expect(payload.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("clears a stored selection when Studio posts null", async () => {
const app = new Hono();
registerSelectionRoutes(app, createAdapter());
await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection }),
});
const clear = await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection: null }),
});
expect(clear.status).toBe(200);
const response = await app.request("http://localhost/projects/demo/selection");
const payload = (await response.json()) as {
selection?: StudioSelectionSnapshot | null;
updatedAt?: string | null;
};
expect(payload.selection).toBeNull();
expect(payload.updatedAt).toBeNull();
});
it("rejects malformed selection payloads", async () => {
const app = new Hono();
registerSelectionRoutes(app, createAdapter());
const response = await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection: { projectId: "demo" } }),
});
expect(response.status).toBe(400);
});
});
@@ -0,0 +1,141 @@
import type { Hono } from "hono";
import type {
StudioApiAdapter,
StudioSelectionResponse,
StudioSelectionSnapshot,
} from "../types.js";
interface StoredSelection {
selection: StudioSelectionSnapshot;
updatedAt: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
}
function hasString(value: Record<string, unknown>, key: string): boolean {
return typeof value[key] === "string";
}
function hasRequiredStrings(value: Record<string, unknown>, keys: string[]): boolean {
return keys.every((key) => hasString(value, key));
}
function hasOptionalString(value: Record<string, unknown>, key: string): boolean {
return value[key] === undefined || typeof value[key] === "string";
}
function hasOptionalNullableString(value: Record<string, unknown>, key: string): boolean {
return value[key] == null || typeof value[key] === "string";
}
function hasOptionalNumber(value: Record<string, unknown>, key: string): boolean {
return value[key] === undefined || isFiniteNumber(value[key]);
}
function isBoundingBox(value: unknown): value is StudioSelectionSnapshot["boundingBox"] {
return (
isRecord(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]))
);
}
function isTarget(value: unknown): value is StudioSelectionSnapshot["target"] {
if (!isRecord(value)) return false;
return (
hasOptionalNullableString(value, "id") &&
hasOptionalString(value, "hfId") &&
hasOptionalString(value, "selector") &&
hasOptionalNumber(value, "selectorIndex")
);
}
function isTextField(value: unknown): value is StudioSelectionSnapshot["textFields"][number] {
return (
isRecord(value) &&
hasRequiredStrings(value, ["key", "label", "value", "tagName"]) &&
["self", "child", "text-node"].includes(value.source as string)
);
}
function isTextFields(value: unknown): value is StudioSelectionSnapshot["textFields"] {
return Array.isArray(value) && value.every(isTextField);
}
function isSelectionSnapshot(value: unknown): value is StudioSelectionSnapshot {
if (!isRecord(value)) return false;
const checks = [
value.schemaVersion === 1 &&
hasRequiredStrings(value, [
"projectId",
"compositionPath",
"sourceFile",
"label",
"tagName",
"thumbnailUrl",
]),
isFiniteNumber(value.currentTime),
isTarget(value.target),
isBoundingBox(value.boundingBox),
value.textContent === null || typeof value.textContent === "string",
isStringRecord(value.dataAttributes),
isStringRecord(value.inlineStyles),
isStringRecord(value.computedStyles),
isTextFields(value.textFields),
isRecord(value.capabilities),
];
return checks.every(Boolean);
}
export function registerSelectionRoutes(api: Hono, adapter: StudioApiAdapter): void {
const selections = new Map<string, StoredSelection>();
api.get("/projects/:id/selection", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const stored = selections.get(project.id);
return c.json({
selection: stored?.selection ?? null,
updatedAt: stored?.updatedAt ?? null,
} satisfies StudioSelectionResponse);
});
api.put("/projects/:id/selection", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: "invalid json" }, 400);
}
if (!isRecord(body) || !("selection" in body)) {
return c.json({ error: "missing selection" }, 400);
}
if (body.selection === null) {
selections.delete(project.id);
return c.json({ ok: true, selection: null, updatedAt: null });
}
if (!isSelectionSnapshot(body.selection)) {
return c.json({ error: "invalid selection" }, 400);
}
const selection = { ...body.selection, projectId: project.id };
const updatedAt = new Date().toISOString();
selections.set(project.id, { selection, updatedAt });
return c.json({ ok: true, selection, updatedAt });
});
}
+37
View File
@@ -29,6 +29,43 @@ export interface LintResult {
}>;
}
export interface StudioSelectionTextField {
key: string;
label: string;
value: string;
tagName: string;
source: "self" | "child" | "text-node";
}
export interface StudioSelectionSnapshot {
schemaVersion: 1;
projectId: string;
compositionPath: string;
sourceFile: string;
currentTime: number;
target: {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
};
label: string;
tagName: string;
boundingBox: { x: number; y: number; width: number; height: number };
textContent: string | null;
dataAttributes: Record<string, string>;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
textFields: StudioSelectionTextField[];
capabilities: Record<string, boolean | string | undefined>;
thumbnailUrl: string;
}
export interface StudioSelectionResponse {
selection: StudioSelectionSnapshot | null;
updatedAt: string | null;
}
/**
* Adapter interface injected by each consumer to handle host-specific behavior.
* The shared API module calls these methods; each host (vite dev, CLI embedded)
+2 -1
View File
@@ -83,7 +83,7 @@ export function StudioApp() {
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
const [compositionLoading, setCompositionLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [, setPreviewDocumentVersion] = useState(0);
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
const activeCompPathRef = useRef(activeCompPath);
@@ -296,6 +296,7 @@ export function StudioApp() {
projectIdRef: fileManager.projectIdRef,
previewIframe,
refreshKey,
previewDocumentVersion,
rightPanelTab: panelLayout.rightPanelTab,
applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
@@ -18,6 +18,7 @@ import { useGsapScriptCommits } from "./useGsapScriptCommits";
import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring";
import { useGsapAwareEditing } from "./useGsapAwareEditing";
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
// ── Types ──
@@ -54,6 +55,7 @@ export interface UseDomEditSessionParams {
projectIdRef: React.MutableRefObject<string | null>;
previewIframe: HTMLIFrameElement | null;
refreshKey: number;
previewDocumentVersion: number;
rightPanelTab: RightPanelTab;
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
@@ -96,6 +98,7 @@ export function useDomEditSession({
projectIdRef,
previewIframe,
refreshKey,
previewDocumentVersion,
rightPanelTab,
applyStudioManualEditsToPreviewRef,
syncPreviewHistoryHotkey,
@@ -168,6 +171,15 @@ export function useDomEditSession({
domEditSelection,
});
useStudioSelectionPublisher({
projectId,
domEditSelection,
domEditSelectionRef,
refreshKey,
previewDocumentVersion,
refreshDomEditSelectionFromPreview,
});
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
+4 -1
View File
@@ -381,7 +381,10 @@ export function useDomSelection({
if (!doc) return;
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) return;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
}
const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) {
@@ -0,0 +1,102 @@
import { useEffect, useRef, type MutableRefObject } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import { usePlayerStore } from "../player";
import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
import { trackStudioEvent } from "../utils/studioTelemetry";
interface UseStudioSelectionPublisherParams {
projectId: string | null;
domEditSelection: DomEditSelection | null;
domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
refreshKey: number;
previewDocumentVersion: number;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
}
function reportSelectionPublishError(error: unknown): void {
if (error instanceof Error && error.name === "AbortError") return;
const errorName = error instanceof Error ? error.name : typeof error;
const errorMessage = error instanceof Error ? error.message : String(error);
trackStudioEvent("studio_selection_publish_failed", {
error_name: errorName,
error_message: errorMessage.slice(0, 500),
});
// eslint-disable-next-line no-console
console.warn("[Studio] Failed to update agent selection context", error);
}
function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection }),
signal,
}).then(() => undefined);
}
export function useStudioSelectionPublisher({
projectId,
domEditSelection,
domEditSelectionRef,
refreshKey,
previewDocumentVersion,
refreshDomEditSelectionFromPreview,
}: UseStudioSelectionPublisherParams): void {
const lastSelectionRefreshKeyRef = useRef(refreshKey);
const pendingSelectionRefreshKeyRef = useRef<number | null>(null);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!projectId) return;
const selection = domEditSelection?.element.isConnected
? buildStudioSelectionSnapshot({
projectId,
selection: domEditSelection,
currentTime: usePlayerStore.getState().currentTime,
})
: null;
const controller = new AbortController();
void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
return () => controller.abort();
}, [domEditSelection, projectId]);
// Clear server-side agent context when Studio leaves a project. Without this,
// a long-running multi-project preview server can keep serving the last
// selected element for a project after its tab/session unmounts.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!projectId) return;
return () => {
void putSelection(projectId, null).catch(reportSelectionPublishError);
};
}, [projectId]);
// On external file edits, the iframe reloads while React keeps the previous
// DOM selection object alive. Clear the agent-facing snapshot immediately so
// `preview --context` never serves a detached or stale target, then let the
// post-load preview document refresh below re-resolve the selection if it
// still exists in the new document.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (lastSelectionRefreshKeyRef.current === refreshKey) return;
lastSelectionRefreshKeyRef.current = refreshKey;
pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
if (!projectId || !domEditSelectionRef.current) return;
const controller = new AbortController();
void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
return () => controller.abort();
}, [domEditSelectionRef, projectId, refreshKey]);
// `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
// Consume one pending refresh per external reload: enough to re-resolve the
// selected element once the new document is queryable, without republishing
// the same snapshot on every follow-up 80/300ms tick.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (pendingSelectionRefreshKeyRef.current === null) return;
pendingSelectionRefreshKeyRef.current = null;
const selection = domEditSelectionRef.current;
if (!selection) return;
void refreshDomEditSelectionFromPreview(selection);
}, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]);
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot";
import type { DomEditSelection } from "../components/editor/domEditing";
describe("buildStudioSelectionSnapshot", () => {
it("serializes a DOM edit selection without the live HTMLElement", () => {
const selection = {
element: { tagName: "H1" } as HTMLElement,
id: null,
hfId: "hero-title",
selector: ".title",
selectorIndex: 0,
label: "Hero title",
tagName: "h1",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
textContent: "Launch faster",
dataAttributes: { "data-hf-id": "hero-title" },
inlineStyles: { color: "white" },
computedStyles: { "font-size": "48px" },
textFields: [],
capabilities: { canSelect: true, canEditStyles: true },
} as DomEditSelection;
const snapshot = buildStudioSelectionSnapshot({
projectId: "demo",
selection,
currentTime: 1.25,
});
expect(snapshot).toMatchObject({
schemaVersion: 1,
projectId: "demo",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 1.25,
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
thumbnailUrl:
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
});
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
expect(snapshot).not.toHaveProperty("element");
});
});
@@ -0,0 +1,72 @@
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
import type { DomEditSelection } from "../components/editor/domEditing";
function round3(value: number): number {
return Math.round(value * 1000) / 1000;
}
function thumbnailUrl({
projectId,
selection,
currentTime,
}: {
projectId: string;
selection: DomEditSelection;
currentTime: number;
}): string {
const compPath = encodeURIComponent(
selection.compositionPath || selection.sourceFile || "index.html",
);
const params = new URLSearchParams({
t: String(round3(currentTime)),
format: "png",
});
if (selection.selector) params.set("selector", selection.selector);
if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex));
return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`;
}
export function buildStudioSelectionSnapshot({
projectId,
selection,
currentTime,
}: {
projectId: string;
selection: DomEditSelection;
currentTime: number;
}): StudioSelectionSnapshot {
return {
schemaVersion: 1,
projectId,
compositionPath: selection.compositionPath,
sourceFile: selection.sourceFile,
currentTime: round3(currentTime),
target: {
id: selection.id,
hfId: selection.hfId,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
},
label: selection.label,
tagName: selection.tagName,
boundingBox: {
x: round3(selection.boundingBox.x),
y: round3(selection.boundingBox.y),
width: round3(selection.boundingBox.width),
height: round3(selection.boundingBox.height),
},
textContent: selection.textContent,
dataAttributes: { ...selection.dataAttributes },
inlineStyles: { ...selection.inlineStyles },
computedStyles: { ...selection.computedStyles },
textFields: selection.textFields.map((field) => ({
key: field.key,
label: field.label,
value: field.value,
tagName: field.tagName,
source: field.source,
})),
capabilities: { ...selection.capabilities },
thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }),
};
}