From 8a158ae8ae678754f6d098b41bc24cff00df9631 Mon Sep 17 00:00:00 2001 From: Vladyslav Hrabovyi Date: Wed, 8 Apr 2026 21:16:40 +0100 Subject: [PATCH 1/3] feat(review): add Exit button for Pi agent mode (#522) --- apps/pi-extension/server.test.ts | 37 ++++ apps/pi-extension/server/serverReview.ts | 7 + packages/review-editor/App.tsx | 193 +++++++++++------- .../components/PiReviewActions.tsx | 75 +++++++ packages/server/review.ts | 10 + packages/ui/components/CompletionOverlay.tsx | 2 +- packages/ui/components/ToolbarButtons.tsx | 26 +++ 7 files changed, 274 insertions(+), 76 deletions(-) create mode 100644 packages/review-editor/components/PiReviewActions.tsx diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 763b3fb6..007ac539 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -214,6 +214,43 @@ describe("pi review server", () => { } }); + test("exit endpoint resolves decision with exit flag", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = initRepo(); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const gitContext = await getGitContext(); + const diff = await runGitDiff("uncommitted", gitContext.defaultBranch); + + const server = await startReviewServer({ + rawPatch: diff.patch, + gitRef: diff.label, + error: diff.error, + diffType: "uncommitted", + gitContext, + origin: "pi", + htmlContent: "review", + }); + + try { + const exitResponse = await fetch(`${server.url}/api/exit`, { method: "POST" }); + expect(exitResponse.status).toBe(200); + expect(await exitResponse.json()).toEqual({ ok: true }); + + await expect(server.waitForDecision()).resolves.toEqual({ + exit: true, + approved: false, + feedback: "", + annotations: [], + agentSwitch: undefined, + }); + } finally { + server.stop(); + } + }); + test("git-add endpoint stages and unstages files in review mode", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 6d2c6939..c69180aa 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -95,6 +95,7 @@ export interface ReviewServerResult { feedback: string; annotations: unknown[]; agentSwitch?: string; + exit?: boolean; }>; stop: () => void; } @@ -285,12 +286,14 @@ export async function startReviewServer(options: { feedback: string; annotations: unknown[]; agentSwitch?: string; + exit?: boolean; }) => void; const decisionPromise = new Promise<{ approved: boolean; feedback: string; annotations: unknown[]; agentSwitch?: string; + exit?: boolean; }>((r) => { resolveDecision = r; }); @@ -680,6 +683,10 @@ export async function startReviewServer(options: { return; } json(res, { error: "Not found" }, 404); + } else if (url.pathname === "/api/exit" && req.method === "POST") { + deleteDraft(draftKey); + resolveDecision({ approved: false, feedback: '', annotations: [], exit: true }); + json(res, { ok: true }); } else if (url.pathname === "/api/feedback" && req.method === "POST") { try { const body = await parseBody(req); diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index a752d628..d26cf59a 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -4,6 +4,7 @@ import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvide import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { Settings } from '@plannotator/ui/components/Settings'; import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons'; +import { PiReviewActions } from './components/PiReviewActions'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; @@ -158,7 +159,8 @@ const ReviewApp: React.FC = () => { const [diffError, setDiffError] = useState(null); const [isSendingFeedback, setIsSendingFeedback] = useState(false); const [isApproving, setIsApproving] = useState(false); - const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false); + const [isExiting, setIsExiting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false); const [showApproveWarning, setShowApproveWarning] = useState(false); const [sharingEnabled, setSharingEnabled] = useState(true); const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); @@ -1089,6 +1091,22 @@ const ReviewApp: React.FC = () => { } }, [totalAnnotationCount, feedbackMarkdown, allAnnotations]); + // Exit review session without sending any feedback + const handleExit = useCallback(async () => { + setIsExiting(true); + try { + const res = await fetch('/api/exit', { method: 'POST' }); + if (res.ok) { + setSubmitted('exited'); + } else { + throw new Error('Failed to exit'); + } + } catch (error) { + console.error('Failed to exit review:', error); + setIsExiting(false); + } + }, []); + // Approve without feedback (LGTM) const handleApprove = useCallback(async () => { setIsApproving(true); @@ -1279,7 +1297,7 @@ const ReviewApp: React.FC = () => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExportModal || showNoAnnotationsDialog || showApproveWarning) return; - if (submitted || isSendingFeedback || isApproving || isPlatformActioning) return; + if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; if (!origin) return; // Demo mode e.preventDefault(); @@ -1499,73 +1517,92 @@ const ReviewApp: React.FC = () => { )} - {/* Send Feedback button — always the same label */} - { - if (platformMode) { - setPlatformGeneralComment(''); - setPlatformCommentDialog({ action: 'comment' }); - } else { - handleSendFeedback(); - } - }} - disabled={ - isSendingFeedback || isApproving || isPlatformActioning || - (!platformMode && totalAnnotationCount === 0) - } - isLoading={isSendingFeedback || isPlatformActioning} - muted={!platformMode && totalAnnotationCount === 0 && !isSendingFeedback && !isApproving && !isPlatformActioning} - label={platformMode ? 'Post Comments' : 'Send Feedback'} - shortLabel={platformMode ? 'Post' : 'Send'} - loadingLabel={platformMode ? 'Posting...' : 'Sending...'} - shortLoadingLabel={platformMode ? 'Posting...' : 'Sending...'} - title={!platformMode && totalAnnotationCount === 0 ? "Add annotations to send feedback" : "Send feedback"} - /> - - {/* Approve button — always the same label */} -
- { - if (platformMode) { - if (platformUser && prMetadata?.author === platformUser) return; - setPlatformGeneralComment(''); - setPlatformCommentDialog({ action: 'approve' }); - } else { - if (totalAnnotationCount > 0) { - setShowApproveWarning(true); - } else { - handleApprove(); - } - } - }} - disabled={ - isSendingFeedback || isApproving || isPlatformActioning || - (platformMode && !!platformUser && prMetadata?.author === platformUser) - } - isLoading={isApproving} - dimmed={!platformMode && totalAnnotationCount > 0} - muted={platformMode && !!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning} - title={ - platformMode && platformUser && prMetadata?.author === platformUser - ? `You can't approve your own ${mrLabel}` - : "Approve - no changes needed" - } + {/* Pi agent mode: Exit/SendFeedback flip + Approve */} + {origin === 'pi' && !platformMode ? ( + totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} + onExit={handleExit} /> - {/* Tooltip: own PR warning OR annotations-lost warning */} - {platformMode && platformUser && prMetadata?.author === platformUser ? ( -
-
-
- You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}. + ) : !platformMode ? ( + <> + {/* Other agent mode: muted Send Feedback + Approve (original behavior) */} + +
+ totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} + disabled={isSendingFeedback || isApproving} + isLoading={isApproving} + dimmed={totalAnnotationCount > 0} + title="Approve - no changes needed" + /> + {totalAnnotationCount > 0 && ( +
+
+
+ Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve. +
+ )}
- ) : !platformMode && totalAnnotationCount > 0 ? ( -
-
-
- Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve. + + ) : ( + <> + {/* Platform mode: Post Comments + Approve */} + { + setPlatformGeneralComment(''); + setPlatformCommentDialog({ action: 'comment' }); + }} + disabled={isSendingFeedback || isApproving || isPlatformActioning} + isLoading={isSendingFeedback || isPlatformActioning} + label="Post Comments" + shortLabel="Post" + loadingLabel="Posting..." + shortLoadingLabel="Posting..." + title="Send feedback" + /> +
+ { + if (platformUser && prMetadata?.author === platformUser) return; + setPlatformGeneralComment(''); + setPlatformCommentDialog({ action: 'approve' }); + }} + disabled={ + isSendingFeedback || isApproving || isPlatformActioning || + (!!platformUser && prMetadata?.author === platformUser) + } + isLoading={isApproving} + muted={!!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning} + title={ + platformUser && prMetadata?.author === platformUser + ? `You can't approve your own ${mrLabel}` + : "Approve - no changes needed" + } + /> + {platformUser && prMetadata?.author === platformUser && ( +
+
+
+ You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}. +
+ )}
- ) : null} -
+ + )} ) : ( ); + +interface ExitButtonProps { + onClick: () => void; + disabled?: boolean; + isLoading?: boolean; +} + +export const ExitButton: React.FC = ({ + onClick, + disabled = false, + isLoading = false, +}) => ( + +); From a649a449c8a64bc0d421bfd239ef8a078b8d3980 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 9 Apr 2026 17:34:25 -0700 Subject: [PATCH 2/3] feat(review,annotate): generalize Close button to all agents + annotate mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Pi-specific exit/close feature to work across all agent origins (Claude Code, OpenCode, Copilot CLI, Codex, Gemini CLI) and adds the same capability to annotation sessions. - Rename PiReviewActions → AgentReviewActions, remove origin === 'pi' gate - Add /api/exit endpoint to both Bun and Pi annotate servers - Add Close button to platform mode (GitHub/GitLab PR review) - Close always visible; Send Feedback/Annotations appears alongside when annotations exist - Warning dialog when closing with unsent annotations - Handle exit in all calling layers (hook CLI, OpenCode, Pi extension) - Fix stale closures: isExiting and showExitWarning in keyboard handler deps - Fix openLastMessageAnnotation return type to include exit flag - Make ExitButton title a prop with generic default For provenance purposes, this commit was AI assisted. --- apps/hook/server/index.ts | 22 ++- apps/opencode-plugin/commands.ts | 12 ++ apps/pi-extension/index.ts | 12 +- apps/pi-extension/plannotator-browser.ts | 6 +- apps/pi-extension/server/serverAnnotate.ts | 8 +- bun.lock | 7 +- packages/editor/App.tsx | 141 +++++++++++++----- packages/review-editor/App.tsx | 72 ++++----- ...viewActions.tsx => AgentReviewActions.tsx} | 20 +-- packages/server/annotate.ts | 10 ++ packages/server/review.ts | 2 +- packages/ui/components/ToolbarButtons.tsx | 4 +- 12 files changed, 209 insertions(+), 107 deletions(-) rename packages/review-editor/components/{PiReviewActions.tsx => AgentReviewActions.tsx} (87%) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 797cc6e9..265ba0ff 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -431,7 +431,9 @@ if (args[0] === "sessions") { server.stop(); // Output feedback (captured by slash command) - if (result.approved) { + if (result.exit) { + console.log("Review session closed without feedback."); + } else if (result.approved) { console.log("Code review completed — no changes requested."); } else { console.log(result.feedback); @@ -553,7 +555,11 @@ if (args[0] === "sessions") { server.stop(); // Output feedback (captured by slash command) - console.log(result.feedback || "No feedback provided."); + if (result.exit) { + console.log("Annotation session closed without feedback."); + } else { + console.log(result.feedback || "No feedback provided."); + } process.exit(0); } else if (args[0] === "annotate-last" || args[0] === "last") { @@ -667,7 +673,11 @@ if (args[0] === "sessions") { server.stop(); - console.log(result.feedback || "No feedback provided."); + if (result.exit) { + console.log("Annotation session closed without feedback."); + } else { + console.log(result.feedback || "No feedback provided."); + } process.exit(0); } else if (args[0] === "archive") { @@ -852,7 +862,11 @@ if (args[0] === "sessions") { await Bun.sleep(1500); server.stop(); - console.log(result.feedback || "No feedback provided."); + if (result.exit) { + console.log("Annotation session closed without feedback."); + } else { + console.log(result.feedback || "No feedback provided."); + } process.exit(0); } else if (args[0] === "improve-context") { diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index c2829f3c..2218c271 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -103,6 +103,10 @@ export async function handleReviewCommand( await Bun.sleep(1500); server.stop(); + if (result.exit) { + return; + } + if (result.feedback) { // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; @@ -181,6 +185,10 @@ export async function handleAnnotateCommand( await Bun.sleep(1500); server.stop(); + if (result.exit) { + return; + } + if (result.feedback) { // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; @@ -266,6 +274,10 @@ export async function handleAnnotateLastCommand( await Bun.sleep(1500); server.stop(); + if (result.exit) { + return null; + } + return result.feedback || null; } diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index a3c62040..38ed6cfe 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -347,7 +347,9 @@ export default function plannotator(pi: ExtensionAPI): void { const prUrl = args?.trim() || undefined; const isPRReview = prUrl?.startsWith("http://") || prUrl?.startsWith("https://"); const result = await openCodeReview(ctx, { prUrl }); - if (result.feedback) { + if (result.exit) { + ctx.ui.notify("Code review session closed.", "info"); + } else if (result.feedback) { if (result.approved) { pi.sendUserMessage( `# Code Review\n\nCode review completed — no changes requested.`, @@ -424,7 +426,9 @@ export default function plannotator(pi: ExtensionAPI): void { try { const result = await openMarkdownAnnotation(ctx, absolutePath, markdown, mode ?? "annotate", folderPath); - if (result.feedback) { + if (result.exit) { + ctx.ui.notify("Annotation session closed.", "info"); + } else if (result.feedback) { const header = isFolder ? `# Markdown Annotations\n\nFolder: ${absolutePath}\n\n` : `# Markdown Annotations\n\nFile: ${absolutePath}\n\n`; @@ -464,7 +468,9 @@ export default function plannotator(pi: ExtensionAPI): void { try { const result = await openLastMessageAnnotation(ctx, lastText); - if (result.feedback) { + if (result.exit) { + ctx.ui.notify("Annotation session closed.", "info"); + } else if (result.feedback) { pi.sendUserMessage( `# Message Annotations\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, ); diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 5b69fd18..6c6548fb 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -165,7 +165,7 @@ export async function openPlanReviewBrowser( export async function openCodeReview( ctx: ExtensionContext, options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {}, -): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string }> { +): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string; exit?: boolean }> { if (!ctx.hasUI || !reviewHtmlContent) { throw new Error("Plannotator code review browser is unavailable in this session."); } @@ -367,7 +367,7 @@ export async function openMarkdownAnnotation( markdown: string, mode: AnnotateMode, folderPath?: string, -): Promise<{ feedback: string }> { +): Promise<{ feedback: string; exit?: boolean }> { if (!ctx.hasUI || !planHtmlContent) { throw new Error("Plannotator annotation browser is unavailable in this session."); } @@ -402,7 +402,7 @@ export async function openMarkdownAnnotation( export async function openLastMessageAnnotation( ctx: ExtensionContext, lastText: string, -): Promise<{ feedback: string }> { +): Promise<{ feedback: string; exit?: boolean }> { return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last"); } diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index c2b96e34..7699aa12 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -28,7 +28,7 @@ export interface AnnotateServerResult { port: number; portSource: "env" | "remote-default" | "random"; url: string; - waitForDecision: () => Promise<{ feedback: string; annotations: unknown[] }>; + waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean }>; stop: () => void; } @@ -54,10 +54,12 @@ export async function startAnnotateServer(options: { let resolveDecision!: (result: { feedback: string; annotations: unknown[]; + exit?: boolean; }) => void; const decisionPromise = new Promise<{ feedback: string; annotations: unknown[]; + exit?: boolean; }>((r) => { resolveDecision = r; }); @@ -122,6 +124,10 @@ export async function startAnnotateServer(options: { handleFileBrowserRequest(res, url); } else if (url.pathname === "/favicon.svg") { handleFavicon(res); + } else if (url.pathname === "/api/exit" && req.method === "POST") { + deleteDraft(draftKey); + resolveDecision({ feedback: "", annotations: [], exit: true }); + json(res, { ok: true }); } else if (url.pathname === "/api/feedback" && req.method === "POST") { try { const body = await parseBody(req); diff --git a/bun.lock b/bun.lock index c9d35859..786f3433 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -62,7 +63,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.17.3", + "version": "0.17.7", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -84,7 +85,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.17.3", + "version": "0.17.7", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -170,7 +171,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.17.3", + "version": "0.17.7", "dependencies": { "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 5499be10..c015c4c0 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter } from '@plannotator/ui/utils/parser'; import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; @@ -13,7 +13,7 @@ import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; import { Settings } from '@plannotator/ui/components/Settings'; -import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons'; +import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; import { useSharing } from '@plannotator/ui/hooks/useSharing'; import { getCallbackConfig, CallbackAction, executeCallback, type ToastPayload } from '@plannotator/ui/utils/callback'; import { useAgents } from '@plannotator/ui/hooks/useAgents'; @@ -84,6 +84,7 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); + const [showExitWarning, setShowExitWarning] = useState(false); const [showAgentWarning, setShowAgentWarning] = useState(false); const [agentWarningMessage, setAgentWarningMessage] = useState(''); const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768); @@ -136,7 +137,8 @@ const App: React.FC = () => { const [imageBaseDir, setImageBaseDir] = useState(undefined); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); + const [isExiting, setIsExiting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null); const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); @@ -865,6 +867,21 @@ const App: React.FC = () => { } }; + // Exit annotation session without sending feedback + const handleAnnotateExit = useCallback(async () => { + setIsExiting(true); + try { + const res = await fetch('/api/exit', { method: 'POST' }); + if (res.ok) { + setSubmitted('exited'); + } else { + throw new Error('Failed to exit'); + } + } catch { + setIsExiting(false); + } + }, []); + // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -877,10 +894,10 @@ const App: React.FC = () => { // Don't intercept if any modal is open if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; - // Don't intercept if already submitted or submitting - if (submitted || isSubmitting) return; + // Don't intercept if already submitted, submitting, or exiting + if (submitted || isSubmitting || isExiting) return; // Don't intercept in demo/share mode (no API) if (!isApiMode) return; @@ -920,9 +937,9 @@ const App: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, + showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, - submitted, isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode, + submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode, origin, getAgentWarning, ]); @@ -1153,7 +1170,7 @@ const App: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; if (submitted || !isApiMode) return; @@ -1181,7 +1198,7 @@ const App: React.FC = () => { window.addEventListener('keydown', handleSaveShortcut); return () => window.removeEventListener('keydown', handleSaveShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, + showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, isApiMode, markdown, annotationsOutput, ]); @@ -1195,7 +1212,7 @@ const App: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; if (submitted) return; @@ -1206,7 +1223,7 @@ const App: React.FC = () => { window.addEventListener('keydown', handlePrintShortcut); return () => window.removeEventListener('keydown', handlePrintShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, + showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, ]); @@ -1278,27 +1295,44 @@ const App: React.FC = () => { {isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( <> - { - if (annotateMode) { - handleAnnotateFeedback(); - return; - } - const docAnnotations = linkedDocHook.getDocAnnotations(); - const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 - ); - if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { - setShowFeedbackPrompt(true); - } else { - handleDeny(); - } - }} - disabled={isSubmitting} - isLoading={isSubmitting} - label={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'} - title={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'} - /> + {annotateMode ? ( + // Annotate mode: Close always visible, Send Annotations when annotations exist + <> + (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0) ? setShowExitWarning(true) : handleAnnotateExit()} + disabled={isSubmitting || isExiting} + isLoading={isExiting} + /> + {(allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0) && ( + + )} + + ) : ( + // Plan mode: Send Feedback + { + const docAnnotations = linkedDocHook.getDocAnnotations(); + const hasDocAnnotations = Array.from(docAnnotations.values()).some( + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + ); + if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + setShowFeedbackPrompt(true); + } else { + handleDeny(); + } + }} + disabled={isSubmitting} + isLoading={isSubmitting} + label="Send Feedback" + title="Send Feedback" + /> + )} {!annotateMode &&
{ showCancel /> + {/* Exit with annotations warning dialog */} + setShowExitWarning(false)} + onConfirm={() => { + setShowExitWarning(false); + handleAnnotateExit(); + }} + title="Annotations Won't Be Sent" + message={<>You have {allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount} annotation{(allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount) !== 1 ? 's' : ''} that will be lost if you close.} + subMessage="To send your annotations, use Send Annotations instead." + confirmText="Close Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + {/* OpenCode agent not found warning dialog */} { {/* Completion overlay - shown after approve/deny */} diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index d26cf59a..15f1ab6d 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -3,8 +3,8 @@ import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { Settings } from '@plannotator/ui/components/Settings'; -import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons'; -import { PiReviewActions } from './components/PiReviewActions'; +import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; +import { AgentReviewActions } from './components/AgentReviewActions'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; @@ -162,6 +162,7 @@ const ReviewApp: React.FC = () => { const [isExiting, setIsExiting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false); const [showApproveWarning, setShowApproveWarning] = useState(false); + const [showExitWarning, setShowExitWarning] = useState(false); const [sharingEnabled, setSharingEnabled] = useState(true); const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); @@ -1296,7 +1297,7 @@ const ReviewApp: React.FC = () => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; - if (showExportModal || showNoAnnotationsDialog || showApproveWarning) return; + if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return; if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; if (!origin) return; // Demo mode @@ -1325,9 +1326,9 @@ const ReviewApp: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - showExportModal, showNoAnnotationsDialog, showApproveWarning, + showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning, platformCommentDialog, platformGeneralComment, - submitted, isSendingFeedback, isApproving, isPlatformActioning, + submitted, isSendingFeedback, isApproving, isExiting, isPlatformActioning, origin, platformMode, platformUser, prMetadata, totalAnnotationCount, handleApprove, handleSendFeedback, handlePlatformAction ]); @@ -1517,50 +1518,25 @@ const ReviewApp: React.FC = () => {
)} - {/* Pi agent mode: Exit/SendFeedback flip + Approve */} - {origin === 'pi' && !platformMode ? ( - totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} - onExit={handleExit} + onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} /> - ) : !platformMode ? ( - <> - {/* Other agent mode: muted Send Feedback + Approve (original behavior) */} - -
- totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} - disabled={isSendingFeedback || isApproving} - isLoading={isApproving} - dimmed={totalAnnotationCount > 0} - title="Approve - no changes needed" - /> - {totalAnnotationCount > 0 && ( -
-
-
- Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve. -
- )} -
- ) : ( <> - {/* Platform mode: Post Comments + Approve */} + {/* Platform mode: Close + Post Comments + Approve */} + totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning} + isLoading={isExiting} + /> { setPlatformGeneralComment(''); @@ -1905,6 +1881,22 @@ const ReviewApp: React.FC = () => { showCancel /> + setShowExitWarning(false)} + onConfirm={() => { + setShowExitWarning(false); + handleExit(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you close.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Close Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + {/* AI setup dialog — first-run only */} = ({ +export const AgentReviewActions: React.FC = ({ totalAnnotationCount, isSendingFeedback, isApproving, @@ -36,7 +36,13 @@ export const PiReviewActions: React.FC = ({ return ( <> - {hasAnnotations ? ( + + + {hasAnnotations && ( = ({ loadingLabel="Sending..." title="Send feedback" /> - ) : ( - )}
diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 84e7a380..c61de8d7 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -63,6 +63,7 @@ export interface AnnotateServerResult { waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; + exit?: boolean; }>; /** Stop the server */ stop: () => void; @@ -111,10 +112,12 @@ export async function startAnnotateServer( let resolveDecision: (result: { feedback: string; annotations: unknown[]; + exit?: boolean; }) => void; const decisionPromise = new Promise<{ feedback: string; annotations: unknown[]; + exit?: boolean; }>((resolve) => { resolveDecision = resolve; }); @@ -217,6 +220,13 @@ export async function startAnnotateServer( }); if (externalResponse) return externalResponse; + // API: Exit annotation session without feedback + if (url.pathname === "/api/exit" && req.method === "POST") { + deleteDraft(draftKey); + resolveDecision({ feedback: "", annotations: [], exit: true }); + return Response.json({ ok: true }); + } + // API: Submit annotation feedback if (url.pathname === "/api/feedback" && req.method === "POST") { try { diff --git a/packages/server/review.ts b/packages/server/review.ts index 08e33a57..4c785c8f 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -563,7 +563,7 @@ export async function startReviewServer( }); if (agentResponse) return agentResponse; - // API: Exit review session without feedback (Pi agent mode) + // API: Exit review session without feedback if (url.pathname === "/api/exit" && req.method === "POST") { deleteDraft(draftKey); resolveDecision({ approved: false, feedback: "", annotations: [], exit: true }); diff --git a/packages/ui/components/ToolbarButtons.tsx b/packages/ui/components/ToolbarButtons.tsx index c351e7af..1e78923f 100644 --- a/packages/ui/components/ToolbarButtons.tsx +++ b/packages/ui/components/ToolbarButtons.tsx @@ -97,12 +97,14 @@ interface ExitButtonProps { onClick: () => void; disabled?: boolean; isLoading?: boolean; + title?: string; } export const ExitButton: React.FC = ({ onClick, disabled = false, isLoading = false, + title = 'Close session without sending feedback', }) => (