Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 120 additions & 23 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,18 @@ import {
buildPlanImplementationPrompt,
resolvePlanFollowUpSubmission,
} from "../proposedPlan";
import {
appendPlanReviewFeedbackToDraft,
formatPlanReviewFeedback,
type PlanReviewAnnotation,
} from "../proposedPlanReview";
import {
DEFAULT_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
DEFAULT_THREAD_TERMINAL_ID,
MAX_TERMINALS_PER_GROUP,
type ChatMessage,
type ProposedPlan,
type SessionPhase,
type Thread,
type TurnDiffSummary,
Expand Down Expand Up @@ -146,6 +152,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { PlanReviewPanel } from "./PlanReviewPanel";
import { ChatHeader } from "./chat/ChatHeader";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { NoActiveThreadState } from "./NoActiveThreadState";
Expand Down Expand Up @@ -704,9 +711,14 @@ export default function ChatView(props: ChatViewProps) {
const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
useState<Record<string, number>>({});
const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
const [reviewingProposedPlan, setReviewingProposedPlan] = useState<ProposedPlan | null>(null);
const [planReviewAnnotationsByPlanId, setPlanReviewAnnotationsByPlanId] = useState<
Record<string, PlanReviewAnnotation[]>
>({});
const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
// Tracks whether the user explicitly dismissed the sidebar for the active turn.
const planSidebarDismissedForTurnRef = useRef<string | null>(null);
const planSidebarWasOpenBeforeReviewRef = useRef(false);
// When set, the thread-change reset effect will open the sidebar instead of closing it.
// Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
const planSidebarOpenOnNextThreadRef = useRef(false);
Expand Down Expand Up @@ -2135,6 +2147,7 @@ export default function ChatView(props: ChatViewProps) {
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
if (open) {
setReviewingProposedPlan(null);
planSidebarDismissedForTurnRef.current =
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
} else {
Expand All @@ -2144,10 +2157,61 @@ export default function ChatView(props: ChatViewProps) {
});
}, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
const closePlanSidebar = useCallback(() => {
setReviewingProposedPlan(null);
setPlanSidebarOpen(false);
planSidebarDismissedForTurnRef.current =
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
}, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
const openPlanReview = useCallback(
(proposedPlan: ProposedPlan) => {
planSidebarWasOpenBeforeReviewRef.current = planSidebarOpen;
setReviewingProposedPlan(proposedPlan);
planSidebarDismissedForTurnRef.current = null;
setPlanSidebarOpen(true);
},
[planSidebarOpen],
);
const closePlanReview = useCallback(() => {
setReviewingProposedPlan(null);
setPlanSidebarOpen(planSidebarWasOpenBeforeReviewRef.current);
}, []);
const updatePlanReviewAnnotations = useCallback(
(annotations: PlanReviewAnnotation[]) => {
if (!reviewingProposedPlan) return;
setPlanReviewAnnotationsByPlanId((existing) => ({
...existing,
[reviewingProposedPlan.id]: annotations,
}));
},
[reviewingProposedPlan],
);
const finishPlanReview = useCallback(() => {
if (!reviewingProposedPlan) return;
const annotations = planReviewAnnotationsByPlanId[reviewingProposedPlan.id] ?? [];
const feedback = formatPlanReviewFeedback(annotations);
if (feedback.trim().length === 0) return;

const nextPrompt = appendPlanReviewFeedbackToDraft(promptRef.current, feedback);
const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length);
promptRef.current = nextPrompt;
setComposerDraftPrompt(composerDraftTarget, nextPrompt);
composerRef.current?.resetCursorState({
cursor: nextCursor,
prompt: nextPrompt,
detectTrigger: true,
});
setReviewingProposedPlan(null);
setPlanSidebarOpen(planSidebarWasOpenBeforeReviewRef.current);
window.requestAnimationFrame(() => {
composerRef.current?.focusAt(nextCursor);
});
}, [
composerDraftTarget,
composerRef,
planReviewAnnotationsByPlanId,
reviewingProposedPlan,
setComposerDraftPrompt,
]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annotations not cleared after finishing plan review

Medium Severity

finishPlanReview appends annotation feedback to the composer draft but never clears the annotations from planReviewAnnotationsByPlanId for the finished plan. If the user re-opens the review for the same plan, the old annotations are still present and clicking "Done" again will duplicate the feedback in the composer.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 52f5740. Configure here.


const persistThreadSettingsForNextTurn = useCallback(
async (input: {
Expand Down Expand Up @@ -2227,6 +2291,9 @@ export default function ChatView(props: ChatViewProps) {

useEffect(() => {
setPullRequestDialogState(null);
setReviewingProposedPlan(null);
setPlanReviewAnnotationsByPlanId({});
planSidebarWasOpenBeforeReviewRef.current = false;
isAtEndRef.current = true;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
Expand Down Expand Up @@ -3568,6 +3635,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadEnvironmentId={activeThread.environmentId}
routeThreadKey={routeThreadKey}
onOpenTurnDiff={onOpenTurnDiff}
onReviewProposedPlan={openPlanReview}
revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
onRevertUserMessage={onRevertUserMessage}
isRevertingCheckpoint={isRevertingCheckpoint}
Expand Down Expand Up @@ -3725,17 +3793,31 @@ export default function ChatView(props: ChatViewProps) {

{/* Plan sidebar */}
{planSidebarOpen && !shouldUsePlanSidebarSheet ? (
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
mode="sidebar"
onClose={closePlanSidebar}
/>
reviewingProposedPlan ? (
<div className="h-full w-[min(760px,50vw)] min-w-[420px] shrink-0 border-l border-border/70">
<PlanReviewPanel
proposedPlan={reviewingProposedPlan}
markdownCwd={gitCwd ?? undefined}
annotations={planReviewAnnotationsByPlanId[reviewingProposedPlan.id] ?? []}
onAnnotationsChange={updatePlanReviewAnnotations}
onDone={finishPlanReview}
onBack={closePlanReview}
/>
</div>
) : (
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
mode="sidebar"
onClose={closePlanSidebar}
onReviewPlan={openPlanReview}
/>
)
) : null}
</div>
{/* end horizontal flex container */}
Expand All @@ -3758,18 +3840,33 @@ export default function ChatView(props: ChatViewProps) {
/>
))}
{shouldUsePlanSidebarSheet ? (
<RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}>
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
mode="sheet"
onClose={closePlanSidebar}
/>
<RightPanelSheet
open={planSidebarOpen}
onClose={reviewingProposedPlan ? closePlanReview : closePlanSidebar}
>
{reviewingProposedPlan ? (
<PlanReviewPanel
proposedPlan={reviewingProposedPlan}
markdownCwd={gitCwd ?? undefined}
annotations={planReviewAnnotationsByPlanId[reviewingProposedPlan.id] ?? []}
onAnnotationsChange={updatePlanReviewAnnotations}
onDone={finishPlanReview}
onBack={closePlanReview}
/>
) : (
<PlanSidebar
activePlan={activePlan}
activeProposedPlan={sidebarProposedPlan}
label={planSidebarLabel}
environmentId={environmentId}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeWorkspaceRoot}
timestampFormat={timestampFormat}
mode="sheet"
onClose={closePlanSidebar}
onReviewPlan={openPlanReview}
/>
)}
</RightPanelSheet>
) : null}

Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/components/PlanReviewPanel.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import "../index.css";

import { useState } from "react";
import { page } from "vitest/browser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";

import type { ProposedPlan } from "../types";
import type { PlanReviewAnnotation } from "../proposedPlanReview";
import { PlanReviewPanel } from "./PlanReviewPanel";

const PROPOSED_PLAN: ProposedPlan = {
id: "plan-review-browser-test" as never,
turnId: null,
planMarkdown: "# Reviewable plan\n\n- Keep composer stable.\n- Add success criteria.",
implementedAt: null,
implementationThreadId: null,
createdAt: "2026-05-29T00:00:00.000Z",
updatedAt: "2026-05-29T00:00:00.000Z",
};

function findTextNode(root: Node, text: string): Text {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.textContent?.includes(text)) {
return node as Text;
}
node = walker.nextNode();
}
throw new Error(`Unable to find text node containing "${text}".`);
}

function selectText(root: HTMLElement, text: string) {
const textNode = findTextNode(root, text);
const start = textNode.textContent?.indexOf(text) ?? -1;
if (start < 0) {
throw new Error(`Unable to find selection text "${text}".`);
}
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, start + text.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
}

function Harness(props: { onDone: () => void }) {
const [annotations, setAnnotations] = useState<PlanReviewAnnotation[]>([]);
return (
<div className="h-[720px] w-[720px]">
<PlanReviewPanel
proposedPlan={PROPOSED_PLAN}
markdownCwd={undefined}
annotations={annotations}
onAnnotationsChange={setAnnotations}
onDone={props.onDone}
onBack={vi.fn()}
/>
</div>
);
}

describe("PlanReviewPanel", () => {
afterEach(() => {
window.getSelection()?.removeAllRanges();
document.body.innerHTML = "";
});

it("creates a visual comment from selected plan text and enables done", async () => {
const onDone = vi.fn();
await render(<Harness onDone={onDone} />);

let reviewRoot: HTMLElement | null = null;
await vi.waitFor(() => {
reviewRoot = document.querySelector<HTMLElement>('[data-testid="plan-review-markdown"]');
expect(reviewRoot).toBeTruthy();
});
selectText(reviewRoot!, "Add success criteria.");

await page.getByText("Comment", { exact: true }).click();
await page.getByPlaceholder("Add a comment").fill("Needs acceptance coverage.");
await page.getByText("Save", { exact: true }).click();

await expect.element(page.getByText("Needs acceptance coverage.")).toBeInTheDocument();
await expect.element(page.getByText("Add success criteria.").first()).toBeInTheDocument();

await page.getByText("Done", { exact: true }).click();
expect(onDone).toHaveBeenCalledTimes(1);
});
});
Loading
Loading