"use client"; /** * TASK-0126 — "Generate page with AI" dialog. * * Mounted at the workspace shell (like the Ask AI panel) so every entry point — * the command palette, the page context menu, and the Home empty-state card — * can open it with an optional `parentId` context. It is a thin view over * `generateAiPage` (the SSE generation client in lib/ai): the user describes a * page, the LLM emits a structured plan, and the executor runs it through the * domain services while the dialog renders a live step checklist. On success it * navigates to the generated page; on failure it shows an error banner with * Retry (re-sends the same prompt; the server-side `Idempotency-Key` header * keeps a retried cancelled generation from double-creating). * * a11y mirrors `db-ai-dialog.tsx` (role="dialog" aria-modal, focus trap, * Escape closes + aborts, focus restored, aria-live announces the state). * * Pinned testids (do not rename): * generate-dialog the dialog (role=dialog) * generate-input the prompt textarea * generate-submit the "Generate page" button * generate-cancel the cancel button (visible while generating) * generate-step-{n} a live checklist row (page created, blocks …) * generate-error-banner the failure / budget banner * generate-retry the banner's Retry button */ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Sparkles, X } from "lucide-react"; import { useUiStore } from "@/stores/ui-store"; import { useWorkspace } from "@/hooks/use-workspace"; import { cn } from "@/lib/utils"; import { AiPreStreamError, generateAiPage, type GenerateFrame, type GenerateToolName } from "@/lib/ai"; import { DbAiDialog, DbAiLoading } from "@/components/ai/db-ai-dialog"; /** The human label for a checklist row (the executor's structured tool call). */ const STEP_LABELS: Record = { createPage: "Create the page", insertBlocks: "Insert the content", createDatabase: "Add the tasks database", createRelation: "Link the databases" }; type StepStatus = "running" | "done"; interface Step { name: GenerateToolName; status: StepStatus; ref?: string; } export function GeneratePageDialog() { const open = useUiStore((s) => s.generateOpen); const context = useUiStore((s) => s.generateContext); const setOpen = useUiStore((s) => s.setGenerate); const slug = useUiStore((s) => s.searchWorkspaceSlug); const { data: workspace } = useWorkspace(slug); const router = useRouter(); const [prompt, setPrompt] = useState(""); const [steps, setSteps] = useState([]); const [running, setRunning] = useState(false); const [cancelled, setCancelled] = useState(false); const [error, setError] = useState<{ code: string; message: string; retryable: boolean } | null>(null); /** Elapsed seconds since run() started — shown during the silent * "Drafting the page…" phase (the model's thinking phase streams no * progress frames; the elapsed time is the only feedback that the * request is still alive). */ const [elapsed, setElapsed] = useState(0); /** One idempotency key per "try" (a new key when the dialog re-arms; a retry * of a CANCELLED generation reuses it so the server does not double-create). */ const idempKeyRef = useRef(null); const abortRef = useRef(null); const close = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setOpen(false, null); // Fresh state for the next open (a new idempotency key). setRunning(false); setCancelled(false); setError(null); setSteps([]); idempKeyRef.current = null; }, [setOpen]); const updateStep = useCallback((f: Extract) => { setSteps((prev) => { if (f.status === "started") { return [...prev, { name: f.name, status: "running", ref: f.ref }]; } const next = [...prev]; for (let i = next.length - 1; i >= 0; i--) { const step = next[i]; if (step && step.name === f.name && step.status === "running") { next[i] = { ...step, status: "done", ref: f.ref }; break; } } return next; }); }, []); const run = useCallback(async () => { if (!workspace?.id || !prompt.trim() || running) return; const key = idempKeyRef.current ?? (idempKeyRef.current = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}`); setRunning(true); setSteps([]); setError(null); setCancelled(false); const controller = new AbortController(); abortRef.current = controller; try { const res = await generateAiPage({ workspaceId: workspace.id, prompt: prompt.trim(), parentId: context?.parentId ?? null, idempotencyKey: key, signal: controller.signal, onFrame: (f) => { if (f.type === "tool") updateStep(f); } }); if (res.reason === "ok" && res.pageId) { // Success → navigate to the generated page. setOpen(false, null); idempKeyRef.current = null; router.push(`/w/${workspace.slug ?? slug}/page/${res.pageId}`); } else if (res.reason === "aborted" || res.reason === "cancelled") { // The user cancelled: the server rolled back the partial tree. setCancelled(true); } else if (res.reason === "error") { setError({ code: res.error?.code ?? "UNKNOWN", message: res.error?.message ?? "Generation failed.", retryable: true }); } else { setError({ code: "UNKNOWN", message: "Generation failed.", retryable: true }); } } catch (e) { if (e instanceof AiPreStreamError) { setError({ code: e.code, message: e.message, retryable: e.code === "RATE_LIMITED" || e.code === "UNKNOWN" }); } else { setError({ code: "UNKNOWN", message: "Something went wrong.", retryable: true }); } } finally { setRunning(false); abortRef.current = null; } }, [workspace, slug, prompt, running, context, setOpen, router, updateStep]); const cancel = useCallback(() => { abortRef.current?.abort(); }, []); // Elapsed-time ticker while running (the silent drafting phase has no // frames to show — the count is the liveness signal). useEffect(() => { if (!running) return; setElapsed(0); const t = setInterval(() => setElapsed((e) => e + 1), 1000); return () => clearInterval(t); }, [running]); const live = running ? "Generating page…" : cancelled ? "Generation cancelled — nothing was created." : error ? `Error: ${error.message}` : "Describe a page to generate."; const budget = error?.code === "AI_BUDGET_EXCEEDED" || error?.code === "AI_BUDGET_MONTHLY_EXCEEDED"; const stalledError = error?.code === "STREAM_TIMEOUT" || error?.code === "SERVICE_UNAVAILABLE"; return ( {running ? ( ) : null} {!running && (error || cancelled) ? ( ) : ( )} } >