1"use client";
2
3
28import { useCallback, useEffect, useRef, useState } from "react";
29import { useRouter } from "next/navigation";
30import {
31 AlertTriangle,
32 CheckCircle2,
33 Loader2,
34 RotateCcw,
35 Sparkles,
36 X
37} from "lucide-react";
38import { useUiStore } from "@/stores/ui-store";
39import { useWorkspace } from "@/hooks/use-workspace";
40import { cn } from "@/lib/utils";
41import {
42 AiPreStreamError,
43 generateAiPage,
44 type GenerateFrame,
45 type GenerateToolName
46} from "@/lib/ai";
47import { DbAiDialog, DbAiLoading } from "@/components/ai/db-ai-dialog";
48
49
50const STEP_LABELS: Record<GenerateToolName, string> = {
51 createPage: "Create the page",
52 insertBlocks: "Insert the content",
53 createDatabase: "Add the tasks database",
54 createRelation: "Link the databases"
55};
56
57type StepStatus = "running" | "done";
58interface Step {
59 name: GenerateToolName;
60 status: StepStatus;
61 ref?: string;
62}
63
64export function GeneratePageDialog() {
65 const open = useUiStore((s) => s.generateOpen);
66 const context = useUiStore((s) => s.generateContext);
67 const setOpen = useUiStore((s) => s.setGenerate);
68 const slug = useUiStore((s) => s.searchWorkspaceSlug);
69 const { data: workspace } = useWorkspace(slug);
70 const router = useRouter();
71
72 const [prompt, setPrompt] = useState("");
73 const [steps, setSteps] = useState<Step[]>([]);
74 const [running, setRunning] = useState(false);
75 const [cancelled, setCancelled] = useState(false);
76 const [error, setError] = useState<{ code: string; message: string; retryable: boolean } | null>(null);
77
81 const [elapsed, setElapsed] = useState(0);
82
83
85 const idempKeyRef = useRef<string | null>(null);
86 const abortRef = useRef<AbortController | null>(null);
87
88 const close = useCallback(() => {
89 abortRef.current?.abort();
90 abortRef.current = null;
91 setOpen(false, null);
92
93 setRunning(false);
94 setCancelled(false);
95 setError(null);
96 setSteps([]);
97 idempKeyRef.current = null;
98 }, [setOpen]);
99
100 const updateStep = useCallback((f: Extract<GenerateFrame, { type: "tool" }>) => {
101 setSteps((prev) => {
102 if (f.status === "started") {
103 return [...prev, { name: f.name, status: "running", ref: f.ref }];
104 }
105 const next = [...prev];
106 for (let i = next.length - 1; i >= 0; i--) {
107 const step = next[i];
108 if (step && step.name === f.name && step.status === "running") {
109 next[i] = { ...step, status: "done", ref: f.ref };
110 break;
111 }
112 }
113 return next;
114 });
115 }, []);
116
117 const run = useCallback(async () => {
118 if (!workspace?.id || !prompt.trim() || running) return;
119 const key =
120 idempKeyRef.current ?? (idempKeyRef.current = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}`);
121 setRunning(true);
122 setSteps([]);
123 setError(null);
124 setCancelled(false);
125 const controller = new AbortController();
126 abortRef.current = controller;
127
128 try {
129 const res = await generateAiPage({
130 workspaceId: workspace.id,
131 prompt: prompt.trim(),
132 parentId: context?.parentId ?? null,
133 idempotencyKey: key,
134 signal: controller.signal,
135 onFrame: (f) => {
136 if (f.type === "tool") updateStep(f);
137 }
138 });
139
140 if (res.reason === "ok" && res.pageId) {
141
142 setOpen(false, null);
143 idempKeyRef.current = null;
144 router.push(`/w/${workspace.slug ?? slug}/page/${res.pageId}`);
145 } else if (res.reason === "aborted" || res.reason === "cancelled") {
146
147 setCancelled(true);
148 } else if (res.reason === "error") {
149 setError({
150 code: res.error?.code ?? "UNKNOWN",
151 message: res.error?.message ?? "Generation failed.",
152 retryable: true
153 });
154 } else {
155 setError({ code: "UNKNOWN", message: "Generation failed.", retryable: true });
156 }
157 } catch (e) {
158 if (e instanceof AiPreStreamError) {
159 setError({
160 code: e.code,
161 message: e.message,
162 retryable: e.code === "RATE_LIMITED" || e.code === "UNKNOWN"
163 });
164 } else {
165 setError({ code: "UNKNOWN", message: "Something went wrong.", retryable: true });
166 }
167 } finally {
168 setRunning(false);
169 abortRef.current = null;
170 }
171 }, [workspace, slug, prompt, running, context, setOpen, router, updateStep]);
172
173 const cancel = useCallback(() => {
174 abortRef.current?.abort();
175 }, []);
176
177
178
179 useEffect(() => {
180 if (!running) return;
181 setElapsed(0);
182 const t = setInterval(() => setElapsed((e) => e + 1), 1000);
183 return () => clearInterval(t);
184 }, [running]);
185
186 const live = running
187 ? "Generating page…"
188 : cancelled
189 ? "Generation cancelled — nothing was created."
190 : error
191 ? `Error: ${error.message}`
192 : "Describe a page to generate.";
193
194 const budget = error?.code === "AI_BUDGET_EXCEEDED" || error?.code === "AI_BUDGET_MONTHLY_EXCEEDED";
195 const stalledError = error?.code === "STREAM_TIMEOUT" || error?.code === "SERVICE_UNAVAILABLE";
196
197 return (
198 <DbAiDialog
199 open={open}
200 title="Generate page with AI"
201 testId="generate-dialog"
202 onClose={close}
203 loadingText="Generating…"
204 live={live}
205 footer={
206 <div className="flex w-full items-center justify-end gap-2">
207 {running ? (
208 <button
209 type="button"
210 data-testid="generate-cancel"
211 onClick={cancel}
212 className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent"
213 >
214 <X className="size-3.5" aria-hidden /> Cancel
215 </button>
216 ) : null}
217 {!running && (error || cancelled) ? (
218 <button
219 type="button"
220 data-testid="generate-retry"
221 onClick={run}
222 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
223 >
224 <RotateCcw className="size-3.5" aria-hidden /> Retry
225 </button>
226 ) : (
227 <button
228 type="button"
229 data-testid="generate-submit"
230 onClick={run}
231 disabled={!prompt.trim() || !workspace?.id}
232 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
233 >
234 <Sparkles className="size-3.5" aria-hidden /> Generate page
235 </button>
236 )}
237 </div>
238 }
239 >
240 <label htmlFor="generate-prompt" className="sr-only">
241 Describe the page to generate
242 </label>
243 <textarea
244 id="generate-prompt"
245 data-testid="generate-input"
246 value={prompt}
247 onChange={(e) => setPrompt(e.target.value)}
248 onKeyDown={(e) => {
249 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
250 e.preventDefault();
251 void run();
252 }
253 }}
254 rows={3}
255 placeholder="Describe a page… e.g. “A product launch plan with objectives, timeline, a tasks database, risks and team.”"
256 className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
257 />
258
259 {running ? (
260 steps.length > 0 ? (
261 <ol data-testid="generate-steps" className="space-y-2" aria-label="Generation progress">
262 {steps.map((step, i) => (
263 <li
264 key={`${step.name}-${i}`}
265 data-testid={`generate-step-${i + 1}`}
266 className="flex items-center gap-2 text-sm"
267 aria-current={step.status === "running" ? "step" : undefined}
268 >
269 {step.status === "running" ? (
270 <Loader2 className="size-4 shrink-0 animate-spin text-primary" aria-hidden />
271 ) : (
272 <CheckCircle2 className="size-4 shrink-0 text-emerald-500" aria-hidden />
273 )}
274 <span className={cn(step.status === "running" ? "text-foreground" : "text-muted-foreground")}>
275 {STEP_LABELS[step.name]}
276 </span>
277 </li>
278 ))}
279 </ol>
280 ) : (
281 <div>
282 <DbAiLoading text="Drafting the page…" rows={1} />
283 <p
284 data-testid="generate-elapsed"
285 className="mt-2 text-center text-[11px] tabular-nums text-muted-foreground"
286 >
287 Working — {elapsed}s elapsed. Large pages can take a couple of minutes.
288 </p>
289 </div>
290 )
291 ) : null}
292
293 {cancelled ? (
294 <div
295 data-testid="generate-cancelled"
296 role="status"
297 className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
298 >
299 Generation was cancelled. The partial page (if any) was rolled back — nothing was created.
300 </div>
301 ) : null}
302
303 {error ? (
304 <div
305 data-testid="generate-error-banner"
306 role="alert"
307 className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
308 >
309 <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
310 <div className="min-w-0 flex-1">
311 <p className="font-medium">
312 {budget
313 ? "Monthly AI budget reached"
314 : error.code === "RATE_LIMITED"
315 ? "Sending too fast"
316 : error.code === "FORBIDDEN"
317 ? "You don't have permission"
318 : stalledError
319 ? "Generation stalled"
320 : "Something went wrong"}
321 </p>
322 <p className="mt-0.5 break-words">{error.message}</p>
323 {budget ? (
324 <p className="mt-1 text-[11px] opacity-80">
325 Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.
326 </p>
327 ) : null}
328 {error.code === "FORBIDDEN" ? (
329 <p className="mt-1 text-[11px] opacity-80">You need edit access to this workspace to use AI.</p>
330 ) : null}
331 </div>
332 </div>
333 ) : null}
334 </DbAiDialog>
335 );
336}
337