import { type KeyboardEvent, useCallback, useEffect, useState } from "react" import { useNavigate } from "@tanstack/react-router" import type { PlanComment, PlanData } from "@/lib/plan" import { addPlanComment, approvePlan, deletePlanComment, getPlanComments, rejectPlan, updatePlan, } from "@/lib/plan" import { Button } from "@/components/ui/button" import { Markdown } from "@/components/agents/ported" import { useResolvedTheme } from "@/lib/theme" const POLL_MS = 4000 // Copy text to the clipboard across browsers: prefer the async Clipboard API // (needs a secure context), and fall back to a hidden-textarea + execCommand // for older Safari/Firefox and non-HTTPS origins. Returns whether it copied. async function copyToClipboard(text: string): Promise { // The DOM types mark navigator.clipboard required, but it's absent in older // browsers and non-secure origins — treat it as optional. const nav = navigator as { clipboard?: Clipboard } try { if (window.isSecureContext && nav.clipboard) { await nav.clipboard.writeText(text) return true } } catch { /* fall through to the legacy path */ } try { const textarea = document.createElement("textarea") textarea.value = text textarea.setAttribute("readonly", "") textarea.style.position = "fixed" textarea.style.top = "-9999px" document.body.appendChild(textarea) textarea.select() textarea.setSelectionRange(0, text.length) const ok = document.execCommand("copy") document.body.removeChild(textarea) return ok } catch { return false } } export function PlanReview({ plan }: { plan: PlanData }) { const navigate = useNavigate() const resolvedTheme = useResolvedTheme() const [comments, setComments] = useState>([]) const [draft, setDraft] = useState("") const [posting, setPosting] = useState(false) const [decision, setDecision] = useState(null) const [busy, setBusy] = useState<"approve" | "reject" | null>(null) const [error, setError] = useState(null) const [copied, setCopied] = useState(false) // Locally track the displayed markdown so a manual edit shows immediately; the // route's query stops polling once content exists, so the prop won't refetch. const [markdown, setMarkdown] = useState(plan.markdown) const [editing, setEditing] = useState(false) const [editDraft, setEditDraft] = useState(plan.markdown) const [saving, setSaving] = useState(false) // Reflect external plan updates (e.g. an agent revision) while not editing. useEffect(() => { if (!editing) setMarkdown(plan.markdown) }, [plan.markdown, editing]) const isShared = plan.status === "shared" const canEdit = plan.isOwner && !isShared && plan.status !== "approved" && plan.status !== "cancelled" const startEditing = useCallback(() => { setEditDraft(markdown) setEditing(true) setError(null) }, [markdown]) const cancelEditing = useCallback(() => { setEditing(false) setError(null) }, []) const saveEdit = useCallback(async () => { const next = editDraft.trim() if (!next) { setError("The plan cannot be empty.") return } setSaving(true) setError(null) try { const result = await updatePlan(plan.threadId, next) setMarkdown(result.markdown) setEditing(false) } catch (e) { setError((e as Error).message) } finally { setSaving(false) } }, [editDraft, plan.threadId]) // Poll so reviewers see each other's comments without a realtime transport. useEffect(() => { let cancelled = false const load = async () => { try { const next = await getPlanComments(plan.threadId) if (!cancelled) setComments(next) } catch { /* transient; next tick retries */ } } if (isShared) return void load() const timer = setInterval(load, POLL_MS) return () => { cancelled = true clearInterval(timer) } }, [isShared, plan.threadId]) const submitComment = useCallback(async () => { const body = draft.trim() if (!body) return setPosting(true) setError(null) try { const created = await addPlanComment(plan.threadId, body) setComments((prev) => [...prev, created]) setDraft("") } catch (e) { setError((e as Error).message) } finally { setPosting(false) } }, [draft, plan.threadId]) const handleCommentKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return event.preventDefault() if (posting || !draft.trim()) return void submitComment() }, [draft, posting, submitComment] ) const removeComment = useCallback( async (id: string) => { try { await deletePlanComment(plan.threadId, id) setComments((prev) => prev.filter((c) => c.id !== id)) } catch (e) { setError((e as Error).message) } }, [plan.threadId] ) const decide = useCallback( async (kind: "approve" | "reject") => { setBusy(kind) setError(null) try { if (kind === "approve") { await approvePlan(plan.threadId) await navigate({ to: "/agents/$threadId", params: { threadId: plan.threadId }, }) return } await rejectPlan(plan.threadId) setDecision("Changes requested — the agent is revising the plan.") } catch (e) { setError((e as Error).message) } finally { setBusy(null) } }, [navigate, plan.threadId] ) const copyPlan = useCallback(async () => { setError(null) if (await copyToClipboard(markdown)) { setCopied(true) window.setTimeout(() => setCopied(false), 1500) } else { setError("Couldn't copy the plan to the clipboard.") } }, [markdown]) return (

{isShared ? "Shared response" : "Implementation plan"}

{isShared ? "Viewing" : "Reviewing"} as {plan.user.name} {plan.isOwner ? " (owner)" : ""} · status:{" "} {plan.status}

{decision && ( {decision} )} {canEdit && ( )} {!isShared && plan.isOwner && ( )} {!isShared && ( )}
{editing ? (