From 009064011addd7a7dc70a21a9f6533359184fbfc Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 25 Jun 2026 11:21:41 -0700 Subject: [PATCH] feat: editable plan mode + fix review-plan banner overlap (#1610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: editable plan mode + fix review-plan banner overlap Lets the thread owner edit the plan markdown by hand from the plan-review page (Edit -> textarea -> Save) via a new PUT /dashboard/api/plan/{id} endpoint that re-publishes the plan and mirrors it into the sandbox plan.md, so approve hands the edited plan to the agent as the source of truth. Also fixes the collapsed git-panel's floating expand button covering the "Review plan ->" banner by reserving space for it. Co-authored-by: open-swe[bot] * fix: abort plan approval when the published plan read fails get_plan_content() swallowed store errors and returned None, so a transient failure during approve would still mark the plan approved and dispatch the generic fallback text — silently dropping an owner's edited plan. Read the plan strictly (raise_on_error=True) so approval aborts instead, matching the comment read. Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] --- agent/dashboard/plan_api.py | 52 ++++- agent/dashboard/plan_store.py | 51 ++++- agent/tools/save_plan.py | 24 +-- tests/test_plan_review.py | 204 +++++++++++++++++++ ui/src/components/agents/AgentGitPanel.tsx | 34 ++-- ui/src/components/agents/AgentThreadView.tsx | 29 ++- ui/src/components/agents/PlanReview.tsx | 162 +++++++++++---- ui/src/lib/plan.ts | 10 + 8 files changed, 483 insertions(+), 83 deletions(-) diff --git a/agent/dashboard/plan_api.py b/agent/dashboard/plan_api.py index 7f381e29..671d410a 100644 --- a/agent/dashboard/plan_api.py +++ b/agent/dashboard/plan_api.py @@ -24,12 +24,16 @@ from pydantic import BaseModel from .oauth import require_same_origin_for_mutations, require_session from .plan_store import ( PLAN_STATUS_APPROVED, + PLAN_STATUS_CANCELLED, + PLAN_STATUS_READY, PLAN_STATUS_REVISING, add_plan_comment, delete_plan_comment, get_plan_content, list_plan_comments, + save_plan_content, set_plan_status, + write_plan_to_sandbox, ) from .thread_api import ( _repo_config_from_metadata, @@ -52,6 +56,10 @@ class CommentBody(BaseModel): body: str +class PlanUpdate(BaseModel): + markdown: str + + async def _thread_metadata(thread_id: str) -> dict[str, Any]: client = get_client() try: @@ -86,6 +94,32 @@ async def get_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -> di } +@plan_router.put("/{thread_id}") +async def update_plan( + thread_id: str, body: PlanUpdate, session: dict[str, Any] = _SESSION_DEP +) -> dict[str, Any]: + """Owner-only manual edit of the plan markdown. + + Re-publishes the edited plan as ``ready`` (and mirrors it into the sandbox + ``plan.md``) while preserving reviewer comments, so the owner can refine the + plan before approving it.""" + metadata = await _thread_metadata(thread_id) + if not _user_owns_thread(metadata, session["sub"], session.get("email")): + raise HTTPException(403, "only the plan owner can edit the plan") + markdown = body.markdown.strip() + if not markdown: + raise HTTPException(422, "plan markdown cannot be empty") + content = await get_plan_content(thread_id) or {} + status = content.get("status") or metadata.get("plan_status") or "planning" + if status in (PLAN_STATUS_APPROVED, PLAN_STATUS_CANCELLED): + raise HTTPException(409, f"cannot edit a {status} plan") + await save_plan_content( + thread_id, markdown=markdown, status=PLAN_STATUS_READY, clear_comments=False + ) + await write_plan_to_sandbox(thread_id, markdown) + return {"status": PLAN_STATUS_READY, "markdown": markdown} + + @plan_router.get("/{thread_id}/comments") async def get_plan_comments( thread_id: str, session: dict[str, Any] = _SESSION_DEP @@ -136,17 +170,25 @@ async def approve_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) - metadata = await _thread_metadata(thread_id) if not _user_owns_thread(metadata, session["sub"], session.get("email")): raise HTTPException(403, "only the plan owner can approve") - # Read comments BEFORE mutating state: a store failure here aborts the - # decision (500) rather than dispatching the run without the feedback. + # Read the published plan + comments BEFORE mutating state: a store failure + # here aborts the decision (500) rather than dispatching without them. The + # published markdown may have been edited by the reviewer, so it is the + # source of truth handed to the agent (not its own stale history) — read it + # strictly so a transient failure can't silently drop the edit. + content = await get_plan_content(thread_id, raise_on_error=True) or {} + plan_markdown = str(content.get("markdown", "")).strip() feedback = _format_comments(await list_plan_comments(thread_id, raise_on_error=True)) await set_plan_status(thread_id, PLAN_STATUS_APPROVED, plan_mode=False) - if feedback: + if plan_markdown: text = ( - "The plan has been approved. Implement it now, taking this reviewer " - f"feedback into account:\n\n{feedback}" + "The plan has been approved. Implement it now exactly as written " + "below (it may have been edited by the reviewer, so treat this as " + f"the source of truth):\n\n{plan_markdown}" ) else: text = "The plan has been approved. Implement it now as described in the plan." + if feedback: + text += "\n\nAlso take this reviewer feedback into account:\n\n" + feedback await _dispatch_followup(thread_id, metadata, text, plan_mode=False) return {"status": PLAN_STATUS_APPROVED} diff --git a/agent/dashboard/plan_store.py b/agent/dashboard/plan_store.py index b6098163..d12198a6 100644 --- a/agent/dashboard/plan_store.py +++ b/agent/dashboard/plan_store.py @@ -12,15 +12,21 @@ store operations (no CRDT/WebSocket). from __future__ import annotations +import logging import uuid from datetime import UTC, datetime from typing import Any from langgraph_sdk import get_client +logger = logging.getLogger(__name__) + PLAN_CONTENT_NAMESPACE = ["plan", "content"] PLAN_COMMENTS_NAMESPACE = ["plan", "comments"] +# The plan is mirrored into the sandbox as a real file the agent can re-read. +PLAN_FILE_PATH = "plan.md" + # Plan lifecycle, stored on both the content record and the thread metadata. PLAN_STATUS_PLANNING = "planning" PLAN_STATUS_READY = "ready" @@ -41,32 +47,61 @@ def _item_value(item: Any) -> dict[str, Any] | None: async def save_plan_content( - thread_id: str, *, markdown: str, status: str = PLAN_STATUS_READY + thread_id: str, + *, + markdown: str, + status: str = PLAN_STATUS_READY, + clear_comments: bool = True, ) -> None: """Publish the plan markdown + status for the dashboard to render. A republished (revised) plan supersedes the prior revision, so comments left on it are cleared — otherwise stale feedback would resurface on the new plan - and be fed back to the agent on the next approve/reject.""" + and be fed back to the agent on the next approve/reject. A manual owner edit + passes ``clear_comments=False`` so reviewer feedback survives the edit.""" client = _client() await client.store.put_item( PLAN_CONTENT_NAMESPACE, thread_id, {"markdown": markdown, "status": status}, ) - try: - await clear_plan_comments(thread_id) - except Exception: - # Best-effort: a failed cleanup must not block publishing the new plan. - pass + if clear_comments: + try: + await clear_plan_comments(thread_id) + except Exception: + # Best-effort: a failed cleanup must not block publishing the new plan. + pass await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True}) -async def get_plan_content(thread_id: str) -> dict[str, Any] | None: +async def write_plan_to_sandbox(thread_id: str, content: str) -> str: + """Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox + must not block publishing the plan to the review page.""" + try: + from ..utils.sandbox_state import get_sandbox_backend + + backend = await get_sandbox_backend(thread_id) + await backend.awrite(PLAN_FILE_PATH, content) + return PLAN_FILE_PATH + except Exception: + logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True) + return PLAN_FILE_PATH + + +async def get_plan_content( + thread_id: str, *, raise_on_error: bool = False +) -> dict[str, Any] | None: + """The published plan record, or ``None`` when none exists. + + With ``raise_on_error=True`` a store failure propagates instead of resolving + to ``None``. Approve uses this so a transient failure aborts the decision + rather than dispatching the agent without the (possibly edited) plan.""" client = _client() try: item = await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id) except Exception: + if raise_on_error: + raise return None return _item_value(item) diff --git a/agent/tools/save_plan.py b/agent/tools/save_plan.py index bab39953..62e9ac8b 100644 --- a/agent/tools/save_plan.py +++ b/agent/tools/save_plan.py @@ -14,12 +14,14 @@ from typing import Any from langgraph.config import get_config -from ..dashboard.plan_store import PLAN_STATUS_READY, save_plan_content +from ..dashboard.plan_store import ( + PLAN_STATUS_READY, + save_plan_content, + write_plan_to_sandbox, +) logger = logging.getLogger(__name__) -PLAN_FILE_PATH = "plan.md" - def save_plan(plan_markdown: str) -> dict[str, Any]: """Write your implementation plan as a markdown file and publish it for review. @@ -62,20 +64,6 @@ def save_plan(plan_markdown: str) -> dict[str, Any]: async def _save(thread_id: str, content: str) -> str: - sandbox_path = await _write_to_sandbox(thread_id, content) + sandbox_path = await write_plan_to_sandbox(thread_id, content) await save_plan_content(thread_id, markdown=content, status=PLAN_STATUS_READY) return sandbox_path - - -async def _write_to_sandbox(thread_id: str, content: str) -> str: - """Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox - must not block publishing the plan to the review page.""" - try: - from ..utils.sandbox_state import get_sandbox_backend - - backend = await get_sandbox_backend(thread_id) - await backend.awrite(PLAN_FILE_PATH, content) - return PLAN_FILE_PATH - except Exception: - logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True) - return PLAN_FILE_PATH diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py index b9a43035..a6de3d1e 100644 --- a/tests/test_plan_review.py +++ b/tests/test_plan_review.py @@ -176,3 +176,207 @@ def test_plan_mode_middleware_self_activation_via_state() -> None: # After enter_plan_mode sets state: the next request is filtered. on = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {"plan_mode": True}) assert _names(mw._filter(on)) == {"read_file"} + + +# --- manual plan editing ------------------------------------------------- + + +async def test_save_plan_content_clear_comments_flag(monkeypatch: pytest.MonkeyPatch) -> None: + from agent.dashboard import plan_store + + cleared: list[str] = [] + + class _Store: + async def put_item(self, *a: Any, **k: Any) -> None: + return None + + async def fake_clear(thread_id: str) -> None: + cleared.append(thread_id) + + async def fake_merge(thread_id: str, metadata: dict[str, Any]) -> None: + return None + + monkeypatch.setattr(plan_store, "_client", lambda: _fake_client(_Store())) + monkeypatch.setattr(plan_store, "clear_plan_comments", fake_clear) + monkeypatch.setattr(plan_store, "_merge_thread_metadata", fake_merge) + + # A manual edit keeps reviewer comments; the agent's republish clears them. + await plan_store.save_plan_content("t", markdown="x", clear_comments=False) + assert cleared == [] + await plan_store.save_plan_content("t", markdown="x") + assert cleared == ["t"] + + +def _patch_update_plan_deps( + monkeypatch: pytest.MonkeyPatch, + *, + metadata: dict[str, Any], + owner: bool, + content: dict[str, Any], + saved: dict[str, Any], + sandbox: dict[str, Any], +) -> None: + from agent.dashboard import plan_api + + async def fake_meta(thread_id: str) -> dict[str, Any]: + return metadata + + async def fake_get_content(thread_id: str) -> dict[str, Any]: + return content + + async def fake_save( + thread_id: str, *, markdown: str, status: str, clear_comments: bool = True + ) -> None: + saved.update(markdown=markdown, status=status, clear_comments=clear_comments) + + async def fake_write(thread_id: str, c: str) -> str: + sandbox["content"] = c + return "plan.md" + + monkeypatch.setattr(plan_api, "_thread_metadata", fake_meta) + monkeypatch.setattr(plan_api, "_user_owns_thread", lambda *a, **k: owner) + monkeypatch.setattr(plan_api, "get_plan_content", fake_get_content) + monkeypatch.setattr(plan_api, "save_plan_content", fake_save) + monkeypatch.setattr(plan_api, "write_plan_to_sandbox", fake_write) + + +async def test_update_plan_owner_saves_and_mirrors_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent.dashboard import plan_api + + saved: dict[str, Any] = {} + sandbox: dict[str, Any] = {} + _patch_update_plan_deps( + monkeypatch, + metadata={"plan_status": "ready"}, + owner=True, + content={"markdown": "old", "status": "ready"}, + saved=saved, + sandbox=sandbox, + ) + + result = await plan_api.update_plan( + "t1", plan_api.PlanUpdate(markdown="# New\n\ndo x"), session={"sub": "a", "email": None} + ) + assert result == {"status": "ready", "markdown": "# New\n\ndo x"} + assert saved["status"] == "ready" + assert saved["clear_comments"] is False + assert sandbox["content"] == "# New\n\ndo x" + + +async def test_update_plan_rejects_non_owner(monkeypatch: pytest.MonkeyPatch) -> None: + from fastapi import HTTPException + + from agent.dashboard import plan_api + + _patch_update_plan_deps(monkeypatch, metadata={}, owner=False, content={}, saved={}, sandbox={}) + with pytest.raises(HTTPException) as exc: + await plan_api.update_plan( + "t1", plan_api.PlanUpdate(markdown="x"), session={"sub": "b", "email": None} + ) + assert exc.value.status_code == 403 + + +async def test_update_plan_rejects_empty(monkeypatch: pytest.MonkeyPatch) -> None: + from fastapi import HTTPException + + from agent.dashboard import plan_api + + _patch_update_plan_deps(monkeypatch, metadata={}, owner=True, content={}, saved={}, sandbox={}) + with pytest.raises(HTTPException) as exc: + await plan_api.update_plan( + "t1", plan_api.PlanUpdate(markdown=" "), session={"sub": "a", "email": None} + ) + assert exc.value.status_code == 422 + + +async def test_update_plan_blocked_once_approved(monkeypatch: pytest.MonkeyPatch) -> None: + from fastapi import HTTPException + + from agent.dashboard import plan_api + + _patch_update_plan_deps( + monkeypatch, + metadata={"plan_status": "approved"}, + owner=True, + content={"markdown": "old", "status": "approved"}, + saved={}, + sandbox={}, + ) + with pytest.raises(HTTPException) as exc: + await plan_api.update_plan( + "t1", plan_api.PlanUpdate(markdown="x"), session={"sub": "a", "email": None} + ) + assert exc.value.status_code == 409 + + +async def test_approve_plan_dispatches_published_markdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent.dashboard import plan_api + + dispatched: dict[str, Any] = {} + + async def fake_meta(thread_id: str) -> dict[str, Any]: + return {"plan_status": "ready"} + + async def fake_get_content(thread_id: str, *, raise_on_error: bool = False) -> dict[str, Any]: + return {"markdown": "# Edited plan\n\nstep one", "status": "ready"} + + async def fake_list(thread_id: str, *, raise_on_error: bool = False) -> list[dict[str, Any]]: + return [{"author": "bob", "body": "use snake_case"}] + + async def fake_set_status(thread_id: str, status: str, *, plan_mode: Any = None) -> None: + return None + + async def fake_dispatch( + thread_id: str, metadata: dict[str, Any], text: str, *, plan_mode: bool + ) -> None: + dispatched.update(text=text, plan_mode=plan_mode) + + monkeypatch.setattr(plan_api, "_thread_metadata", fake_meta) + monkeypatch.setattr(plan_api, "_user_owns_thread", lambda *a, **k: True) + monkeypatch.setattr(plan_api, "get_plan_content", fake_get_content) + monkeypatch.setattr(plan_api, "list_plan_comments", fake_list) + monkeypatch.setattr(plan_api, "set_plan_status", fake_set_status) + monkeypatch.setattr(plan_api, "_dispatch_followup", fake_dispatch) + + result = await plan_api.approve_plan("t1", session={"sub": "a", "email": None}) + assert result["status"] == "approved" + # The (possibly edited) published plan is the source of truth, plus feedback. + assert "# Edited plan" in dispatched["text"] + assert "use snake_case" in dispatched["text"] + assert dispatched["plan_mode"] is False + + +async def test_approve_plan_aborts_when_plan_read_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent.dashboard import plan_api + + dispatched: list[Any] = [] + + async def fake_meta(thread_id: str) -> dict[str, Any]: + return {"plan_status": "ready"} + + async def fake_get_content(thread_id: str, *, raise_on_error: bool = False) -> dict[str, Any]: + # A transient store failure must abort approval, not silently drop the + # owner's edited plan and dispatch the generic fallback text. + raise RuntimeError("store down") + + async def fake_set_status(thread_id: str, status: str, *, plan_mode: Any = None) -> None: + return None + + async def fake_dispatch(*a: Any, **k: Any) -> None: + dispatched.append((a, k)) + + monkeypatch.setattr(plan_api, "_thread_metadata", fake_meta) + monkeypatch.setattr(plan_api, "_user_owns_thread", lambda *a, **k: True) + monkeypatch.setattr(plan_api, "get_plan_content", fake_get_content) + monkeypatch.setattr(plan_api, "set_plan_status", fake_set_status) + monkeypatch.setattr(plan_api, "_dispatch_followup", fake_dispatch) + + with pytest.raises(RuntimeError): + await plan_api.approve_plan("t1", session={"sub": "a", "email": None}) + assert dispatched == [] diff --git a/ui/src/components/agents/AgentGitPanel.tsx b/ui/src/components/agents/AgentGitPanel.tsx index 1090defb..a96fe1b0 100644 --- a/ui/src/components/agents/AgentGitPanel.tsx +++ b/ui/src/components/agents/AgentGitPanel.tsx @@ -42,6 +42,8 @@ import { cn } from "@/lib/utils" interface AgentGitPanelProps { thread: AgentThread messages: Array + collapsed: boolean + onCollapsedChange: (next: boolean) => void } interface PanelFile { @@ -117,7 +119,7 @@ function readStoredPanelWidth(): number { return clampPanelWidth(parsed) } -function readStoredPanelCollapsed(): boolean { +export function readStoredPanelCollapsed(): boolean { if (typeof window === "undefined") return true // Default to collapsed until the user opens it once. return ( @@ -126,6 +128,14 @@ function readStoredPanelCollapsed(): boolean { ) } +export function writeStoredPanelCollapsed(collapsed: boolean): void { + if (typeof window === "undefined") return + window.localStorage.setItem( + PANEL_STORAGE_COLLAPSED, + collapsed ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE + ) +} + function PanelResizeHandle({ width, onResize, @@ -267,12 +277,14 @@ export function treeThemeStyle(): React.CSSProperties { } as React.CSSProperties } -export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) { +export function AgentGitPanel({ + thread, + messages, + collapsed, + onCollapsedChange, +}: AgentGitPanelProps) { const [topTab, setTopTab] = useState<"git" | "desktop" | "terminal">("git") const [tab, setTab] = useState<"diff" | "review" | "commits">("diff") - const [collapsed, setCollapsedState] = useState(() => - readStoredPanelCollapsed() - ) const [width, setWidthState] = useState(() => readStoredPanelWidth()) const [fullScreen, setFullScreen] = useState(false) const isMobile = useIsMobile() @@ -281,15 +293,9 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) { const overlay = fullScreen || isMobile const panelRef = useRef(null) - const setCollapsed = (next: boolean) => { - setCollapsedState(next) - if (typeof window !== "undefined") { - window.localStorage.setItem( - PANEL_STORAGE_COLLAPSED, - next ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE - ) - } - } + // Collapsed state is owned by the parent (so the plan banner can reserve space + // for the floating expand button); persistence to localStorage lives there too. + const setCollapsed = onCollapsedChange const applyWidth = useCallback( (next: number) => { diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index a6af2546..4aca5309 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react" +import { useCallback, useMemo, useState } from "react" import { Link } from "@tanstack/react-router" import { useStreamContext as useAgentThreadStream } from "@langchain/react" import { Map as MapIcon } from "lucide-react" @@ -8,6 +8,8 @@ import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" import { AgentGitPanel, PANEL_MIN_CHAT_WIDTH, + readStoredPanelCollapsed, + writeStoredPanelCollapsed, } from "@/components/agents/AgentGitPanel" import { AgentPromptBar } from "@/components/agents/AgentPromptBar" import { Messages } from "@/components/agents/messages" @@ -15,6 +17,7 @@ import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi" import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage" import { useModelOptions } from "@/lib/agents/provider/useModelOptions" import { useIsMobile } from "@/lib/useIsMobile" +import { cn } from "@/lib/utils" interface AgentThreadViewProps { thread: AgentThread @@ -41,6 +44,16 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { const [planMode, setPlanMode] = useState(null) const activePlanMode = planMode ?? thread.planMode ?? false + // Own the git panel's collapsed state so the plan banner can reserve space for + // the floating expand button the panel renders while collapsed. + const [panelCollapsed, setPanelCollapsed] = useState(() => + readStoredPanelCollapsed() + ) + const handlePanelCollapsedChange = useCallback((next: boolean) => { + setPanelCollapsed(next) + writeStoredPanelCollapsed(next) + }, []) + const baseMessages = useMemo>(() => { const live = streamMessagesToUi( stream.messages, @@ -77,7 +90,12 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { to="/agents/$threadId/plan" params={{ threadId: thread.id }} data-testid="review-plan-link" - className="flex items-center justify-between gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-2 text-xs text-[var(--ui-text)] hover:bg-[var(--ui-panel-2)]" + className={cn( + "flex items-center justify-between gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-2 text-xs text-[var(--ui-text)] hover:bg-[var(--ui-panel-2)]", + // The collapsed panel floats a fixed expand button in the + // top-right corner; clear it so it never covers "Review plan →". + panelCollapsed && "pr-14" + )} > @@ -156,7 +174,12 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { )} - + ) } diff --git a/ui/src/components/agents/PlanReview.tsx b/ui/src/components/agents/PlanReview.tsx index 1220c4a3..1c6b5dd4 100644 --- a/ui/src/components/agents/PlanReview.tsx +++ b/ui/src/components/agents/PlanReview.tsx @@ -7,6 +7,7 @@ import { deletePlanComment, getPlanComments, rejectPlan, + updatePlan, } from "@/lib/plan" import { Button } from "@/components/ui/button" import { Markdown } from "@/components/agents/ported" @@ -55,6 +56,50 @@ export function PlanReview({ plan }: { plan: PlanData }) { 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 a plan 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 canEdit = + plan.isOwner && 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(() => { @@ -126,13 +171,13 @@ export function PlanReview({ plan }: { plan: PlanData }) { const copyPlan = useCallback(async () => { setError(null) - if (await copyToClipboard(plan.markdown)) { + if (await copyToClipboard(markdown)) { setCopied(true) window.setTimeout(() => setCopied(false), 1500) } else { setError("Couldn't copy the plan to the clipboard.") } - }, [plan.markdown]) + }, [markdown]) return (
)} - - {plan.isOwner && ( - + {editing ? ( + <> + + + + ) : ( + <> + {canEdit && ( + + )} + + {plan.isOwner && ( + + )} + + )} -
@@ -200,8 +279,21 @@ export function PlanReview({ plan }: { plan: PlanData }) { data-testid="plan-document" data-color-scheme={resolvedTheme} > - {plan.markdown.trim() ? ( - + {editing ? ( +
+ {error && ( +

{error}

+ )} +