1"use client";
2
3
33import { useCallback, useEffect, useMemo, useRef, useState } from "react";
34import { useQueryClient } from "@tanstack/react-query";
35import {
36 AlertTriangle,
37 Check,
38 CheckCheck,
39 Loader2,
40 RefreshCw,
41 RotateCcw,
42 Sparkles,
43 X
44} from "lucide-react";
45import { wordDiff, type DiffSegment } from "@factory/editor";
46import {
47 AiPreStreamError,
48 blockPlainText,
49 sendWriting,
50 writingApi,
51 WRITING_LANGUAGES,
52 type WritingAction,
53 type WritingApplyResult,
54 type WritingSuggestion
55} from "@/lib/ai";
56import type { Block } from "@/lib/types";
57import { cn } from "@/lib/utils";
58
59
60
61
62
63
64const ACTION_LABEL: Record<WritingAction, string> = {
65 summarize: "Summary",
66 rewrite: "Rewrite",
67 improve: "Improved writing",
68 translate: "Translation"
69};
70
71function languageLabel(id: string): string {
72 return WRITING_LANGUAGES.find((l) => l.id === id)?.label ?? id;
73}
74
75
76function renderSegments(segs: DiffSegment[], keyPrefix: string) {
77 return segs.map((s, i) => {
78 const k = `${keyPrefix}-${i}`;
79 if (s.type === "same") return <span key={k}>{s.text}</span>;
80 if (s.type === "del")
81 return (
82 <span key={k} className="rounded-sm bg-red-100 text-red-700 line-through dark:bg-red-950/60 dark:text-red-300">
83 {s.text}
84 </span>
85 );
86 return (
87 <span key={k} className="rounded-sm bg-green-100 text-green-800 dark:bg-green-950/60 dark:text-green-300">
88 {s.text}
89 </span>
90 );
91 });
92}
93
94
95
96export interface WritingDialogProps {
97 open: boolean;
98 workspaceId: string;
99 pageId: string;
100
101 blocks: Block[];
102 action: WritingAction;
103 language?: string;
104 onClose: () => void;
105}
106
107interface FlowError {
108 code: string;
109 message: string;
110 retryable?: boolean;
111 retryAfterSeconds?: number;
112}
113
114export function WritingDialog({ open, workspaceId, pageId, blocks, action, language, onClose }: WritingDialogProps) {
115 const qc = useQueryClient();
116 const [status, setStatus] = useState<"idle" | "streaming" | "done" | "error">("idle");
117 const [suggestion, setSuggestion] = useState<WritingSuggestion | null>(null);
118 const [suggestionId, setSuggestionId] = useState<string | null>(null);
119 const [notes, setNotes] = useState<string>("");
120 const [error, setError] = useState<FlowError | null>(null);
121 const [applyResults, setApplyResults] = useState<WritingApplyResult[] | null>(null);
122 const [applying, setApplying] = useState(false);
123 const [rebaseIds, setRebaseIds] = useState<Set<string>>(new Set());
124
125 const dialogRef = useRef<HTMLDivElement>(null);
126 const triggerRef = useRef<HTMLElement | null>(null);
127 const abortRef = useRef<AbortController | null>(null);
128
129 const blockById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks]);
130
131
132
133 const run = useCallback(
134 async (ids: string[]) => {
135 setStatus("streaming");
136 setSuggestion(null);
137 setSuggestionId(null);
138 setNotes("");
139 setError(null);
140 setApplyResults(null);
141 setRebaseIds(new Set());
142 const controller = new AbortController();
143 abortRef.current = controller;
144 try {
145 const res = await sendWriting({
146 workspaceId,
147 pageId,
148 blockIds: ids,
149 action,
150 ...(language ? { language } : {}),
151 signal: controller.signal
152 });
153 if (res.reason === "aborted") {
154 setStatus("idle");
155 return;
156 }
157 if (res.reason !== "ok" || !res.suggestion) {
158 setStatus("error");
159 setError({ code: "AI_STREAM_ERROR", message: "The suggestion stream ended unexpectedly." });
160 return;
161 }
162 setSuggestion(res.suggestion);
163 setSuggestionId(res.suggestionId ?? null);
164 setNotes(res.notes ?? "");
165 setStatus("done");
166 } catch (err) {
167 if (err instanceof AiPreStreamError) {
168 setStatus("error");
169 setError({
170 code: err.code,
171 message: err.message,
172 retryable: err.retryAfterSeconds != null,
173 retryAfterSeconds: err.retryAfterSeconds
174 });
175 } else {
176 setStatus("error");
177 setError({ code: "NETWORK_ERROR", message: "Could not reach the server." });
178 }
179 } finally {
180 abortRef.current = null;
181 }
182 },
183 [workspaceId, pageId, action, language]
184 );
185
186
187 useEffect(() => {
188 if (!open) return;
189 const ids = blocks.map((b) => b.id);
190 if (ids.length > 0) void run(ids);
191 return () => abortRef.current?.abort();
192
193 }, [open, pageId, action, language]);
194
195
196
197 const close = useCallback(() => {
198 abortRef.current?.abort();
199 onClose();
200 requestAnimationFrame(() => {
201 triggerRef.current?.focus();
202 triggerRef.current = null;
203 });
204 }, [onClose]);
205
206 useEffect(() => {
207 if (!open) return;
208 const prev = document.activeElement as HTMLElement | null;
209 triggerRef.current = prev;
210 requestAnimationFrame(() => dialogRef.current?.querySelector<HTMLElement>("button, [tabindex]")?.focus());
211 const onKey = (e: KeyboardEvent) => {
212 if (e.key === "Escape") {
213 e.stopPropagation();
214 close();
215 } else if (e.key === "Tab" && dialogRef.current) {
216 const focusables = dialogRef.current.querySelectorAll<HTMLElement>('button, [tabindex]:not([tabindex="-1"])');
217 if (focusables.length === 0) return;
218 const first = focusables[0];
219 const last = focusables[focusables.length - 1];
220 if (!first || !last) return;
221 if (e.shiftKey && document.activeElement === first) {
222 e.preventDefault();
223 last.focus();
224 } else if (!e.shiftKey && document.activeElement === last) {
225 e.preventDefault();
226 first.focus();
227 }
228 }
229 };
230 window.addEventListener("keydown", onKey);
231 return () => window.removeEventListener("keydown", onKey);
232 }, [open, close]);
233
234
235
236 const invalidate = useCallback(() => {
237 void qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] });
238 }, [qc, pageId]);
239
240 const apply = useCallback(
241 async (onlyBlockId?: string) => {
242 if (!suggestionId || applying) return;
243 setApplying(true);
244 setApplyResults(null);
245 try {
246 const res = await writingApi.apply(workspaceId, {
247 suggestionId,
248 ...(onlyBlockId ? { blockIds: [onlyBlockId] } : {})
249 });
250 setApplyResults(res.results);
251 invalidate();
252 const failed = res.results.filter((r) => r.status === "failed");
253 if (failed.length === 0) {
254
255 close();
256 }
257 } catch (err) {
258 const e = err as { code?: string; message?: string };
259 setError({ code: e.code ?? "APPLY_ERROR", message: e.message ?? "Apply failed." });
260 } finally {
261 setApplying(false);
262 }
263 },
264 [suggestionId, applying, workspaceId, invalidate, close]
265 );
266
267
269 const rebase = useCallback(
270 (blockId: string) => {
271 setRebaseIds((s) => {
272 const next = new Set(s);
273 next.delete(blockId);
274 return next;
275 });
276 const remaining = blocks.map((b) => b.id).filter((id) => id !== blockId);
277 if (remaining.length === 0) {
278 close();
279 return;
280 }
281
282
283 void qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] });
284 void run(remaining);
285 },
286 [blocks, pageId, run, close, qc]
287 );
288
289 if (!open) return null;
290
291 const conflictIds = new Set((applyResults ?? []).filter((r) => r.status === "failed" && r.code === "CONFLICT").map((r) => r.blockId));
292 const nonConflictFailures = (applyResults ?? []).filter((r) => r.status === "failed" && r.code !== "CONFLICT");
293 const budgetError = error && (error.code === "AI_BUDGET_EXCEEDED" || error.code === "AI_BUDGET_MONTHLY_EXCEEDED");
294
295 const streaming = status === "streaming";
296 const showDiff = status === "done" && suggestion && suggestion.blocks.length > 0;
297
298 return (
299 <div className="fixed inset-0 z-[85] flex items-end justify-center sm:items-center" role="presentation">
300 <div className="absolute inset-0 bg-black/30" aria-hidden onClick={close} />
301 <div
302 ref={dialogRef}
303 data-testid="writing-diff"
304 role="dialog"
305 aria-modal="true"
306 aria-label={`AI ${ACTION_LABEL[action]}`}
307 className="relative flex max-h-[85vh] w-full flex-col rounded-t-xl border border-border bg-background shadow-2xl sm:max-w-2xl sm:rounded-xl"
308 >
309 {}
310 <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
311 <div className="flex min-w-0 items-center gap-2">
312 <Sparkles className="size-4 shrink-0 text-primary" aria-hidden />
313 <h2 className="text-sm font-semibold">
314 {ACTION_LABEL[action]}
315 {action === "translate" && language ? <span className="ml-1 text-xs font-normal text-muted-foreground">→ {languageLabel(language)}</span> : null}
316 </h2>
317 <span className="rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[10px] text-muted-foreground">
318 {blocks.length} block{blocks.length === 1 ? "" : "s"}
319 </span>
320 </div>
321 <button
322 type="button"
323 onClick={close}
324 className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
325 aria-label="Close"
326 >
327 <X className="size-4" aria-hidden />
328 </button>
329 </div>
330
331 {}
332 <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
333 {streaming ? (
334 <div data-testid="writing-loading" className="space-y-2" role="status">
335 <p className="flex items-center gap-2 text-xs text-muted-foreground">
336 <Loader2 className="size-3.5 animate-spin" aria-hidden /> Generating…
337 </p>
338 {blocks.slice(0, 4).map((b) => (
339 <div key={b.id} className="animate-pulse rounded-md border border-border bg-muted/40 px-3 py-2.5">
340 <div className="mb-2 h-2.5 w-1/3 rounded bg-foreground/10" />
341 <div className="h-2.5 w-full rounded bg-foreground/10" />
342 <div className="mt-1.5 h-2.5 w-2/3 rounded bg-foreground/10" />
343 </div>
344 ))}
345 </div>
346 ) : null}
347
348 {status === "error" && error ? (
349 <div
350 data-testid="writing-error-banner"
351 role="alert"
352 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"
353 >
354 <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
355 <div className="min-w-0 flex-1">
356 <p className="font-medium">
357 {budgetError
358 ? "Monthly AI budget reached"
359 : error.code === "RATE_LIMITED"
360 ? "Sending too fast"
361 : error.code === "PAYLOAD_TOO_LARGE"
362 ? "Selection too large"
363 : "Something went wrong"}
364 </p>
365 <p className="mt-0.5 break-words">{error.message}</p>
366 {budgetError ? (
367 <p className="mt-1 text-[11px] opacity-80">
368 Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.
369 </p>
370 ) : null}
371 </div>
372 {error.retryable ? (
373 <button
374 type="button"
375 onClick={() => void run(blocks.map((b) => b.id))}
376 className="inline-flex shrink-0 items-center gap-1 rounded border border-amber-400 px-2 py-1 font-medium hover:bg-amber-100 dark:hover:bg-amber-900/40"
377 >
378 <RotateCcw className="size-3" aria-hidden /> Retry
379 </button>
380 ) : null}
381 </div>
382 ) : null}
383
384 {notes ? (
385 <p className="rounded-md border border-border bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground">{notes}</p>
386 ) : null}
387
388 {showDiff ? (
389 <div className="space-y-3" data-testid="writing-diff-list">
390 {suggestion!.blocks.map((sb) => {
391 const old = blockPlainText(blockById.get(sb.blockId)?.content ?? "");
392 const isConflict = conflictIds.has(sb.blockId);
393 const isRebasing = rebaseIds.has(sb.blockId);
394 return (
395 <div key={sb.blockId} data-testid="writing-diff-block" className="rounded-md border border-border bg-background">
396 <div className="flex items-center justify-between border-b border-border px-3 py-1.5">
397 <span className="truncate text-[11px] text-muted-foreground">
398 {sb.blockId.slice(0, 8)}
399 {isConflict ? <span className="ml-2 text-red-600">changed by someone else</span> : null}
400 </span>
401 {!streaming && !isConflict && !isRebasing ? (
402 <button
403 type="button"
404 data-testid={`writing-accept-one-${sb.blockId}`}
405 onClick={() => void apply(sb.blockId)}
406 disabled={applying}
407 className="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-40"
408 >
409 <Check className="size-3" aria-hidden /> Accept one
410 </button>
411 ) : null}
412 </div>
413 <div className="px-3 py-2 text-sm">
414 {isConflict ? (
415 <div data-testid="writing-rebase-prompt" className="space-y-2 rounded-md border border-amber-300 bg-amber-50 p-2.5 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
416 <p className="font-medium">
417 This block was edited by someone else since the suggestion was made.
418 </p>
419 <div className="flex gap-2">
420 <button
421 type="button"
422 data-testid={`writing-rebase-${sb.blockId}`}
423 onClick={() => void rebase(sb.blockId)}
424 className="inline-flex items-center gap-1 rounded border border-amber-400 px-2 py-1 font-medium hover:bg-amber-100 dark:hover:bg-amber-900/40"
425 >
426 <RefreshCw className="size-3" aria-hidden /> Rebase & retry the rest
427 </button>
428 <button
429 type="button"
430 onClick={() => setRebaseIds((s) => new Set(s).add(sb.blockId))}
431 className="rounded border border-border px-2 py-1 hover:bg-accent"
432 >
433 Skip this block
434 </button>
435 </div>
436 </div>
437 ) : isRebasing ? (
438 <p className="text-xs text-muted-foreground">Skipped (rebasing the rest).</p>
439 ) : action === "summarize" ? (
440
441 <p className="whitespace-pre-wrap break-words text-foreground">{sb.newText}</p>
442 ) : (
443 <p className="whitespace-pre-wrap break-words">{renderSegments(wordDiff(old, sb.newText), sb.blockId)}</p>
444 )}
445 </div>
446 </div>
447 );
448 })}
449
450 {nonConflictFailures.length > 0 ? (
451 <div role="alert" className="space-y-1 rounded-md border border-red-300 bg-red-50 p-2.5 text-xs text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
452 <p className="font-medium">Some blocks could not be applied:</p>
453 <ul className="list-inside list-disc">
454 {nonConflictFailures.map((r) => (
455 <li key={r.blockId}>
456 {r.blockId.slice(0, 8)} — {r.message ?? r.code}
457 </li>
458 ))}
459 </ul>
460 </div>
461 ) : null}
462 </div>
463 ) : null}
464
465 {status === "done" && suggestion && suggestion.blocks.length === 0 ? (
466 <p className="text-xs text-muted-foreground">The model returned no edits for this selection.</p>
467 ) : null}
468 </div>
469
470 {}
471 <div className="sr-only" role="status" aria-live="polite">
472 {streaming
473 ? "AI is writing a suggestion"
474 : showDiff
475 ? `${suggestion!.blocks.length} suggested edit${suggestion!.blocks.length === 1 ? "" : "s"} ready to accept`
476 : error
477 ? `Error: ${error.message}`
478 : ""}
479 </div>
480
481 {}
482 <div className="flex shrink-0 items-center justify-between gap-2 border-t border-border p-3">
483 <button
484 type="button"
485 data-testid="writing-discard"
486 onClick={close}
487 disabled={applying || streaming}
488 className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-40"
489 >
490 <X className="size-3.5" aria-hidden /> Discard
491 </button>
492 <div className="flex items-center gap-2">
493 {applying ? (
494 <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
495 <Loader2 className="size-3.5 animate-spin" aria-hidden /> Applying…
496 </span>
497 ) : null}
498 <button
499 type="button"
500 data-testid="writing-accept"
501 onClick={() => void apply()}
502 disabled={!showDiff || applying || streaming || (showDiff && conflictIds.size > 0)}
503 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
504 >
505 <CheckCheck className="size-3.5" aria-hidden /> Accept
506 </button>
507 </div>
508 </div>
509 </div>
510 </div>
511 );
512}
513