diff --git a/tests/e2e/tests/plan_review.spec.ts b/tests/e2e/tests/plan_review.spec.ts index cd9032c7..e7637a7b 100644 --- a/tests/e2e/tests/plan_review.spec.ts +++ b/tests/e2e/tests/plan_review.spec.ts @@ -75,7 +75,9 @@ test.describe("Plan review (HTTP comments)", () => { // 3. The OWNER opens the conversation, follows the "Review plan" banner, and // sees the rendered plan. - const ownerCtx = await browser.newContext(); + const ownerCtx = await browser.newContext({ + permissions: ["clipboard-read", "clipboard-write"], + }); await ownerCtx.request.post("/control/login", { data: OWNER }); const owner = await ownerCtx.newPage(); await owner.goto(`/agents/${threadId}`); @@ -93,6 +95,13 @@ test.describe("Plan review (HTTP comments)", () => { // comment exists. await expect(owner.getByTestId("reject-plan")).toBeDisabled(); + // Copy the whole plan as markdown. + await owner.getByTestId("copy-plan").click(); + await expect(owner.getByTestId("copy-plan")).toContainText("Copied!"); + const clipboard = await owner.evaluate(() => navigator.clipboard.readText()); + expect(clipboard).toContain("## Plan: Add greet() helper"); + expect(clipboard).toContain("### Verification"); + // Owner leaves a comment. await addComment(owner, "Owner: looks solid, ship it."); await expect(owner.getByTestId("plan-comment")).toHaveCount(1); diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index cf1d7bb8..a6af2546 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -81,9 +81,11 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { > - {thread.planStatus === "revising" - ? "The agent is revising the plan." - : "A plan is ready for your review."} + {thread.planStatus === "ready" + ? "A plan is ready for your review." + : thread.planStatus === "revising" + ? "The agent is revising the plan." + : "The agent is writing a plan."} Review plan → diff --git a/ui/src/components/agents/PlanReview.tsx b/ui/src/components/agents/PlanReview.tsx index bafcf43c..1220c4a3 100644 --- a/ui/src/components/agents/PlanReview.tsx +++ b/ui/src/components/agents/PlanReview.tsx @@ -14,6 +14,38 @@ 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 resolvedTheme = useResolvedTheme() const [comments, setComments] = useState>([]) @@ -22,6 +54,7 @@ export function PlanReview({ plan }: { plan: PlanData }) { const [decision, setDecision] = useState(null) const [busy, setBusy] = useState<"approve" | "reject" | null>(null) const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) // Poll so reviewers see each other's comments without a realtime transport. useEffect(() => { @@ -91,6 +124,16 @@ export function PlanReview({ plan }: { plan: PlanData }) { [plan.threadId] ) + const copyPlan = useCallback(async () => { + setError(null) + if (await copyToClipboard(plan.markdown)) { + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } else { + setError("Couldn't copy the plan to the clipboard.") + } + }, [plan.markdown]) + return (
)} + {plan.isOwner && (