From 767e2e25b2f6b58d0bf8d54507aa0c19eda9874e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 28 Apr 2026 15:31:29 -0400 Subject: [PATCH] fix(studio): copy absolute paths in agent prompts --- .../core/src/studio-api/routes/projects.ts | 2 +- packages/studio/src/App.tsx | 28 ++++++++++++-- .../src/components/editor/domEditing.test.ts | 37 +++++++++++++++++++ .../src/components/editor/domEditing.ts | 5 ++- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/core/src/studio-api/routes/projects.ts b/packages/core/src/studio-api/routes/projects.ts index 8df3104339..738775de84 100644 --- a/packages/core/src/studio-api/routes/projects.ts +++ b/packages/core/src/studio-api/routes/projects.ts @@ -25,6 +25,6 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); const files = walkDir(project.dir); - return c.json({ id: project.id, files }); + return c.json({ id: project.id, dir: project.dir, title: project.title, files }); }); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 0ed1371cbc..4b0e7a9473 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -181,6 +181,23 @@ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): stri return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath; } +function isAbsoluteFilePath(value: string): boolean { + return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value); +} + +function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined { + const trimmedSource = sourceFile.trim(); + if (!trimmedSource) return undefined; + + const normalizedSource = trimmedSource.replace(/\\/g, "/"); + if (isAbsoluteFilePath(normalizedSource)) return normalizedSource; + + const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, ""); + if (!normalizedRoot) return undefined; + + return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`; +} + function ensureImportedFontFace( html: string, asset: ImportedFontAsset, @@ -589,6 +606,7 @@ export function StudioApp() { }); const [editingFile, setEditingFile] = useState(null); + const [projectDir, setProjectDir] = useState(null); const [activeCompPath, setActiveCompPath] = useState(null); const [fileTree, setFileTree] = useState([]); const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); @@ -1094,10 +1112,13 @@ export function StudioApp() { let cancelled = false; fetch(`/api/projects/${projectId}`) .then((r) => r.json()) - .then((data: { files?: string[] }) => { + .then((data: { files?: string[]; dir?: string }) => { if (!cancelled && data.files) setFileTree(data.files); + if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null); }) - .catch(() => {}); + .catch(() => { + if (!cancelled) setProjectDir(null); + }); return () => { cancelled = true; }; @@ -2009,6 +2030,7 @@ export function StudioApp() { currentTime, tagSnippet, userInstruction, + sourceFilePath: toProjectAbsolutePath(projectDir, targetPath), }); try { @@ -2030,7 +2052,7 @@ export function StudioApp() { setCopiedAgentPrompt(true); copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600); }, - [activeCompPath, currentTime, domEditSelection], + [activeCompPath, currentTime, domEditSelection, projectDir], ); const handlePreviewIframeRef = useCallback( diff --git a/packages/studio/src/components/editor/domEditing.test.ts b/packages/studio/src/components/editor/domEditing.test.ts index d29bf72e81..9a7be202cf 100644 --- a/packages/studio/src/components/editor/domEditing.test.ts +++ b/packages/studio/src/components/editor/domEditing.test.ts @@ -467,6 +467,43 @@ describe("patch builders and prompt builder", () => { expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning."); }); + it("uses an absolute source path in copied agent prompts when provided", () => { + const selection = { + element: {} as HTMLElement, + id: "editable-card", + selector: "#editable-card", + selectorIndex: undefined, + sourceFile: "index.html", + compositionPath: "index.html", + compositionSrc: undefined, + isCompositionHost: false, + label: "Drag me first", + tagName: "div", + boundingBox: { x: 108, y: 112, width: 380, height: 196 }, + textContent: "Drag me first", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canMove: true, + canResize: true, + canDetachFromLayout: false, + }, + } satisfies DomEditSelection; + + const prompt = buildElementAgentPrompt({ + selection, + currentTime: 1.25, + sourceFilePath: "/tmp/hf-studio-project/index.html", + }); + + expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html"); + expect(prompt).not.toContain("Source file: index.html"); + }); + it("serializes child text fields back into HTML", () => { expect( serializeDomEditTextFields([ diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts index 51a212a47f..cfbce4673f 100644 --- a/packages/studio/src/components/editor/domEditing.ts +++ b/packages/studio/src/components/editor/domEditing.ts @@ -697,12 +697,15 @@ export function buildElementAgentPrompt({ currentTime, tagSnippet, userInstruction, + sourceFilePath, }: { selection: DomEditSelection; currentTime: number; tagSnippet?: string; userInstruction?: string; + sourceFilePath?: string; }): string { + const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile; const lines = [ "## HyperFrames element edit request v1", "Schema version: 1", @@ -711,7 +714,7 @@ export function buildElementAgentPrompt({ "", `Composition: ${selection.compositionPath}`, `Playback time: ${formatTime(currentTime)}`, - `Source file: ${selection.sourceFile}`, + `Source file: ${displayedSourceFile}`, `DOM id: ${selection.id ?? "(none)"}`, `Selector: ${selection.selector ?? "(none)"}`, `Selector index: ${selection.selectorIndex ?? 0}`,