Skip to content

Commit 8a158ae

Browse files
committed
feat(review): add Exit button for Pi agent mode (#522)
1 parent bac424d commit 8a158ae

File tree

7 files changed

+274
-76
lines changed

7 files changed

+274
-76
lines changed

apps/pi-extension/server.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,43 @@ describe("pi review server", () => {
214214
}
215215
});
216216

217+
test("exit endpoint resolves decision with exit flag", async () => {
218+
const homeDir = makeTempDir("plannotator-pi-home-");
219+
const repoDir = initRepo();
220+
process.env.HOME = homeDir;
221+
process.chdir(repoDir);
222+
process.env.PLANNOTATOR_PORT = String(await reservePort());
223+
224+
const gitContext = await getGitContext();
225+
const diff = await runGitDiff("uncommitted", gitContext.defaultBranch);
226+
227+
const server = await startReviewServer({
228+
rawPatch: diff.patch,
229+
gitRef: diff.label,
230+
error: diff.error,
231+
diffType: "uncommitted",
232+
gitContext,
233+
origin: "pi",
234+
htmlContent: "<!doctype html><html><body>review</body></html>",
235+
});
236+
237+
try {
238+
const exitResponse = await fetch(`${server.url}/api/exit`, { method: "POST" });
239+
expect(exitResponse.status).toBe(200);
240+
expect(await exitResponse.json()).toEqual({ ok: true });
241+
242+
await expect(server.waitForDecision()).resolves.toEqual({
243+
exit: true,
244+
approved: false,
245+
feedback: "",
246+
annotations: [],
247+
agentSwitch: undefined,
248+
});
249+
} finally {
250+
server.stop();
251+
}
252+
});
253+
217254
test("git-add endpoint stages and unstages files in review mode", async () => {
218255
const homeDir = makeTempDir("plannotator-pi-home-");
219256
const repoDir = initRepo();

apps/pi-extension/server/serverReview.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export interface ReviewServerResult {
9595
feedback: string;
9696
annotations: unknown[];
9797
agentSwitch?: string;
98+
exit?: boolean;
9899
}>;
99100
stop: () => void;
100101
}
@@ -285,12 +286,14 @@ export async function startReviewServer(options: {
285286
feedback: string;
286287
annotations: unknown[];
287288
agentSwitch?: string;
289+
exit?: boolean;
288290
}) => void;
289291
const decisionPromise = new Promise<{
290292
approved: boolean;
291293
feedback: string;
292294
annotations: unknown[];
293295
agentSwitch?: string;
296+
exit?: boolean;
294297
}>((r) => {
295298
resolveDecision = r;
296299
});
@@ -680,6 +683,10 @@ export async function startReviewServer(options: {
680683
return;
681684
}
682685
json(res, { error: "Not found" }, 404);
686+
} else if (url.pathname === "/api/exit" && req.method === "POST") {
687+
deleteDraft(draftKey);
688+
resolveDecision({ approved: false, feedback: '', annotations: [], exit: true });
689+
json(res, { ok: true });
683690
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
684691
try {
685692
const body = await parseBody(req);

packages/review-editor/App.tsx

Lines changed: 118 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvide
44
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
55
import { Settings } from '@plannotator/ui/components/Settings';
66
import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons';
7+
import { PiReviewActions } from './components/PiReviewActions';
78
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
89
import { storage } from '@plannotator/ui/utils/storage';
910
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
@@ -158,7 +159,8 @@ const ReviewApp: React.FC = () => {
158159
const [diffError, setDiffError] = useState<string | null>(null);
159160
const [isSendingFeedback, setIsSendingFeedback] = useState(false);
160161
const [isApproving, setIsApproving] = useState(false);
161-
const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false);
162+
const [isExiting, setIsExiting] = useState(false);
163+
const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false);
162164
const [showApproveWarning, setShowApproveWarning] = useState(false);
163165
const [sharingEnabled, setSharingEnabled] = useState(true);
164166
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
@@ -1089,6 +1091,22 @@ const ReviewApp: React.FC = () => {
10891091
}
10901092
}, [totalAnnotationCount, feedbackMarkdown, allAnnotations]);
10911093

1094+
// Exit review session without sending any feedback
1095+
const handleExit = useCallback(async () => {
1096+
setIsExiting(true);
1097+
try {
1098+
const res = await fetch('/api/exit', { method: 'POST' });
1099+
if (res.ok) {
1100+
setSubmitted('exited');
1101+
} else {
1102+
throw new Error('Failed to exit');
1103+
}
1104+
} catch (error) {
1105+
console.error('Failed to exit review:', error);
1106+
setIsExiting(false);
1107+
}
1108+
}, []);
1109+
10921110
// Approve without feedback (LGTM)
10931111
const handleApprove = useCallback(async () => {
10941112
setIsApproving(true);
@@ -1279,7 +1297,7 @@ const ReviewApp: React.FC = () => {
12791297
const tag = (e.target as HTMLElement)?.tagName;
12801298
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
12811299
if (showExportModal || showNoAnnotationsDialog || showApproveWarning) return;
1282-
if (submitted || isSendingFeedback || isApproving || isPlatformActioning) return;
1300+
if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return;
12831301
if (!origin) return; // Demo mode
12841302

12851303
e.preventDefault();
@@ -1499,73 +1517,92 @@ const ReviewApp: React.FC = () => {
14991517
</div>
15001518
)}
15011519

1502-
{/* Send Feedback button — always the same label */}
1503-
<FeedbackButton
1504-
onClick={() => {
1505-
if (platformMode) {
1506-
setPlatformGeneralComment('');
1507-
setPlatformCommentDialog({ action: 'comment' });
1508-
} else {
1509-
handleSendFeedback();
1510-
}
1511-
}}
1512-
disabled={
1513-
isSendingFeedback || isApproving || isPlatformActioning ||
1514-
(!platformMode && totalAnnotationCount === 0)
1515-
}
1516-
isLoading={isSendingFeedback || isPlatformActioning}
1517-
muted={!platformMode && totalAnnotationCount === 0 && !isSendingFeedback && !isApproving && !isPlatformActioning}
1518-
label={platformMode ? 'Post Comments' : 'Send Feedback'}
1519-
shortLabel={platformMode ? 'Post' : 'Send'}
1520-
loadingLabel={platformMode ? 'Posting...' : 'Sending...'}
1521-
shortLoadingLabel={platformMode ? 'Posting...' : 'Sending...'}
1522-
title={!platformMode && totalAnnotationCount === 0 ? "Add annotations to send feedback" : "Send feedback"}
1523-
/>
1524-
1525-
{/* Approve button — always the same label */}
1526-
<div className="relative group/approve">
1527-
<ApproveButton
1528-
onClick={() => {
1529-
if (platformMode) {
1530-
if (platformUser && prMetadata?.author === platformUser) return;
1531-
setPlatformGeneralComment('');
1532-
setPlatformCommentDialog({ action: 'approve' });
1533-
} else {
1534-
if (totalAnnotationCount > 0) {
1535-
setShowApproveWarning(true);
1536-
} else {
1537-
handleApprove();
1538-
}
1539-
}
1540-
}}
1541-
disabled={
1542-
isSendingFeedback || isApproving || isPlatformActioning ||
1543-
(platformMode && !!platformUser && prMetadata?.author === platformUser)
1544-
}
1545-
isLoading={isApproving}
1546-
dimmed={!platformMode && totalAnnotationCount > 0}
1547-
muted={platformMode && !!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning}
1548-
title={
1549-
platformMode && platformUser && prMetadata?.author === platformUser
1550-
? `You can't approve your own ${mrLabel}`
1551-
: "Approve - no changes needed"
1552-
}
1520+
{/* Pi agent mode: Exit/SendFeedback flip + Approve */}
1521+
{origin === 'pi' && !platformMode ? (
1522+
<PiReviewActions
1523+
totalAnnotationCount={totalAnnotationCount}
1524+
isSendingFeedback={isSendingFeedback}
1525+
isApproving={isApproving}
1526+
isExiting={isExiting}
1527+
onSendFeedback={handleSendFeedback}
1528+
onApprove={() => totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()}
1529+
onExit={handleExit}
15531530
/>
1554-
{/* Tooltip: own PR warning OR annotations-lost warning */}
1555-
{platformMode && platformUser && prMetadata?.author === platformUser ? (
1556-
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-48 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
1557-
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
1558-
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
1559-
You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}.
1531+
) : !platformMode ? (
1532+
<>
1533+
{/* Other agent mode: muted Send Feedback + Approve (original behavior) */}
1534+
<FeedbackButton
1535+
onClick={handleSendFeedback}
1536+
disabled={isSendingFeedback || isApproving || totalAnnotationCount === 0}
1537+
isLoading={isSendingFeedback}
1538+
muted={totalAnnotationCount === 0 && !isSendingFeedback && !isApproving}
1539+
label="Send Feedback"
1540+
shortLabel="Send"
1541+
loadingLabel="Sending..."
1542+
title={totalAnnotationCount === 0 ? "Add annotations to send feedback" : "Send feedback"}
1543+
/>
1544+
<div className="relative group/approve">
1545+
<ApproveButton
1546+
onClick={() => totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()}
1547+
disabled={isSendingFeedback || isApproving}
1548+
isLoading={isApproving}
1549+
dimmed={totalAnnotationCount > 0}
1550+
title="Approve - no changes needed"
1551+
/>
1552+
{totalAnnotationCount > 0 && (
1553+
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
1554+
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
1555+
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
1556+
Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve.
1557+
</div>
1558+
)}
15601559
</div>
1561-
) : !platformMode && totalAnnotationCount > 0 ? (
1562-
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
1563-
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
1564-
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
1565-
Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve.
1560+
</>
1561+
) : (
1562+
<>
1563+
{/* Platform mode: Post Comments + Approve */}
1564+
<FeedbackButton
1565+
onClick={() => {
1566+
setPlatformGeneralComment('');
1567+
setPlatformCommentDialog({ action: 'comment' });
1568+
}}
1569+
disabled={isSendingFeedback || isApproving || isPlatformActioning}
1570+
isLoading={isSendingFeedback || isPlatformActioning}
1571+
label="Post Comments"
1572+
shortLabel="Post"
1573+
loadingLabel="Posting..."
1574+
shortLoadingLabel="Posting..."
1575+
title="Send feedback"
1576+
/>
1577+
<div className="relative group/approve">
1578+
<ApproveButton
1579+
onClick={() => {
1580+
if (platformUser && prMetadata?.author === platformUser) return;
1581+
setPlatformGeneralComment('');
1582+
setPlatformCommentDialog({ action: 'approve' });
1583+
}}
1584+
disabled={
1585+
isSendingFeedback || isApproving || isPlatformActioning ||
1586+
(!!platformUser && prMetadata?.author === platformUser)
1587+
}
1588+
isLoading={isApproving}
1589+
muted={!!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning}
1590+
title={
1591+
platformUser && prMetadata?.author === platformUser
1592+
? `You can't approve your own ${mrLabel}`
1593+
: "Approve - no changes needed"
1594+
}
1595+
/>
1596+
{platformUser && prMetadata?.author === platformUser && (
1597+
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-48 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
1598+
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
1599+
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
1600+
You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}.
1601+
</div>
1602+
)}
15661603
</div>
1567-
) : null}
1568-
</div>
1604+
</>
1605+
)}
15691606
</>
15701607
) : (
15711608
<button
@@ -1878,18 +1915,24 @@ const ReviewApp: React.FC = () => {
18781915
}}
18791916
/>
18801917

1881-
{/* Completion overlay - shown after approve/feedback */}
1918+
{/* Completion overlay - shown after approve/feedback/exit */}
18821919
<CompletionOverlay
18831920
submitted={submitted}
1884-
title={submitted === 'approved' ? 'Changes Approved' : 'Feedback Sent'}
1921+
title={
1922+
submitted === 'approved' ? 'Changes Approved'
1923+
: submitted === 'exited' ? 'Session Closed'
1924+
: 'Feedback Sent'
1925+
}
18851926
subtitle={
1886-
platformMode
1887-
? submitted === 'approved'
1888-
? `Your approval was submitted to ${platformLabel}.`
1889-
: `Your feedback was submitted to ${platformLabel}.`
1890-
: submitted === 'approved'
1891-
? `${getAgentName(origin)} will proceed with the changes.`
1892-
: `${getAgentName(origin)} will address your review feedback.`
1927+
submitted === 'exited'
1928+
? 'Review session closed without feedback.'
1929+
: platformMode
1930+
? submitted === 'approved'
1931+
? `Your approval was submitted to ${platformLabel}.`
1932+
: `Your feedback was submitted to ${platformLabel}.`
1933+
: submitted === 'approved'
1934+
? `${getAgentName(origin)} will proceed with the changes.`
1935+
: `${getAgentName(origin)} will address your review feedback.`
18931936
}
18941937
agentLabel={getAgentName(origin)}
18951938
/>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React from 'react';
2+
import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
3+
4+
interface PiReviewActionsProps {
5+
totalAnnotationCount: number;
6+
isSendingFeedback: boolean;
7+
isApproving: boolean;
8+
isExiting: boolean;
9+
onSendFeedback: () => void;
10+
onApprove: () => void;
11+
onExit: () => void;
12+
}
13+
14+
/**
15+
* Toolbar actions for Pi agent mode.
16+
*
17+
* The left button flips based on whether there are annotations:
18+
* No annotations → [Close] [Approve]
19+
* Has annotations → [Send Feedback] [Approve]
20+
*
21+
* - Close (Exit): closes the session without sending feedback
22+
* - Send Feedback: primary action when annotations exist
23+
* - Approve: LGTM; dimmed when annotations exist (they won't be sent)
24+
*/
25+
export const PiReviewActions: React.FC<PiReviewActionsProps> = ({
26+
totalAnnotationCount,
27+
isSendingFeedback,
28+
isApproving,
29+
isExiting,
30+
onSendFeedback,
31+
onApprove,
32+
onExit,
33+
}) => {
34+
const busy = isSendingFeedback || isApproving || isExiting;
35+
const hasAnnotations = totalAnnotationCount > 0;
36+
37+
return (
38+
<>
39+
{hasAnnotations ? (
40+
<FeedbackButton
41+
onClick={onSendFeedback}
42+
disabled={busy}
43+
isLoading={isSendingFeedback}
44+
label="Send Feedback"
45+
shortLabel="Send"
46+
loadingLabel="Sending..."
47+
title="Send feedback"
48+
/>
49+
) : (
50+
<ExitButton
51+
onClick={onExit}
52+
disabled={busy}
53+
isLoading={isExiting}
54+
/>
55+
)}
56+
57+
<div className="relative group/approve inline-flex items-center">
58+
<ApproveButton
59+
onClick={onApprove}
60+
disabled={busy}
61+
isLoading={isApproving}
62+
dimmed={totalAnnotationCount > 0}
63+
title="Approve - no changes needed"
64+
/>
65+
{totalAnnotationCount > 0 && (
66+
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
67+
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
68+
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
69+
Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve.
70+
</div>
71+
)}
72+
</div>
73+
</>
74+
);
75+
};

0 commit comments

Comments
 (0)