From 534f402f48383d50fd34c17789e9d6673c2c6226 Mon Sep 17 00:00:00 2001 From: Ramon Nogueira Date: Wed, 24 Jun 2026 15:07:25 -0400 Subject: [PATCH] feat(open-swe): copy-plan-as-markdown button + fix premature "ready" banner (#1603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "Copy markdown" button to the plan header that copies the whole plan. Cross-browser: async Clipboard API in secure contexts, hidden-textarea + execCommand fallback for older Safari/Firefox and non-HTTPS origins. - Fix the conversation banner: it showed "A plan is ready for your review" for every non-approved/cancelled status, including "planning" — so it claimed the plan was ready the instant plan mode began (the agent shares the link early to follow along), then the plan page correctly said it was still being written. Now: planning → "writing a plan", revising → "revising", ready → "ready for your review". --- tests/e2e/tests/plan_review.spec.ts | 11 ++++- ui/src/components/agents/AgentThreadView.tsx | 8 +-- ui/src/components/agents/PlanReview.tsx | 51 ++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) 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 && (