"use client"; import { CheckIcon, XIcon } from "lucide-react"; import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { Fragment, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type GhostTone = "info" | "brand" | "destructive"; export interface Ghostwriter { id: string; name: string; tone: GhostTone; role?: string; } export type DocPiece = string | { id: string; text: string }; export interface GhostSuggestion { id: string; agent: string; target: string; mode: "replace" | "after"; text: string; from: string; note?: string; at: number; } export interface GhostwritersProps { title?: string; agents?: Ghostwriter[]; paragraphs?: DocPiece[][]; suggestions?: GhostSuggestion[]; autoplay?: boolean; className?: string; } type Decision = "accepted" | "rejected"; const tones: Record = { info: { solid: "bg-info", chip: "bg-blue-600 text-white", soft: "bg-info/14", mark: "bg-info/22", line: "decoration-info" }, brand: { solid: "bg-brand", chip: "bg-brand text-brand-foreground", soft: "bg-brand/30", mark: "bg-brand/45", line: "decoration-brand-ink" }, destructive: { solid: "bg-destructive", chip: "bg-red-600 text-white", soft: "bg-destructive/12", mark: "bg-destructive/20", line: "decoration-destructive" }, }; const defaultAgents: Ghostwriter[] = [ { id: "editor", name: "Editor", tone: "info", role: "Tightens loose wording" }, { id: "facts", name: "Fact-check", tone: "destructive", role: "Checks every number" }, { id: "scout", name: "Scout", tone: "brand", role: "Fills in what readers will ask" }, ]; const defaultParagraphs: DocPiece[][] = [ [ { id: "h1", text: "Halcyon" }, " 3.2 makes exports ", { id: "fast", text: "really fast" }, ". Beta teams said scheduled reports saved them ", { id: "pct", text: "12%" }, " of their ", { id: "h2", text: "week" }, ".", ], [ "Admins also get an ", { id: "h3", text: "audit log" }, " that keeps every change for ", { id: "tail", text: "90 days." }, ], ]; const defaultSuggestions: GhostSuggestion[] = [ { id: "s1", agent: "editor", target: "fast", mode: "replace", text: "up to 4× faster", from: "h1", at: 300 }, { id: "s2", agent: "facts", target: "pct", mode: "replace", text: "9%", from: "h2", note: "beta survey, n=48", at: 1900 }, { id: "s3", agent: "scout", target: "tail", mode: "after", text: " It’s on for every workspace.", from: "h3", at: 3500 }, ]; const press = "transition-[box-shadow,scale] duration-150 ease-out active:scale-[0.97]"; const APPEAR = 500; const MOVE = 700; const SELECT = 850; const CHAR = 42; type Phase = "hidden" | "home" | "moving" | "selecting" | "typing" | "pending"; function phaseOf(s: GhostSuggestion, e: number): { phase: Phase; typed: number } { const t0 = s.at; if (e < t0) return { phase: "hidden", typed: 0 }; if (e < t0 + APPEAR) return { phase: "home", typed: 0 }; if (e < t0 + APPEAR + MOVE) return { phase: "moving", typed: 0 }; const sel = s.mode === "replace" ? SELECT : 300; if (e < t0 + APPEAR + MOVE + sel) return { phase: "selecting", typed: 0 }; const typeStart = t0 + APPEAR + MOVE + sel; const typed = Math.floor((e - typeStart) / CHAR); if (typed < s.text.length) return { phase: "typing", typed: Math.max(0, typed) }; return { phase: "pending", typed: s.text.length }; } function endOf(s: GhostSuggestion) { return s.at + APPEAR + MOVE + (s.mode === "replace" ? SELECT : 300) + s.text.length * CHAR; } export function Ghostwriters({ title = "Halcyon 3.2 launch notes", agents = defaultAgents, paragraphs = defaultParagraphs, suggestions = defaultSuggestions, autoplay = true, className, }: GhostwritersProps) { const [elapsed, setElapsed] = useState(0); const [decisions, setDecisions] = useState>({}); const [focus, setFocus] = useState(null); const [positions, setPositions] = useState>({}); const docRef = useRef(null); const lastInput = useRef(0); const doneAt = useRef(null); const clock = useRef(0); const agentById = useCallback((id: string) => agents.find((a) => a.id === id) ?? agents[0], [agents]); const texts = new Map(); for (const p of paragraphs) for (const piece of p) if (typeof piece !== "string") texts.set(piece.id, piece.text); const scriptEnd = Math.max(...suggestions.map(endOf)); useEffect(() => { let last = performance.now(); const id = window.setInterval(() => { const now = performance.now(); clock.current += now - last; last = now; setElapsed(clock.current); }, 40); return () => window.clearInterval(id); }, []); useEffect(() => { const all = suggestions.every((s) => decisions[s.id]); if (all) { if (doneAt.current === null) doneAt.current = elapsed; if (elapsed - doneAt.current > 2600) { clock.current = 0; doneAt.current = null; setDecisions({}); setElapsed(0); } return; } if (!autoplay) return; const quiet = performance.now() - lastInput.current > 3500; const open = suggestions.filter((s) => !decisions[s.id]); const slot = Math.floor((elapsed - scriptEnd - 3200) / 1500); if (quiet && slot >= 0 && open.length) { const resolvedCount = suggestions.length - open.length; if (slot >= resolvedCount) { const next = open[0]; setDecisions((d) => ({ ...d, [next.id]: next.mode === "after" ? "rejected" : "accepted" })); } } }, [elapsed, decisions, suggestions, autoplay, scriptEnd]); const states = suggestions.map((s) => ({ s, ...phaseOf(s, elapsed), decision: decisions[s.id] })); const byTarget = new Map(states.map((st) => [st.s.target, st])); const byFrom = new Map(); for (const st of states) byFrom.set(st.s.from, [...(byFrom.get(st.s.from) ?? []), st]); useLayoutEffect(() => { const root = docRef.current; if (!root) return; const box = root.getBoundingClientRect(); const next: Record = {}; for (const el of root.querySelectorAll("[data-caret]")) { const r = el.getBoundingClientRect(); next[el.dataset.caret as string] = { x: r.left - box.left, y: r.top - box.top, h: r.height }; } const same = Object.keys(next).length === Object.keys(positions).length && Object.entries(next).every(([k, v]) => positions[k] && Math.abs(positions[k].x - v.x) < 0.5 && Math.abs(positions[k].y - v.y) < 0.5); if (!same) setPositions(next); }); const caret = (agentId: string) => ( {"\u2060"} ); const renderPiece = (piece: DocPiece, key: number): ReactNode => { if (typeof piece === "string") return {piece}; const homes = (byFrom.get(piece.id) ?? []).filter((st) => st.phase === "home"); const st = byTarget.get(piece.id); const homeCarets = homes.map((h) => {caret(h.s.agent)}); if (!st || st.phase === "hidden" || st.phase === "home") { return ( {homeCarets} {piece.text} ); } const t = tones[agentById(st.s.agent).tone]; const lit = focus === st.s.id; if (st.decision === "accepted") { return ( {homeCarets} {st.s.mode === "after" ? piece.text : null} {st.s.text} ); } if (st.decision === "rejected") return {homeCarets}{piece.text}; const typing = st.phase === "typing" || st.phase === "pending"; const original = st.phase === "moving" ? ( <> {caret(st.s.agent)} {piece.text} ) : st.phase === "selecting" ? ( <> {piece.text} {caret(st.s.agent)} ) : st.s.mode === "replace" ? ( {piece.text} ) : ( piece.text ); return ( {homeCarets} {original} {typing && ( {st.s.text.slice(0, st.typed)} {caret(st.s.agent)} )} ); }; const spring = { type: "spring" as const, stiffness: 320, damping: 30, mass: 0.8 }; const fade = { duration: 0.16, ease: [0.23, 1, 0.32, 1] as const }; const active = states.filter((st) => st.phase !== "hidden" && !st.decision); const decide = (id: string, d: Decision) => { lastInput.current = performance.now(); setDecisions((prev) => ({ ...prev, [id]: d })); setFocus(null); }; return (
{ lastInput.current = performance.now(); }} >
{title}
{active.length ? `${active.length} ${active.length === 1 ? "agent" : "agents"} editing` : "Draft · saved"}
{agents.map((a) => { const on = active.some((st) => st.s.agent === a.id); const t = tones[a.tone]; return ( {a.name[0]} ); })}
{/* An invisible copy with every suggestion written in reserves the height, so nothing below jumps while agents type. */}
{paragraphs.map((p, i) => (

{p.map((piece) => { if (typeof piece === "string") return piece; const sug = suggestions.find((x) => x.target === piece.id); return sug ? `${piece.text}${sug.mode === "replace" ? " " : ""}${sug.text}` : piece.text; })}

))}
{paragraphs.map((p, i) => (

{p.map(renderPiece)}

))}
{active.map((st) => { const pos = positions[st.s.agent]; if (!pos) return null; const a = agentById(st.s.agent); const t = tones[a.tone]; const h = Math.min(pos.h || 18, 20); const quiet = st.phase === "pending" && elapsed > endOf(st.s) + 1400; return ( {a.name} ); })}
    {states.length === 0 &&
  • No suggestions yet. Agents comment here as they read.
  • } {states.map((st) => { const a = agentById(st.s.agent); const t = tones[a.tone]; const pending = st.phase === "pending" && !st.decision; const writing = st.phase === "typing" || st.phase === "pending" || st.decision; const status = st.decision ? null : st.phase === "pending" ? (st.s.note ?? (st.s.mode === "after" ? "adds a sentence" : "suggests a rewrite")) : st.phase === "typing" ? "typing…" : st.phase === "selecting" ? "selecting…" : st.phase === "hidden" ? "waiting" : "reading…"; return (
  • setFocus(null)} onFocus={() => setFocus(st.s.id)} onPointerEnter={(e) => e.pointerType === "mouse" && setFocus(st.s.id)} onPointerLeave={() => setFocus(null)} >
    {a.name} {status && {status}}
    {writing ? ( {st.s.mode === "replace" && st.decision !== "accepted" && ( <> {texts.get(st.s.target)} → )} {st.s.mode === "after" ? `+ ${st.s.text.slice(0, st.typed).trim()}` : st.s.text.slice(0, st.typed)} ) : ( {a.role ?? "Reading the draft"} )}
    {st.decision ? ( {st.decision === "accepted" ? "Accepted" : "Rejected"} ) : pending ? ( ) : null}
  • ); })}
); }