import { defineAction } from "@agent-native/core"; import { readAppStateForCurrentTab } from "@agent-native/core/application-state"; import { accessFilter, assertAccess } from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { readLiveSourceFile, writeInlineSourceFile, type SourceWorkspaceFile, } from "../server/source-workspace.js"; const schemaInput = z.object({ renderUrl: z .string() .url() .refine((value) => { try { const protocol = new URL(value).protocol; return protocol === "http:" || protocol === "https:"; } catch { return false; } }, "Render URL must use http or https.") .describe( "Figma-rendered SVG/PNG URL returned by list-figma-library-assets.", ), fileKey: z.string().describe("Figma file key."), nodeId: z.string().optional().describe("Figma node id for this asset."), componentKey: z.string().optional().describe("Figma component key."), kind: z.enum(["component", "component_set"]).default("component"), name: z.string().optional().describe("Human-readable Figma asset name."), description: z.string().optional().describe("Figma asset description."), sourceUrl: z .string() .url() .optional() .describe("URL back to the Figma node."), designId: z .string() .optional() .describe("Design id. Defaults to the current editor navigation state."), fileId: z .string() .optional() .describe("Design file id. Defaults to the active editor file."), ownerId: z .string() .optional() .describe("Design editor selection owner token from current screen state."), }); function stringFromState(state: unknown, key: string): string | undefined { if (!state || typeof state !== "object") return undefined; const value = (state as Record)[key]; return typeof value === "string" && value ? value : undefined; } function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function createInsertedNodeId(prefix: string): string { const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID().replace(/-/g, "").slice(0, 12) : Math.random().toString(36).slice(2, 14); return `inserted-${prefix}-${random}`; } function insertBeforeClosingTag( html: string, closingTag: "main" | "body", snippet: string, ): string | null { const pattern = new RegExp(``, "i"); if (!pattern.test(html)) return null; return html.replace(pattern, `${snippet}\n`); } function optionalDataAttribute(name: string, value: string | undefined) { return value ? ` ${name}="${escapeHtml(value)}"` : ""; } function appendFigmaAssetMarkup( html: string, args: z.infer, nodeId: string, ): string { const label = args.name?.trim() || "Figma library asset"; const description = args.description?.trim(); const sourceLink = args.sourceUrl ? `Open in Figma` : ""; const snippet = `
${escapeHtml(label)}
${escapeHtml(label)} ${description ? `${escapeHtml(description)}` : ""} ${sourceLink}
`; return ( insertBeforeClosingTag(html, "main", snippet) ?? insertBeforeClosingTag(html, "body", snippet) ?? `${html}\n${snippet}` ); } async function resolveTarget(args: z.infer) { const [navigation, selection] = await Promise.all([ readAppStateForCurrentTab("navigation").catch(() => null), readAppStateForCurrentTab("design-selection").catch(() => null), ]); const navigationDesignId = stringFromState(navigation, "designId"); const selectionDesignId = stringFromState(selection, "designId"); const selectionOwnerId = stringFromState(selection, "ownerId"); const selectionMatchesOwner = Boolean(args.ownerId) && selectionOwnerId === args.ownerId; const designId = args.designId ?? (selectionMatchesOwner ? selectionDesignId : undefined) ?? navigationDesignId; const canUseSelection = selectionMatchesOwner && Boolean(designId) && selectionDesignId === designId; const navigationActiveFileId = designId && navigationDesignId === designId ? stringFromState(navigation, "activeFileId") : undefined; return { designId, fileId: args.fileId ?? (canUseSelection ? stringFromState(selection, "activeFileId") : undefined) ?? navigationActiveFileId, }; } function isHtmlFile(file: { fileType: string | null; filename: string | null; }): boolean { return file.fileType === "html" || file.filename?.endsWith(".html") === true; } export default defineAction({ description: "Insert a rendered Figma component or component set into a Design file, preserving Figma file/node/component provenance. Use list-figma-library-assets first to get renderUrl and metadata.", schema: schemaInput, publicAgent: { expose: true, readOnly: false, requiresAuth: true }, run: async (args) => { const target = await resolveTarget(args); if (!target.designId) { throw new Error( "No active design found. Open a design or pass designId.", ); } const db = getDb(); const files = await db .select({ id: schema.designFiles.id, designId: schema.designFiles.designId, filename: schema.designFiles.filename, fileType: schema.designFiles.fileType, content: schema.designFiles.content, }) .from(schema.designFiles) .innerJoin( schema.designs, eq(schema.designFiles.designId, schema.designs.id), ) .where( and( eq(schema.designFiles.designId, target.designId), accessFilter(schema.designs, schema.designShares), ), ); const requestedFile = files.find( (candidate) => candidate.id === target.fileId, ); const file = requestedFile && isHtmlFile(requestedFile) ? requestedFile : (files.find(isHtmlFile) ?? null); if (!file) throw new Error("No editable HTML design file found."); await assertAccess("design", file.designId, "editor"); // Read the LIVE base (collab text when present, else the SQL row) right // before transforming, and carry its versionHash through to the write // below. writeInlineSourceFile re-reads the live text immediately before // its own applyText/DB write and rejects if it no longer matches this // hash — closing the race window where a concurrent editor/agent write // lands between this read and the persist (the same stale-diff-base bug // fixed for update-file: a diff computed from a stale base, char-diffed // into a collab doc that has since moved on, corrupts or drops the // other writer's change). See update-file.ts and apply-source-edit.ts // for the identical pattern. writeInlineSourceFile/readLiveSourceFile // only ever dereference file.id (and content/filename for the read); the // createdAt/updatedAt fields aren't selected above and aren't needed. const workspaceFile: SourceWorkspaceFile = { id: file.id, designId: file.designId, filename: file.filename ?? "", fileType: file.fileType ?? "html", content: file.content, createdAt: null, updatedAt: null, }; const live = await readLiveSourceFile(workspaceFile); const base = live.content; const insertedNodeId = createInsertedNodeId("figma"); const content = appendFigmaAssetMarkup(base, args, insertedNodeId); await writeInlineSourceFile({ designId: file.designId, file: workspaceFile, content, expectedVersionHash: live.versionHash, }); return { designId: file.designId, fileId: file.id, filename: file.filename, inserted: true, insertedNodeId, insertedSelector: `[data-agent-native-node-id="${insertedNodeId}"]`, source: "figma", fileKey: args.fileKey, nodeId: args.nodeId ?? null, componentKey: args.componentKey ?? null, renderUrl: args.renderUrl, }; }, });