Files
hyperframes/packages/studio/src/hooks/useStudioSelectionPublisher.ts
T
Miguel Ángel 9983f37c13 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.
2026-06-28 15:15:57 -04:00

103 lines
4.4 KiB
TypeScript

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]);
}