import { defineAction } from "@agent-native/core"; import { agentEnterDocument, agentLeaveDocument, agentUpdateSelection, } from "@agent-native/core/collab"; 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"; import { applyVisualEdit, type AutoLayoutEditIntent, type ClassEditIntent, type CodeLayerSource, type EditIntent, type UnwrapEditIntent, type WrapNodesEditIntent, } from "../shared/code-layer.js"; import { agentSelectionDescriptor } from "../shared/collab-selection.js"; import type { TailwindBreakpointPrefix } from "../shared/design-state.js"; import { planLocalJsxVisualEdit, type LocalJsxLeafIntent, } from "../shared/local-jsx-visual-edit.js"; import { breakpointUpperBoundPx, planBreakpointStyleWrite, utilityStem, widthToPrefix, } from "../shared/responsive-classes.js"; import readLocalFileAction from "./read-local-file.js"; import writeLocalFileAction from "./write-local-file.js"; /** * Short human-readable label describing an edit intent, shown next to the * agent's selection ring for live viewers (e.g. "AI — Editing text"). */ function editIntentLabel(intent: EditIntent): string { switch (intent.kind) { case "textContent": return "Editing text"; case "style": return "Editing style"; case "class": case "responsive-class": case "breakpoint-style": return "Editing styles"; case "moveNode": return "Moving element"; case "wrapNodes": return "Grouping elements"; case "unwrap": return "Ungrouping elements"; case "autoLayout": return "Editing layout"; default: return "Editing element"; } } type VisualEditActionSource = CodeLayerSource & { html?: string }; /** Tailwind responsive prefix values accepted by the action. */ const TAILWIND_PREFIXES = ["base", "sm", "md", "lg", "xl", "2xl"] as const; /** * Resolve the active breakpoint prefix for a class edit. * * - If `activeBreakpoint` is provided it is used directly. * - If only `activeFrameWidthPx` is provided the prefix is derived via `widthToPrefix`. * - If neither is provided the result is `null` (= no breakpoint scoping; global * class edit, current backward-compatible behaviour). */ function resolveActivePrefix( activeBreakpoint?: TailwindBreakpointPrefix | null, activeFrameWidthPx?: number | null, ): TailwindBreakpointPrefix | null { if (activeBreakpoint != null) return activeBreakpoint; if (activeFrameWidthPx != null) return widthToPrefix(activeFrameWidthPx); return null; } /** * Derive a CSS-property key from a Tailwind class token for use in * `responsive-class` `"remove"` operations (e.g. `"text-lg"` → `"font-size"`). * * Delegates to the shared `utilityStem` so the key matches EXACTLY what * `setPropertyClass`/`removePropertyClass` compute internally — a divergent * local heuristic would make breakpoint-scoped removes silently miss (and, with * the old first-segment heuristic, nuke unrelated utilities like `text-center`). */ function stemFromToken(token: string): string { // Strip any responsive prefix (e.g. "md:text-sm" → "text-sm"). const prefixMatch = /^(?:2xl|xl|lg|md|sm):/.exec(token); const utility = prefixMatch ? token.slice(prefixMatch[0].length) : token; return utilityStem(utility); } /** * Convert a global `ClassEditIntent` into the equivalent `EditIntent` scoped to * the given breakpoint prefix. * * - `"add"` and `"replace"` become `"responsive-class"` edits that write / * replace the utility at the target prefix. * - `"remove"` becomes a `"responsive-class"` remove that strips the utility * stem at the target prefix. * - `"set"` has no direct per-breakpoint analog (it replaces the whole class * list) and is passed through unchanged so existing behaviour is preserved. * * When `prefix` is `"base"`, the intent is returned unchanged because * `setPropertyClass(className, "base", utility)` is equivalent to a * global unprefixed add/replace and the existing `"class"` path already * handles it correctly. */ function scopeClassIntentToBreakpoint( intent: ClassEditIntent, prefix: TailwindBreakpointPrefix, ): EditIntent { if (prefix === "base") return intent; if (intent.operation === "add") { const tokens = intent.classNames ?? (intent.className ? [intent.className] : []); if (tokens.length !== 1 || !tokens[0]) return intent; return { kind: "responsive-class", target: intent.target, prefix, operation: "add", utility: tokens[0], }; } if (intent.operation === "replace") { if (!intent.to) return intent; return { kind: "responsive-class", target: intent.target, prefix, operation: "replace", utility: intent.to, from: intent.from, }; } if (intent.operation === "remove") { const tokens = intent.classNames ?? (intent.className ? [intent.className] : []); if (tokens.length !== 1 || !tokens[0]) return intent; return { kind: "responsive-class", target: intent.target, prefix, operation: "remove", stem: stemFromToken(tokens[0]), }; } // "set" — no per-breakpoint analog; fall back to global class edit. return intent; } /** * Convert a `class` or `style` intent into the equivalent Framer-scoped edit * for a desktop-down max-width bound (§6.4 breakpoint bar semantics): * * - `class` add/replace/remove → `responsive-class` with `maxWidthPx` * (writes/removes a `max-[px]:` scoped token). * - `style` → the single class-vs-media decision (`planBreakpointStyleWrite`): * Tailwind-utility values become scoped classes; raw CSS values become * managed `@media (max-width: px)` rules via `breakpoint-style`. * - Everything else passes through unchanged. */ function scopeIntentToFramerBound( intent: EditIntent, maxWidthPx: number, ): EditIntent { if (intent.kind === "class") { if (intent.operation === "add" || intent.operation === "replace") { const tokens = intent.operation === "replace" ? intent.to ? [intent.to] : [] : (intent.classNames ?? (intent.className ? [intent.className] : [])); if (tokens.length !== 1 || !tokens[0]) return intent; return { kind: "responsive-class", target: intent.target, prefix: "base", // ignored when maxWidthPx is set maxWidthPx, operation: intent.operation, utility: tokens[0], }; } if (intent.operation === "remove") { const tokens = intent.classNames ?? (intent.className ? [intent.className] : []); if (tokens.length !== 1 || !tokens[0]) return intent; return { kind: "responsive-class", target: intent.target, prefix: "base", maxWidthPx, operation: "remove", stem: stemFromToken(tokens[0]), }; } // "set" — no per-breakpoint analog. return intent; } if (intent.kind === "style") { const plan = planBreakpointStyleWrite({ property: intent.property, value: intent.value, upperBoundPx: maxWidthPx, }); if (plan.mode === "class") { return { kind: "responsive-class", target: intent.target, prefix: "base", maxWidthPx: plan.boundPx, operation: "replace", utility: plan.utility, }; } if (plan.mode === "media") { return { kind: "breakpoint-style", target: intent.target, maxWidthPx: plan.maxWidthPx, property: plan.property, value: plan.value, operation: "set", }; } return intent; } return intent; } /** * Resolve the Framer desktop-down bound for a design-file edit from the * design's stored breakpoint set (+ the edited screen's primary width). * * - `{ kind: "bound" }` — a wider frame exists; scope below it. * - `{ kind: "base" }` — the active frame IS the widest context; edits * belong to the base layer (Framer semantics). * - `{ kind: "unknown" }` — the design has no breakpoint set; callers fall * back to the legacy min-width prefix behaviour. */ function resolveFramerBoundFromDesignData( designData: string | null, fileId: string, activeFrameWidthPx: number, ): { kind: "bound"; boundPx: number } | { kind: "base" } | { kind: "unknown" } { if (!designData) return { kind: "unknown" }; try { const parsed = JSON.parse(designData) as Record; const rawSet = parsed.breakpointSet as | { breakpoints?: Array<{ widthPx?: unknown }> } | undefined; const widths = Array.isArray(rawSet?.breakpoints) ? rawSet.breakpoints .map((bp) => bp?.widthPx) .filter( (width): width is number => typeof width === "number" && Number.isFinite(width), ) : []; if (widths.length === 0) return { kind: "unknown" }; const metadataByFileId = parsed.screenMetadata as | Record | undefined; const rawScreenWidth = metadataByFileId?.[fileId]?.width; const screenWidthPx = typeof rawScreenWidth === "number" && Number.isFinite(rawScreenWidth) ? rawScreenWidth : null; const boundPx = breakpointUpperBoundPx( widths, activeFrameWidthPx, screenWidthPx, ); return boundPx === null ? { kind: "base" } : { kind: "bound", boundPx }; } catch { return { kind: "unknown" }; } } function parseJsonString(value: unknown): unknown { if (typeof value !== "string") return value; try { return JSON.parse(value); } catch { return value; } } const sourceSchema = z.preprocess( parseJsonString, z .object({ kind: z .enum(["design-file", "inline-html", "local-file", "remote-url"]) .default("design-file"), designId: z.string().optional(), fileId: z.string().optional(), filename: z.string().optional(), path: z.string().optional(), url: z.string().optional(), connectionId: z.string().optional(), revision: z.string().optional(), html: z.string().optional(), }) .superRefine((source, ctx) => { if (source.kind === "design-file" && !source.designId && !source.fileId) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["designId"], message: "designId or fileId is required for design-file sources", }); } if ( source.kind === "local-file" && (!source.designId || !source.connectionId || !source.path) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["path"], message: "designId, connectionId, and path are required for local-file sources", }); } }), ); const targetSchema = z .object({ nodeId: z.string().optional(), selector: z.string().optional(), sourceAnchor: z .object({ line: z.number().int().positive(), column: z.number().int().positive(), runtimeMultiplicity: z.number().int().positive().optional(), scope: z .enum([ "single-instance", "repeated-render", "shared-component-definition", "unknown", ]) .optional(), }) .optional(), }) .superRefine((target, ctx) => { if (!target.nodeId && !target.selector && !target.sourceAnchor) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["nodeId"], message: "target.nodeId, target.selector, or target.sourceAnchor is required", }); } }); const intentSchema = z.preprocess( parseJsonString, z.discriminatedUnion("kind", [ z.object({ kind: z.literal("style"), target: targetSchema, property: z .string() .describe( "CSS property to set. Deterministic edits cover the visual editor's common layout, typography, fill, stroke, effect, transform, and spacing properties.", ), value: z.string().describe("CSS value to write into the inline style."), }), z.object({ kind: z.literal("class"), target: targetSchema, operation: z.enum(["add", "remove", "replace", "set"]), className: z.string().optional(), classNames: z.array(z.string()).optional(), from: z.string().optional(), to: z.string().optional(), }), z.object({ kind: z.literal("breakpoint-style"), target: targetSchema, maxWidthPx: z .number() .int() .positive() .describe( "Inclusive upper viewport bound (px). The declaration persists as a managed '@media (max-width: px)' rule in the