import { defineAction, embedApp } from "@agent-native/core"; import { fail } from "@agent-native/core/action"; import { deleteAppState, writeAppStateForCurrentTab, } from "@agent-native/core/application-state"; import { seedFromText } from "@agent-native/core/collab"; import { buildDeepLink } from "@agent-native/core/server"; import { accessFilter, assertAccess } from "@agent-native/core/sharing"; import { and, eq, inArray } from "drizzle-orm"; import { nanoid } from "nanoid"; import { z } from "zod"; import "../server/db/index.js"; import { getDb, schema } from "../server/db/index.js"; import { mutateDesignData } from "../server/lib/design-data-mutation.js"; import { trackDesign } from "../server/lib/design-tracking.js"; import { snapshotDesignBeforeAgentEdit } from "../server/lib/design-versions.js"; import { withDesignSourceMutationTransaction } from "../server/source-workspace.js"; import { assertSingleCanvasOutput, resolveCanvasIntent, } from "../shared/canvas-dimensions.js"; import { mergeCanvasFramePlacements, nextFreeCanvasRowY, type CanvasFramePlacement, } from "../shared/canvas-frames.js"; import { getOverviewScreenFileIds } from "../shared/design-files.js"; import { designOutputWriteReceipt } from "../shared/generation-output-receipt.js"; import { assertDesignHtmlWellFormed } from "../shared/html-integrity.js"; import { widthToPrefix } from "../shared/responsive-classes.js"; import { getResponsiveBreakpointWidths, getResponsiveGroupHeight, getResponsiveGroupWidth, MAX_SANE_FRAME_ASPECT_RATIO, MAX_SANE_FRAME_DIMENSION_PX, visibleBreakpointWidths, } from "../shared/responsive-frame-layout.js"; import { annotateAgentScreenHtmlForPersist } from "../shared/screen-annotation.js"; import { hasSpecifiedDesignPrompt } from "../shared/specified-design-prompt.js"; import { withGenerationAttempt } from "./_generation-tracking.js"; const VARIANT_GAP = 96; const MAX_COLUMNS = 3; const MOBILE_WIDTH = 390; const MOBILE_HEIGHT = 844; const TABLET_WIDTH = 768; const TABLET_HEIGHT = 1024; const DESKTOP_WIDTH = 1440; const DESKTOP_HEIGHT = 900; const DEFAULT_RESPONSIVE_BREAKPOINTS = [MOBILE_WIDTH].map((widthPx) => ({ id: `generated-${widthPx}`, label: "Mobile", widthPx, prefix: widthToPrefix(widthPx), })); function isModelVisibleImageAttachment(attachment: { type?: string; name?: string; contentType?: string; displayOnly?: boolean; }): boolean { if (attachment.displayOnly === true) return false; if (attachment.type === "image") return true; const contentType = attachment.contentType?.split(";", 1)[0]?.trim(); if (contentType) return contentType.toLowerCase().startsWith("image/"); return /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(attachment.name ?? ""); } async function hasLinkedDesignSystem(designId: string): Promise { const [design] = await getDb() .select({ designSystemId: schema.designs.designSystemId }) .from(schema.designs) .where( and( eq(schema.designs.id, designId), accessFilter(schema.designs, schema.designShares), ), ); return Boolean(design?.designSystemId?.trim()); } function classifyBreakpointSet( value: unknown, ): "absent" | "malformed" | "present" { if (value === null || value === undefined) return "absent"; if (typeof value !== "object" || Array.isArray(value)) return "malformed"; const breakpoints = (value as { breakpoints?: unknown }).breakpoints; if (breakpoints === undefined) return "malformed"; if (!Array.isArray(breakpoints)) return "malformed"; return breakpoints.length > 0 ? "present" : "malformed"; } function hasBreakpointSet(value: unknown): boolean { return classifyBreakpointSet(value) === "present"; } function designDeepLink(designId: string): string { return buildDeepLink({ app: "design", view: "editor", params: { designId, editorView: "overview" }, to: `/design/${encodeURIComponent(designId)}?editorView=overview`, }); } const FALLBACK_INSTRUCTIONS = "The generated directions have been saved as normal screens on the Design " + "overview board. The chat shows one button per screen. Ask the user to pick " + "a screen by name if the inline buttons are not available; after they pick, " + "delete each other variant screen at most once, call get-design-snapshot with fileId for " + "the kept screen once, then call edit-design on that same fileId in a bounded pass. " + 'Use mode "replace-file" to replace the representative direction screen with ' + "the actual requested product UI; make the result complete but compact and " + "prefer visible controls/affordances over exhaustive content if the request is large. " + "Do not leave a direction board, summary card, or variant brief as the final result. " + "Do not call generate-design after a variant pick."; const VARIANT_PICK_SUBMIT_MESSAGE = "Use this design direction. Keep the selected screen, clean up each other " + "variant screen at most once, read only the kept screen, then update that " + "same screen in one bounded pass into the requested app/product UI. Make it " + "complete but compact: prioritize the primary workflow, and if the full feature " + "list is too large for one reliable edit, render secondary details as visible " + "controls, states, or affordances instead of expanding the action input. " + "The selected screen is only a representative direction; the final saved " + "screen must not be a direction board, variant brief, or summary card. " + "If a cleanup action reports a screen was " + "already missing, continue. Use the exact file ids and tool instructions in " + "the selected answer below. Do not repeat cleanup/read cycles, do not create " + "a new index.html, and stop after the first successful screen update."; const variantSchema = z.object({ id: z.string().min(1).describe("Stable variant id, e.g. 'minimal-focus'"), label: z .string() .min(1) .describe("Short user-facing screen name, e.g. 'One-Line Focus'"), description: z .string() .optional() .describe( "Short visual direction summary. Use this instead of a huge HTML payload when exploring variants quickly.", ), accentColor: z .string() .optional() .describe("Optional CSS color used as this variant's primary accent."), features: z .array(z.string()) .max(8) .optional() .describe("Optional short feature/polish bullets to show in the variant."), content: z .string() .optional() .describe( "Complete self-contained HTML document for this variant. Keep it compact: one representative screen or directional snapshot, not a full multi-screen app. Omit it ONLY for genuinely open-ended exploration, where Design renders a generic direction card from label/description/features — that card ignores any supplied reference, layout, or design system, so omitting content is rejected when the design has a linked design system or the prompt specifies one.", ), width: z .number() .positive() .optional() .describe( "Optional source viewport/artboard width for the overview frame.", ), height: z .number() .positive() .optional() .describe( "Optional source viewport/artboard height for the overview frame.", ), }); interface VariantScreen { id: string; variantId: string; label: string; filename: string; width: number; height: number; } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function isUntouchedVariantSet( rawSet: unknown, ): rawSet is { screens: unknown[]; screenCount: number } & Record< string, unknown > { return ( isRecord(rawSet) && Array.isArray(rawSet.screens) && typeof rawSet.screenCount === "number" && rawSet.screens.length === rawSet.screenCount ); } function collectUnmarkedSupersededCandidateIds( variantSets: Record, ): string[] { const setIds: string[] = []; for (const [setId, rawSet] of Object.entries(variantSets)) { if (!isUntouchedVariantSet(rawSet)) continue; if (isRecord(rawSet) && rawSet.superseded === true) continue; setIds.push(setId); } return setIds; } function screenFileIdsForSet(rawSet: { screens: unknown[] }): string[] { const fileIds = new Set(); for (const screen of rawSet.screens) { const id = isRecord(screen) ? screen.id : typeof screen === "string" ? screen : undefined; if (typeof id === "string") fileIds.add(id); } return Array.from(fileIds); } function pruneKeyedRecordForIds( value: unknown, ids: string[], ): Record { const next = isRecord(value) ? { ...value } : {}; for (const id of ids) delete next[id]; return next; } async function markSupersededVariantSets( designId: string, ): Promise<{ markedSetIds: string[] }> { const db = getDb(); // Cheap early-exit read so the common case (no stale variant set to mark) // never bumps the design's updatedAt via a no-op mutateDesignData call — // mutateDesignDataUnlocked always advances updatedAt on every commit, even // when the payload is unchanged. The authoritative check still runs inside // the mutate() callback below, so a race against this snapshot is harmless. const [designRow] = await db .select({ data: schema.designs.data }) .from(schema.designs) .where( and( eq(schema.designs.id, designId), accessFilter(schema.designs, schema.designShares), ), ); if (!designRow) return { markedSetIds: [] }; let precheckVariantSets: Record = {}; if (designRow.data) { try { const parsed: unknown = JSON.parse(designRow.data); if (isRecord(parsed) && isRecord(parsed.designVariantSets)) { precheckVariantSets = parsed.designVariantSets; } } catch { return { markedSetIds: [] }; } } if (collectUnmarkedSupersededCandidateIds(precheckVariantSets).length === 0) { return { markedSetIds: [] }; } let markedSetIds: string[] = []; await mutateDesignData({ designId, mutate: (current, { updatedAt }) => { const variantSets = isRecord(current.designVariantSets) ? current.designVariantSets : {}; const setIds = collectUnmarkedSupersededCandidateIds(variantSets); markedSetIds = setIds; if (setIds.length === 0) return current; const nextVariantSets = { ...variantSets }; for (const setId of setIds) { const rawSet = variantSets[setId]; if (isRecord(rawSet)) { nextVariantSets[setId] = { ...rawSet, superseded: true }; } } return { ...current, designVariantSets: nextVariantSets, updatedAt, }; }, isApplied: (current) => { const variantSets = isRecord(current.designVariantSets) ? current.designVariantSets : {}; return markedSetIds.every( (setId) => isRecord(variantSets[setId]) && variantSets[setId]!.superseded === true, ); }, }); return { markedSetIds }; } async function deleteVariantSetsByIds( designId: string, requestedSetIds: string[], ): Promise<{ removedSetIds: string[]; removedFileIds: string[] }> { const db = getDb(); const [designRow] = await db .select({ data: schema.designs.data }) .from(schema.designs) .where( and( eq(schema.designs.id, designId), accessFilter(schema.designs, schema.designShares), ), ); if (!designRow) return { removedSetIds: [], removedFileIds: [] }; let precheckVariantSets: Record = {}; if (designRow.data) { try { const parsed: unknown = JSON.parse(designRow.data); if (isRecord(parsed) && isRecord(parsed.designVariantSets)) { precheckVariantSets = parsed.designVariantSets; } } catch { return { removedSetIds: [], removedFileIds: [] }; } } if ( !requestedSetIds.some((setId) => { const rawSet = precheckVariantSets[setId]; return ( isRecord(rawSet) && rawSet.superseded === true && Array.isArray(rawSet.screens) ); }) ) { return { removedSetIds: [], removedFileIds: [] }; } let removedSetIds: string[] = []; let removedFileIds: string[] = []; await mutateDesignData({ designId, mutate: (current, { updatedAt }) => { const variantSets = isRecord(current.designVariantSets) ? current.designVariantSets : {}; const setIds: string[] = []; const fileIds = new Set(); for (const setId of requestedSetIds) { const rawSet = variantSets[setId]; if ( !isRecord(rawSet) || rawSet.superseded !== true || !Array.isArray(rawSet.screens) ) { continue; } setIds.push(setId); for (const id of screenFileIdsForSet( rawSet as { screens: unknown[] }, )) { fileIds.add(id); } } removedSetIds = setIds; removedFileIds = Array.from(fileIds); if (setIds.length === 0) return current; const nextVariantSets = { ...variantSets }; for (const setId of setIds) delete nextVariantSets[setId]; return { ...current, canvasFrames: pruneKeyedRecordForIds( current.canvasFrames, removedFileIds, ), screenMetadata: pruneKeyedRecordForIds( current.screenMetadata, removedFileIds, ), designVariantSets: nextVariantSets, updatedAt, }; }, isApplied: (current) => { const variantSets = isRecord(current.designVariantSets) ? current.designVariantSets : {}; return removedSetIds.every((setId) => !(setId in variantSets)); }, }); if (removedFileIds.length > 0) { await withDesignSourceMutationTransaction(designId, async (tx) => { await tx .delete(schema.designFiles) .where( and( eq(schema.designFiles.designId, designId), inArray(schema.designFiles.id, removedFileIds), ), ); }); await mutateDesignData({ designId, mutate: (current, { updatedAt }) => ({ ...current, canvasFrames: pruneKeyedRecordForIds( current.canvasFrames, removedFileIds, ), screenMetadata: pruneKeyedRecordForIds( current.screenMetadata, removedFileIds, ), updatedAt, }), isApplied: (current) => { const frames = isRecord(current.canvasFrames) ? current.canvasFrames : {}; const metadata = isRecord(current.screenMetadata) ? current.screenMetadata : {}; return removedFileIds.every( (id) => !(id in frames) && !(id in metadata), ); }, }); } return { removedSetIds, removedFileIds }; } function slugify(value: string, fallback: string) { return ( value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 52) || fallback ); } function optionName(index: number) { return `Option ${String.fromCharCode(65 + index)}`; } function uniqueFilename(preferred: string, used: Set) { const dot = preferred.lastIndexOf("."); const stem = dot > 0 ? preferred.slice(0, dot) : preferred; const ext = dot > 0 ? preferred.slice(dot) : ".html"; let candidate = `${stem}${ext}`; let suffix = 2; while (used.has(candidate)) { candidate = `${stem}-${suffix}${ext}`; suffix += 1; } used.add(candidate); return candidate; } function firstCssPixelValue(content: string, property: string) { const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp(`${escaped}\\s*:\\s*(\\d{2,4})px`, "i"); const match = content.match(pattern); if (!match) return undefined; const value = Number(match[1]); return Number.isFinite(value) ? value : undefined; } function boundedDimension(value: unknown, min: number, max: number) { return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : undefined; } function fitVariantAspectRatio(width: number, height: number) { if (width / height > MAX_SANE_FRAME_ASPECT_RATIO) { return { width, height: Math.ceil(width / MAX_SANE_FRAME_ASPECT_RATIO), }; } if (height / width > MAX_SANE_FRAME_ASPECT_RATIO) { return { width: Math.ceil(height / MAX_SANE_FRAME_ASPECT_RATIO), height, }; } return { width, height }; } function inferVariantSize( variant: z.infer, prompt?: string, intent = resolveCanvasIntent(prompt), ) { const promptDimensions = intent.kind === "fixed" ? intent.dimensions : undefined; if (promptDimensions) return promptDimensions; const explicitWidth = boundedDimension( variant.width, 1, MAX_SANE_FRAME_DIMENSION_PX, ); const explicitHeight = boundedDimension( variant.height, 1, MAX_SANE_FRAME_DIMENSION_PX, ); if (explicitWidth && explicitHeight) { return fitVariantAspectRatio(explicitWidth, explicitHeight); } const content = variant.content ?? ""; const cssWidth = firstCssPixelValue(content, "width") ?? firstCssPixelValue(content, "max-width") ?? firstCssPixelValue(content, "min-width"); const cssHeight = firstCssPixelValue(content, "height") ?? firstCssPixelValue(content, "min-height"); const inferredWidth = boundedDimension(cssWidth, 240, 1920); const inferredHeight = boundedDimension(cssHeight, 240, 3000); if (inferredWidth && inferredWidth <= 560) { return fitVariantAspectRatio( explicitWidth ?? inferredWidth, explicitHeight ?? inferredHeight ?? MOBILE_HEIGHT, ); } if (inferredWidth && inferredHeight) { return fitVariantAspectRatio( explicitWidth ?? inferredWidth, explicitHeight ?? inferredHeight, ); } if (intent.kind === "fixed") { return fitVariantAspectRatio( explicitWidth ?? DESKTOP_WIDTH, explicitHeight ?? DESKTOP_HEIGHT, ); } const lowercase = [ content, variant.label, variant.description ?? "", ...(variant.features ?? []), prompt ?? "", ] .join(" ") .toLowerCase(); if ( /\b(?:mobile|phone|iphone|android)\b/.test(lowercase) || /\b(?:max-w-sm|max-w-md|w-\[(?:360|375|390|393|414)px\])\b/.test(lowercase) ) { return fitVariantAspectRatio( explicitWidth ?? MOBILE_WIDTH, explicitHeight ?? MOBILE_HEIGHT, ); } if (/\b(?:tablet|ipad)\b/.test(lowercase)) { return fitVariantAspectRatio( explicitWidth ?? TABLET_WIDTH, explicitHeight ?? TABLET_HEIGHT, ); } return fitVariantAspectRatio( explicitWidth ?? DESKTOP_WIDTH, explicitHeight ?? DESKTOP_HEIGHT, ); } function escapeHtml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function colorForVariant( variant: z.infer, index: number, ) { const provided = variant.accentColor?.trim(); if (provided) return provided; return ["#f59e0b", "#06b6d4", "#10b981", "#f43f5e", "#d97706"][index % 5]!; } function fallbackVariantContent( variant: z.infer, index: number, prompt?: string, size: { width: number; height: number } = { width: DESKTOP_WIDTH, height: DESKTOP_HEIGHT, }, ) { const label = escapeHtml(variant.label.trim() || optionName(index)); const description = escapeHtml( variant.description?.trim() || "A compact dark-mode product direction with a clear primary workflow, crisp hierarchy, and fast keyboard-first flow.", ); const sourcePrompt = escapeHtml( prompt?.trim() || "Generated app interface direction with a polished workflow and production-ready interaction model.", ); const accent = escapeHtml(colorForVariant(variant, index)); const features = variant.features && variant.features.length > 0 ? variant.features.slice(0, 6) : [ "Primary workflow", "Fast capture", "Structured details", "Status tracking", "Inline editing", "Shortcut hints", ]; const safeFeatures = features.map((feature) => escapeHtml(feature)); const cardTitles = [ safeFeatures[0] ?? "Primary workflow", safeFeatures[1] ?? "Structured details", safeFeatures[2] ?? "Polished interactions", safeFeatures[3] ?? "Status tracking", safeFeatures[4] ?? "Review flow", ]; const density = index % 3 === 0 ? "spacious" : index % 3 === 1 ? "glass" : "dense"; const screenWidth = Math.round(size.width); const screenHeight = Math.round(size.height); const compact = screenWidth <= 560; const tablet = screenWidth > 560 && screenWidth <= 900; return ` ${label}

${label}

${sourcePrompt}

${compact ? "Mobile" : tablet ? "Tablet" : "Desktop"} concept · live data
Focus4
${cardTitles[0]}
PrimaryNowFast path
${cardTitles[1]}
Detail viewShortcut E
Build6
${cardTitles[2]}
P2Flow
${cardTitles[3]}
Inline editNext
Ready12
${cardTitles[4]}
CompleteMotion ready
`; } function placeVariantScreens( screens: VariantScreen[], breakpointWidths: readonly number[], originY = 0, ) { const placements: CanvasFramePlacement[] = []; const columns = Math.min(MAX_COLUMNS, Math.max(1, screens.length)); let rowY = originY; for (let rowStart = 0; rowStart < screens.length; rowStart += columns) { const row = screens.slice(rowStart, rowStart + columns); let x = 0; let rowHeight = 0; for (const [offset, screen] of row.entries()) { const visibleWidths = visibleBreakpointWidths( breakpointWidths, screen.width, ); placements.push({ fileId: screen.id, filename: screen.filename, x, y: rowY, width: screen.width, height: screen.height, z: rowStart + offset, }); x += getResponsiveGroupWidth({ primaryWidth: screen.width, scale: 1, visibleWidths, }) + VARIANT_GAP; rowHeight = Math.max( rowHeight, getResponsiveGroupHeight({ primaryHeight: screen.height, scale: 1, sourceWidth: screen.width, sourceHeight: screen.height, visibleWidths, }), ); } rowY += rowHeight + VARIANT_GAP; } return placements; } function effectiveBreakpointWidths(currentBreakpointSet: unknown): number[] { if (!hasBreakpointSet(currentBreakpointSet)) { return DEFAULT_RESPONSIVE_BREAKPOINTS.map(({ widthPx }) => widthPx); } return getResponsiveBreakpointWidths(currentBreakpointSet); } export default defineAction({ description: "Present generated design directions as normal screens on the Design " + "overview board and ask the user to choose one with inline chat buttons. " + "Provide 2-5 variants (3 is the sweet spot). Use this for design " + "exploration before follow-up refinement. After the user's choice, keep " + "the chosen screen, delete the other generated variant screens, and " + "call get-design-snapshot with fileId for the kept screen before " + "calling edit-design on that same fileId in a bounded pass. Use " + '`mode: "replace-file"` when expanding the representative placeholder ' + "into a complete but compact product UI in the chosen direction. Pass " + "the original user request in `brief` and keep `prompt` as the short chat " + "caption so size and output type are preserved. Do not call generate-design after a " + "variant pick. Stop after the first successful edit-design save. For " + "complex apps, " + "make each variant a " + "compact representative screen; pass concise labels/descriptions/features " + "and omit content only for open-ended exploration. For a fixed canvas " + "(exact size, ad, social post), an attached reference image, or a brief " + "with a specific reference, layout, or design system, provide complete " + "self-contained HTML for every variant; the generic fallback is " + "blocked there. Design will render compact screens from direction data only " + "for open-ended exploration. Expand the chosen direction after the user " + "picks. Exact pixel dimensions in the original brief set every variant's " + "exact canvas size and suppress extra mobile or tablet frames. Static " + "artwork such as ads, banners, social posts, flyers, and posters also has " + "no responsive frames. Use one exact " + "canvas size per call; different sizes require separate calls scoped to " + "each screen. Screens from an earlier variant set are never " + "deleted automatically: if you are knowingly replacing your own earlier " + "set that the user never picked from or discussed, pass its set id in " + "deleteSupersededSetIds; otherwise leave old sets in place.", schema: z.object({ designId: z.string().describe("Design project ID to show variants for"), prompt: z .string() .optional() .describe("Caption shown in chat above the variant choice buttons"), brief: z .string() .optional() .describe( "Original user request for this design. Keep requested output type and exact dimensions here; prompt is only the short chat caption.", ), variants: z .array(variantSchema) .min(2) .max(5) .describe( "2-5 concise, visually distinct generated design options to place as overview screens (3 is the sweet spot). Prefer short label/description/features for each direction; include inline HTML content only when it is compact enough to finish.", ), fixedCanvas: z .boolean() .optional() .describe( "Set true to make every direction one fixed canvas with no responsive breakpoint frames. App surfaces get breakpoint frames otherwise; exact dimensions and static artwork such as ads have none either way. False or left out changes nothing.", ), deleteSupersededSetIds: z .array(z.string()) .optional() .describe( "OPT-IN permanent deletion of earlier variant sets you are knowingly " + "replacing. Pass a previous variantSetId ONLY when you created that " + "set yourself and the user never picked, kept, or discussed any of " + "its screens (e.g. the user asked for a completely different set of " + "directions). If the user may have picked a screen — even if the " + "losing screens have not been deleted yet — do NOT pass the set id: " + "deletion is permanent and would destroy the picked screen. Ids are " + "only honored for sets provably untouched by any delete-file call.", ), }), mcpApp: { compactCatalog: true, resource: embedApp({ title: "Design directions", description: "Open the Design editor with generated directions on the overview board.", iframeTitle: "Agent-Native Design", openLabel: "Open screen overview", height: 720, }), }, mcpAnnotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false, }, // No list here replaces a saved one: `deleteSupersededSetIds: []` deletes // nothing and `features: []` gets the same default chips as leaving it out, // so GPT-6 Luna's padded `[]` beside padded nulls can run as sent. emptyListsEraseNothing: true, run: withGenerationAttempt(async (input, context, attempt) => { const { designId, prompt, brief, variants, deleteSupersededSetIds, fixedCanvas, } = input; await assertAccess("design", designId, "editor"); attempt.start({ designId, generationPath: "variants", prompt: brief?.trim() || prompt, properties: { prompt_type: "ui", variant_count: variants.length }, }); const originalBrief = brief?.trim() || (prompt?.trim() && !/^pick a direction$/i.test(prompt.trim()) ? prompt.trim() : ""); const intentPrompt = originalBrief; const canvasIntent = resolveCanvasIntent(intentPrompt); assertSingleCanvasOutput(canvasIntent); const promptDimensions = canvasIntent.kind === "fixed" ? canvasIntent.dimensions : undefined; await snapshotDesignBeforeAgentEdit(designId, context); // A switch that only `true` turns on: GPT-6 Luna pads unused booleans with // `false`, so `responsive: false` used to make every padded call fixed. const useResponsiveFrames = canvasIntent.kind !== "fixed" && fixedCanvas !== true; const omittedContent = variants.filter( (variant) => !variant.content?.trim(), ); const hasUnrenderedImageReference = context?.attachments?.some( isModelVisibleImageAttachment, ); if (omittedContent.length > 0 && hasUnrenderedImageReference) { fail( "Every variant must include complete HTML when an image attachment is present; the direction-only fallback does not use attached image references.", { errorCode: "image_attachment_requires_variant_content", statusCode: 422, }, ); } if ( omittedContent.length > 0 && (hasSpecifiedDesignPrompt(intentPrompt) || (await hasLinkedDesignSystem(designId))) ) { throw new Error( "present-design-variants requires complete self-contained HTML for " + "every variant when the design has a linked design system, or the " + "prompt specifies a layout, reference, or product surface. The " + "generic direction fallback ignores the brief and the system's " + "tokens; use generate-design, or provide each variant's complete " + "content.", ); } for (const variant of variants) { const candidate = variant.content?.trim(); if (!candidate) continue; assertDesignHtmlWellFormed({ content: annotateAgentScreenHtmlForPersist(candidate, "html"), filename: `variant-${slugify(variant.label.trim(), "option")}.html`, }); } await markSupersededVariantSets(designId); const variantSetCleanup = deleteSupersededSetIds && deleteSupersededSetIds.length > 0 ? await deleteVariantSetsByIds(designId, deleteSupersededSetIds) : { removedSetIds: [], removedFileIds: [] }; const db = getDb(); const now = new Date().toISOString(); const variantSetId = nanoid(); let screenFileIds: string[] = []; const screenContents = new Map(); let savedScreenCount = 0; attempt.savedFileCount = () => savedScreenCount; const screens = await withDesignSourceMutationTransaction( designId, async (tx) => { const existingFiles = await tx .select() .from(schema.designFiles) .where(eq(schema.designFiles.designId, designId)); const usedFilenames = new Set( existingFiles.map((file) => file.filename), ); screenFileIds = getOverviewScreenFileIds(existingFiles); const createdScreens: VariantScreen[] = []; for (let index = 0; index < variants.length; index += 1) { const variant = variants[index]!; const label = variant.label.trim() || optionName(index); const slug = slugify(label, `option-${index + 1}`); const filename = uniqueFilename( `variant-${slug}.html`, usedFilenames, ); const fileId = nanoid(); const providedContent = variant.content?.trim(); const initialSize = inferVariantSize( variant, intentPrompt, canvasIntent, ); const rawContent = providedContent || fallbackVariantContent(variant, index, intentPrompt, initialSize); const { width, height } = providedContent ? inferVariantSize( { ...variant, content: rawContent }, promptDimensions ? intentPrompt : undefined, canvasIntent, ) : initialSize; const content = annotateAgentScreenHtmlForPersist(rawContent, "html"); await tx.insert(schema.designFiles).values({ id: fileId, designId, filename, fileType: "html", content, createdAt: now, updatedAt: now, }); usedFilenames.add(filename); screenContents.set(fileId, content); createdScreens.push({ id: fileId, variantId: variant.id, label, filename, width, height, }); } return createdScreens; }, ); savedScreenCount = screens.length; await Promise.all( screens.map((screen) => seedFromText(screen.id, screenContents.get(screen.id)!), ), ); let installedBreakpointSet = false; await mutateDesignData({ designId, mutate: (current, { updatedAt }) => { installedBreakpointSet = useResponsiveFrames && classifyBreakpointSet(current.breakpointSet) === "absent"; const currentBreakpointWidths = effectiveBreakpointWidths( current.breakpointSet, ); const mergedFrames = mergeCanvasFramePlacements({ existing: current.canvasFrames, placements: placeVariantScreens( screens, useResponsiveFrames ? currentBreakpointWidths : [], nextFreeCanvasRowY(current.canvasFrames, VARIANT_GAP, { ignoreFileIds: screens.map((screen) => screen.id), responsiveLayout: { screenFileIds, screenMetadataByFileId: current.screenMetadata, breakpointWidths: currentBreakpointWidths, }, }), ), resolveFileId: (placement) => placement.fileId, }); const previousMetadata = isRecord(current.screenMetadata) ? { ...current.screenMetadata } : {}; const previousVariantSets = isRecord(current.designVariantSets) ? { ...current.designVariantSets } : {}; for (const screen of screens) { const existingMetadata = previousMetadata[screen.id]; const metadata: Record = { ...(isRecord(existingMetadata) ? existingMetadata : {}), sourceType: "inline", previewState: "preview", title: screen.label, width: screen.width, height: screen.height, variantSetId, variantId: screen.variantId, }; if (useResponsiveFrames) { delete metadata.breakpointWidths; } else { metadata.breakpointWidths = []; } if (canvasIntent.kind === "fixed") { metadata.heightPinned = true; metadata.heightMode = "fixed"; } previousMetadata[screen.id] = metadata; } previousVariantSets[variantSetId] = { id: variantSetId, prompt: prompt ?? "Pick a direction", ...(originalBrief ? { brief: originalBrief } : {}), createdAt: now, screenCount: screens.length, screens: screens.map((screen) => ({ id: screen.id, variantId: screen.variantId, label: screen.label, filename: screen.filename, width: screen.width, height: screen.height, })), }; return { ...current, canvasFrames: mergedFrames.canvasFrames, screenMetadata: previousMetadata, designVariantSets: previousVariantSets, ...(!useResponsiveFrames || classifyBreakpointSet(current.breakpointSet) !== "absent" ? {} : { breakpointSet: { id: "generated-responsive", breakpoints: DEFAULT_RESPONSIVE_BREAKPOINTS, }, breakpointSetUpdatedAt: updatedAt, }), updatedAt, }; }, isApplied: (current) => { const canvasFrames = isRecord(current.canvasFrames) ? current.canvasFrames : {}; const metadata = isRecord(current.screenMetadata) ? current.screenMetadata : {}; const variantSets = isRecord(current.designVariantSets) ? current.designVariantSets : {}; const set = isRecord(variantSets[variantSetId]) ? variantSets[variantSetId] : null; const persistedScreens = Array.isArray(set?.screens) ? set.screens : []; return ( (!useResponsiveFrames || hasBreakpointSet(current.breakpointSet)) && screens.every((screen) => { const frame = canvasFrames[screen.id]; const screenMetadata = metadata[screen.id]; return ( isRecord(frame) && frame.width === screen.width && frame.height === screen.height && isRecord(screenMetadata) && screenMetadata.width === screen.width && screenMetadata.height === screen.height && (useResponsiveFrames ? screenMetadata.breakpointWidths === undefined : Array.isArray(screenMetadata.breakpointWidths) && screenMetadata.breakpointWidths.length === 0) && (!promptDimensions || (screenMetadata.heightPinned === true && screenMetadata.heightMode === "fixed")) && persistedScreens.some( (persisted) => isRecord(persisted) && persisted.id === screen.id, ) ); }) ); }, }); await writeAppStateForCurrentTab("navigate", { view: "editor", designId, editorView: "overview", path: `/design/${encodeURIComponent(designId)}?editorView=overview`, }); const [linkedDesign] = await db .select({ designSystemId: schema.designs.designSystemId }) .from(schema.designs) .where( and( eq(schema.designs.id, designId), accessFilter(schema.designs, schema.designShares), ), ); const variantPickContext = [ originalBrief ? `The user's original request for this design: "${originalBrief}". Expand the kept direction into that, not into a generic version of it.` : "", linkedDesign?.designSystemId ? `This design is linked to design system "${linkedDesign.designSystemId}". Call \`get-design-system\` for that id and apply its tokens, typography, and usage notes while expanding the kept screen — do not substitute a generic palette or font.` : "", ] .filter(Boolean) .join("\n"); await writeAppStateForCurrentTab("guided-questions", { ...(variantPickContext ? { submitContext: variantPickContext } : {}), title: prompt ?? "Pick a direction", description: "All options are on the board. Choose one to keep; I will delete the others, read only the kept screen, and turn that direction into the final requested screen.", submitLabel: "Use selected direction", submitMessage: VARIANT_PICK_SUBMIT_MESSAGE, skipLabel: "Show another set", skipMessage: "None of these directions are right.", questions: [ { id: "variant", type: "text-options", question: "Which screen should I keep?", required: true, allowOther: false, includeExplore: false, includeDecide: false, submitOnSelect: false, options: screens.map((screen, index) => { const otherScreens = screens .filter((other) => other.id !== screen.id) .map( (other) => `${other.label} (${other.filename}, file id ${other.id})`, ) .join("; "); return { label: screen.label || optionName(index), value: `Keep "${screen.label}" (${screen.filename}, file id ${screen.id}) ` + `from variant set ${variantSetId}. Delete each other variant screen at most once: ${otherScreens}. If delete-file says a screen is already missing, continue. ` + `Then call get-design-snapshot exactly once with designId ${designId} and fileId ${screen.id} (filename ${screen.filename}), then call edit-design with fileId ${screen.id} on that same kept file in a bounded single-file pass. Use mode "replace-file" to replace the representative direction screen with a complete but compact requested app/product UI in the chosen visual style. Prioritize the primary workflow; if the full feature list is too large for one reliable edit, render secondary details as visible controls, states, or affordances instead of expanding the action input. The final saved screen must be the actual usable UI requested by the user, not a direction board, variant brief, summary card, or description of the direction. Do not call generate-design after this variant pick, do not repeat delete/snapshot cycles, do not create index.html, and do not resend a huge payload. Stop after the first successful edit-design save.`, }; }), }, ], }); await deleteAppState("design-variants").catch(() => false); attempt.addProperties({ has_linked_design_system: Boolean(linkedDesign?.designSystemId), variant_set_id: variantSetId, }); trackDesign( "variants_generated", { output_id: designId, output_type: "design", generation_attempt_id: attempt.id, variant_set_id: variantSetId, variant_count: screens.length, }, context, ); return { designId, prompt: prompt ?? "Pick a direction", variantSetId, count: screens.length, _receipt: designOutputWriteReceipt(designId, savedScreenCount), screens, path: `/design/${encodeURIComponent(designId)}?editorView=overview`, embed: true, cleanedUpPreviousVariantScreens: variantSetCleanup.removedFileIds.length, deletedSupersededSetIds: variantSetCleanup.removedSetIds, ...(installedBreakpointSet ? { installedBreakpointSet: DEFAULT_RESPONSIVE_BREAKPOINTS.map( (breakpoint) => breakpoint.widthPx, ), } : {}), fallbackInstructions: FALLBACK_INSTRUCTIONS, nextRequiredAction: 'Wait for the user to pick a screen in chat. Then delete each unchosen variant screen with delete-file at most once, call get-design-snapshot exactly once with fileId for the chosen screen, and call edit-design with that same fileId in a bounded pass. Use mode "replace-file" to replace the representative direction screen with a complete but compact requested app/product UI in the chosen visual style. Prioritize the primary workflow and render secondary details as visible controls, states, or affordances if the full feature list is too large for one reliable edit. Do not leave a direction board, variant brief, or summary card as the final result. Do not repeat delete/snapshot cycles. Do not call generate-design after a variant pick. Stop after the first successful edit-design save.', }; }), link: ({ result }) => { if (!result || typeof result !== "object") return null; const designId = (result as { designId?: string }).designId; if (!designId) return null; return { url: designDeepLink(designId), label: "Open screen overview", view: "editor", }; }, });