feat(open-swe): copy-plan-as-markdown button + fix premature "ready" banner (#1603)

- 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".
This commit is contained in:
Ramon Nogueira 2026-06-24 15:07:25 -04:00 • committed by GitHub
parent 714914b14c
commit 534f402f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 4 deletions

View file

@ -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);

View file

@ -81,9 +81,11 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
>
<span className="flex items-center gap-2">
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
{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."}
</span>
<span className="font-medium text-[var(--ui-accent)]">
Review plan →

View file

@ -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<boolean> {
// 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<Array<PlanComment>>([])
@ -22,6 +54,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
const [decision, setDecision] = useState<string | null>(null)
const [busy, setBusy] = useState<"approve" | "reject" | null>(null)
const [error, setError] = useState<string | null>(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 (
<div
data-testid="plan-review"
@ -116,6 +159,14 @@ export function PlanReview({ plan }: { plan: PlanData }) {
{decision}
</span>
)}
<Button
data-testid="copy-plan"
variant="secondary"
disabled={!plan.markdown.trim()}
onClick={() => void copyPlan()}
>
{copied ? "Copied!" : "Copy markdown"}
</Button>
{plan.isOwner && (
<Button
data-testid="approve-plan"