"use client"; /** * TASK-0123 — the writing suggestion dialog (accept-diff). * * Flow: the caller (selection toolbar / block context menu) opens it with * `{ pageId, blockIds, action, language? }`. It streams the suggestion * (SSE — `sendWriting`), renders a per-block diff (word-level via * `@factory/editor`'s `wordDiff` for improve/rewrite/translate; the summary * text in place for summarize), then offers: * * - Accept — apply ALL listed blocks (`writingApi.apply`), per-block * failures are listed, never silently dropped; * - Accept one — apply a single block (`blockIds: [id]`); * - Discard — no API call (UI-only; the persisted suggestion stays for the * cost ledger, per spec); * - Rebase prompt — a per-block 409 (concurrent edit) offers a re-run. * * a11y (mirrors ai-panel.tsx): role="dialog" aria-modal, focus trap, Escape * closes (and aborts the stream), focus restored to the trigger, and an * aria-live region announces "N suggested edits" / the error. * * Pinned testids (tests/e2e/specs/ai-writing.spec.ts — do not rename): * writing-diff the dialog * writing-diff-block one block's diff card * writing-accept the Accept (all) button * writing-accept-one-* per-block Accept one (id = blockId) * writing-discard the Discard button * writing-rebase-prompt the per-block 409 rebase offer * writing-error-banner the error banner (TASK-0122 semantics) * writing-loading the shimmer placeholder while streaming */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Check, CheckCheck, Loader2, RefreshCw, RotateCcw, Sparkles, X } from "lucide-react"; import { wordDiff, type DiffSegment } from "@factory/editor"; import { AiPreStreamError, blockPlainText, sendWriting, writingApi, WRITING_LANGUAGES, type WritingAction, type WritingApplyResult, type WritingSuggestion } from "@/lib/ai"; import type { Block } from "@/lib/types"; import { cn } from "@/lib/utils"; /* --------------------------------------------------------------- helpers */ // blockPlainText lives in lib/ai.ts (imported below) so it is unit-testable // without importing this React component. const ACTION_LABEL: Record = { summarize: "Summary", rewrite: "Rewrite", improve: "Improved writing", translate: "Translation" }; function languageLabel(id: string): string { return WRITING_LANGUAGES.find((l) => l.id === id)?.label ?? id; } /** One diff card's word-level segments (same / del / add). */ function renderSegments(segs: DiffSegment[], keyPrefix: string) { return segs.map((s, i) => { const k = `${keyPrefix}-${i}`; if (s.type === "same") return {s.text}; if (s.type === "del") return ( {s.text} ); return ( {s.text} ); }); } /* -------------------------------------------------------------- component */ export interface WritingDialogProps { open: boolean; workspaceId: string; pageId: string; /** The selected blocks (the diff's old side + the request). */ blocks: Block[]; action: WritingAction; language?: string; onClose: () => void; } interface FlowError { code: string; message: string; retryable?: boolean; retryAfterSeconds?: number; } export function WritingDialog({ open, workspaceId, pageId, blocks, action, language, onClose }: WritingDialogProps) { const qc = useQueryClient(); const [status, setStatus] = useState<"idle" | "streaming" | "done" | "error">("idle"); const [suggestion, setSuggestion] = useState(null); const [suggestionId, setSuggestionId] = useState(null); const [notes, setNotes] = useState(""); const [error, setError] = useState(null); const [applyResults, setApplyResults] = useState(null); const [applying, setApplying] = useState(false); const [rebaseIds, setRebaseIds] = useState>(new Set()); const dialogRef = useRef(null); const triggerRef = useRef(null); const abortRef = useRef(null); const blockById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks]); /* ----------------------------------------------------------- the run */ const run = useCallback( async (ids: string[]) => { setStatus("streaming"); setSuggestion(null); setSuggestionId(null); setNotes(""); setError(null); setApplyResults(null); setRebaseIds(new Set()); const controller = new AbortController(); abortRef.current = controller; try { const res = await sendWriting({ workspaceId, pageId, blockIds: ids, action, ...(language ? { language } : {}), signal: controller.signal }); if (res.reason === "aborted") { setStatus("idle"); return; } if (res.reason !== "ok" || !res.suggestion) { setStatus("error"); setError({ code: "AI_STREAM_ERROR", message: "The suggestion stream ended unexpectedly." }); return; } setSuggestion(res.suggestion); setSuggestionId(res.suggestionId ?? null); setNotes(res.notes ?? ""); setStatus("done"); } catch (err) { if (err instanceof AiPreStreamError) { setStatus("error"); setError({ code: err.code, message: err.message, retryable: err.retryAfterSeconds != null, retryAfterSeconds: err.retryAfterSeconds }); } else { setStatus("error"); setError({ code: "NETWORK_ERROR", message: "Could not reach the server." }); } } finally { abortRef.current = null; } }, [workspaceId, pageId, action, language] ); // Kick off the stream when opened (and reset on parameter change). useEffect(() => { if (!open) return; const ids = blocks.map((b) => b.id); if (ids.length > 0) void run(ids); return () => abortRef.current?.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, pageId, action, language]); /* ------------------------------------------------- close + focus trap */ const close = useCallback(() => { abortRef.current?.abort(); onClose(); requestAnimationFrame(() => { triggerRef.current?.focus(); triggerRef.current = null; }); }, [onClose]); useEffect(() => { if (!open) return; const prev = document.activeElement as HTMLElement | null; triggerRef.current = prev; requestAnimationFrame(() => dialogRef.current?.querySelector("button, [tabindex]")?.focus()); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); close(); } else if (e.key === "Tab" && dialogRef.current) { const focusables = dialogRef.current.querySelectorAll('button, [tabindex]:not([tabindex="-1"])'); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (!first || !last) return; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, close]); /* ------------------------------------------------------------- apply */ const invalidate = useCallback(() => { void qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] }); }, [qc, pageId]); const apply = useCallback( async (onlyBlockId?: string) => { if (!suggestionId || applying) return; setApplying(true); setApplyResults(null); try { const res = await writingApi.apply(workspaceId, { suggestionId, ...(onlyBlockId ? { blockIds: [onlyBlockId] } : {}) }); setApplyResults(res.results); invalidate(); const failed = res.results.filter((r) => r.status === "failed"); if (failed.length === 0) { // all applied — the dialog can go (Discard is the other exit). close(); } } catch (err) { const e = err as { code?: string; message?: string }; setError({ code: e.code ?? "APPLY_ERROR", message: e.message ?? "Apply failed." }); } finally { setApplying(false); } }, [suggestionId, applying, workspaceId, invalidate, close] ); /** The rebase path: a 409 means the block moved on — drop it from the * selection, re-run the suggestion for the rest (fresh versions). */ const rebase = useCallback( (blockId: string) => { setRebaseIds((s) => { const next = new Set(s); next.delete(blockId); return next; }); const remaining = blocks.map((b) => b.id).filter((id) => id !== blockId); if (remaining.length === 0) { close(); return; } // refresh the local blocks first (the conflicting one moved) so the // re-run diffs against the CURRENT text. void qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] }); void run(remaining); }, [blocks, pageId, run, close, qc] ); if (!open) return null; const conflictIds = new Set((applyResults ?? []).filter((r) => r.status === "failed" && r.code === "CONFLICT").map((r) => r.blockId)); const nonConflictFailures = (applyResults ?? []).filter((r) => r.status === "failed" && r.code !== "CONFLICT"); const budgetError = error && (error.code === "AI_BUDGET_EXCEEDED" || error.code === "AI_BUDGET_MONTHLY_EXCEEDED"); const streaming = status === "streaming"; const showDiff = status === "done" && suggestion && suggestion.blocks.length > 0; return (
{/* Header */}

{ACTION_LABEL[action]} {action === "translate" && language ? → {languageLabel(language)} : null}

{blocks.length} block{blocks.length === 1 ? "" : "s"}
{/* Body */}
{streaming ? (

Generating…

{blocks.slice(0, 4).map((b) => (
))}
) : null} {status === "error" && error ? (

{budgetError ? "Monthly AI budget reached" : error.code === "RATE_LIMITED" ? "Sending too fast" : error.code === "PAYLOAD_TOO_LARGE" ? "Selection too large" : "Something went wrong"}

{error.message}

{budgetError ? (

Your workspace has hit its monthly token limit. Ask an admin to raise it in Settings → AI.

) : null}
{error.retryable ? ( ) : null}
) : null} {notes ? (

{notes}

) : null} {showDiff ? (
{suggestion!.blocks.map((sb) => { const old = blockPlainText(blockById.get(sb.blockId)?.content ?? ""); const isConflict = conflictIds.has(sb.blockId); const isRebasing = rebaseIds.has(sb.blockId); return (
{sb.blockId.slice(0, 8)} {isConflict ? changed by someone else : null} {!streaming && !isConflict && !isRebasing ? ( ) : null}
{isConflict ? (

This block was edited by someone else since the suggestion was made.

) : isRebasing ? (

Skipped (rebasing the rest).

) : action === "summarize" ? ( /* summarize: the summary in place of the span (no word diff). */

{sb.newText}

) : (

{renderSegments(wordDiff(old, sb.newText), sb.blockId)}

)}
); })} {nonConflictFailures.length > 0 ? (

Some blocks could not be applied:

    {nonConflictFailures.map((r) => (
  • {r.blockId.slice(0, 8)} — {r.message ?? r.code}
  • ))}
) : null}
) : null} {status === "done" && suggestion && suggestion.blocks.length === 0 ? (

The model returned no edits for this selection.

) : null}
{/* aria-live summary (the diff is announced, not just painted) */}
{streaming ? "AI is writing a suggestion" : showDiff ? `${suggestion!.blocks.length} suggested edit${suggestion!.blocks.length === 1 ? "" : "s"} ready to accept` : error ? `Error: ${error.message}` : ""}
{/* Footer (pinned on mobile — the Accept bar sticks to the sheet). */}
{applying ? ( Applying… ) : null}
); }