From 02a31dbbaa49c706cab9933fba854cd1b72aa41f Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:48:28 -0300 Subject: [PATCH 1/2] feat(review): add approve and next for stacked PRs --- packages/review-editor/App.tsx | 163 ++++++++++++++---- .../components/ReviewSubmissionDialog.tsx | 14 +- .../hooks/useApproveAndNextAffordance.ts | 88 ++++++++++ packages/review-editor/hooks/usePRStack.ts | 6 +- .../review-editor/utils/runtimeSurface.ts | 3 + packages/ui/hooks/useEditorAnnotations.ts | 2 +- 6 files changed, 236 insertions(+), 40 deletions(-) create mode 100644 packages/review-editor/hooks/useApproveAndNextAffordance.ts create mode 100644 packages/review-editor/utils/runtimeSurface.ts diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 974b5aa1f..d23e30b67 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -41,7 +41,7 @@ import { useAgentJobs, jobMatchesReviewContext } from '@plannotator/ui/hooks/use import { exportEditorAnnotations } from '@plannotator/ui/utils/parser'; import { buildReviewAgentInstructions } from '@plannotator/ui/utils/reviewAgentInstructions'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; -import { FolderTree } from 'lucide-react'; +import { ArrowRight, FolderTree } from 'lucide-react'; import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockview-react'; import { ReviewHeaderMenu } from './components/ReviewHeaderMenu'; import { ReviewSidebar } from './components/ReviewSidebar'; @@ -54,6 +54,7 @@ import { StackedPRLabel } from './components/StackedPRLabel'; import { PRSelector } from './components/PRSelector'; import { PRSwitchOverlay } from './components/PRSwitchOverlay'; import { usePRStack } from './hooks/usePRStack'; +import { useApproveAndNextAffordance } from './hooks/useApproveAndNextAffordance'; import { useDiffFreshness } from './hooks/useDiffFreshness'; import { usePRSession, type PRSessionUpdate } from './hooks/usePRSession'; import { useAnnotationFactory } from './hooks/useAnnotationFactory'; @@ -90,7 +91,7 @@ import { GuideIntroDialog } from './components/GuideIntroDialog'; import { needsGuideIntro, markGuideIntroSeen, needsGuideHint, markGuideHintSeen } from './utils/guideIntro'; import { TextShimmer } from '@plannotator/ui/components/TextShimmer'; import type { PRMetadata } from '@plannotator/shared/pr-types'; -import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; +import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackNode, PRStackTree } from '@plannotator/shared/pr-stack'; import { altKey } from '@plannotator/ui/utils/platform'; import { TourDialog } from './components/tour/TourDialog'; import { DEMO_TOUR_ID } from './demoTour'; @@ -341,7 +342,7 @@ const ReviewApp: React.FC = () => { const [isPlatformActioning, setIsPlatformActioning] = useState(false); const [platformActionError, setPlatformActionError] = useState(null); const [platformUser, setPlatformUser] = useState(null); - const [platformCommentDialog, setPlatformCommentDialog] = useState<{ action: 'approve' | 'comment'; plan: ReviewSubmission } | null>(null); + const [platformCommentDialog, setPlatformCommentDialog] = useState<{ action: 'approve' | 'comment'; plan: ReviewSubmission; nextPr?: PRStackNode } | null>(null); const [platformGeneralComment, setPlatformGeneralComment] = useState(''); const [platformOpenPR, setPlatformOpenPR] = useState(() => { const platformSetting = storage.getItem('plannotator-platform-open-pr'); @@ -381,7 +382,6 @@ const ReviewApp: React.FC = () => { return () => clearTimeout(t); } }, [updateInfo?.updateAvailable, updateInfo?.dismissed]); - const identity = useConfigValue('displayName'); const clearPendingSelection = useCallback(() => { @@ -528,7 +528,6 @@ const ReviewApp: React.FC = () => { }, [annotations, externalAnnotations]); const allAnnotationsRef = useRef(allAnnotations); allAnnotationsRef.current = allAnnotations; - // Auto-save code annotation drafts const { draftBanner, restoreDraft, getDraftGeneration, dismissDraft } = useCodeAnnotationDraft({ annotations: allAnnotations, @@ -2434,8 +2433,54 @@ const ReviewApp: React.FC = () => { } }, [getDraftGeneration]); + const annotationBelongsToApprovedPR = useCallback((annotation: { prUrl?: string }, approvedPrUrl: string | undefined) => { + return !approvedPrUrl || !annotation.prUrl || annotation.prUrl === approvedPrUrl; + }, []); + + const clearApprovedPRReviewState = useCallback((approvedPrUrl: string | undefined) => { + const externalIds = externalAnnotations + .filter(annotation => annotationBelongsToApprovedPR(annotation, approvedPrUrl)) + .map(annotation => annotation.id); + + dismissDraft(); + setAnnotations(prev => prev.filter(annotation => !annotationBelongsToApprovedPR(annotation, approvedPrUrl))); + setDescriptionAnnotations(prev => prev.filter(annotation => !proseAnnotationMatchesPr(annotation, approvedPrUrl))); + setCommentAnnotations(prev => prev.filter(annotation => !proseAnnotationMatchesPr(annotation, approvedPrUrl))); + setSelectedAnnotationId(prev => { + if (!prev) return prev; + const selected = allAnnotationsRef.current.find(annotation => annotation.id === prev); + return selected && annotationBelongsToApprovedPR(selected, approvedPrUrl) ? null : prev; + }); + setSelectedDescriptionAnnotationId(prev => { + if (!prev) return prev; + const selected = descriptionAnnotations.find(annotation => annotation.id === prev); + return selected && proseAnnotationMatchesPr(selected, approvedPrUrl) ? null : prev; + }); + setSelectedCommentAnnotationId(prev => { + if (!prev) return prev; + const selected = commentAnnotations.find(annotation => annotation.id === prev); + return selected && proseAnnotationMatchesPr(selected, approvedPrUrl) ? null : prev; + }); + + for (const id of externalIds) { + deleteExternalAnnotation(id); + } + }, [ + annotationBelongsToApprovedPR, + commentAnnotations, + deleteExternalAnnotation, + descriptionAnnotations, + dismissDraft, + externalAnnotations, + ]); + // Submit reviews to one or more PRs via /api/pr-action - const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', plan: ReviewSubmission, generalComment?: string) => { + const handlePlatformAction = useCallback(async ( + action: 'approve' | 'comment', + plan: ReviewSubmission, + generalComment?: string, + nextPr?: PRStackNode, + ) => { setIsPlatformActioning(true); setPlatformActionError(null); @@ -2501,12 +2546,38 @@ const ReviewApp: React.FC = () => { } setPlatformCommentDialog(null); - setSubmitted(action === 'approve' ? 'approved' : 'feedback'); if (platformOpenPR) { for (const url of openUrls) window.open(url, '_blank'); } + if (action === 'approve' && nextPr?.url) { + const nextUrl = nextPr.url; + const approvedLabel = mrNumberLabel || mrLabel; + const nextLabel = nextPr.number != null ? `${mrLabel} #${nextPr.number}` : nextPr.branch; + clearApprovedPRReviewState(prMetadata?.url); + toast.success(`${approvedLabel} approved. Moving to ${nextLabel}...`); + const switched = await handlePRSwitch(nextUrl); + if (!switched) { + const message = `${approvedLabel} approved, but Plannotator couldn't open ${nextLabel}.`; + setPlatformActionError(message); + toast.error(message, { + action: { + label: 'Retry', + onClick: () => { + setPlatformActionError(null); + void handlePRSwitch(nextUrl).then((ok) => { + if (!ok) setPlatformActionError(message); + }); + }, + }, + }); + } + return; + } + + setSubmitted(action === 'approve' ? 'approved' : 'feedback'); + const agentSwitchSettings = getAgentSwitchSettings(); const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); const prLinks = openUrls.join(', '); @@ -2529,9 +2600,9 @@ const ReviewApp: React.FC = () => { } finally { setIsPlatformActioning(false); } - }, [platformOpenPR, platformLabel, mrLabel, prMetadata]); + }, [platformOpenPR, platformLabel, mrLabel, mrNumberLabel, prMetadata, handlePRSwitch, clearApprovedPRReviewState]); - const openPlatformDialog = useCallback((action: 'approve' | 'comment') => { + const openPlatformDialog = useCallback((action: 'approve' | 'comment', nextPr?: PRStackNode) => { const diffPaths = new Set(files.map(f => f.path)); const prMeta = prMetadata ? { number: prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid, @@ -2544,7 +2615,7 @@ const ReviewApp: React.FC = () => { // where the user can edit before submitting. Also means a review with only // prose notes still has something to post. setPlatformGeneralComment(buildProseFeedback(visibleDescriptionAnnotations, visibleCommentAnnotations, prContext?.body)); - setPlatformCommentDialog({ action, plan }); + setPlatformCommentDialog({ action, plan, ...(nextPr && { nextPr }) }); }, [allAnnotations, editorAnnotations, files, prMetadata, visibleDescriptionAnnotations, visibleCommentAnnotations, prContext?.body]); // Double-tap Option/Alt to toggle review destination (PR mode only) @@ -2596,7 +2667,7 @@ const ReviewApp: React.FC = () => { const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim(); if (!canSubmit) return; e.preventDefault(); - handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment, platformCommentDialog.nextPr); return; } @@ -2636,6 +2707,27 @@ const ReviewApp: React.FC = () => { handleApprove, handleSendFeedback, handlePlatformAction ]); + const { + isOwnPlatformPR, + nextApproveLabel, + nextOpenStackNode, + platformApproveDisabled, + platformApproveGroupClass, + platformApproveMuted, + platformApproveTitle, + showApproveAndNext, + } = useApproveAndNextAffordance({ + isApproving, + isPlatformActioning, + isSendingFeedback, + mrLabel, + platformMode, + platformUser, + prDiffScope, + prMetadata, + prStackTree, + }); + if (isLoading) { return ( @@ -2967,24 +3059,34 @@ const ReviewApp: React.FC = () => { /> )}
- { - if (platformUser && prMetadata?.author === platformUser) return; - openPlatformDialog('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 && ( +
+ { + if (isOwnPlatformPR) return; + openPlatformDialog('approve'); + }} + disabled={platformApproveDisabled} + isLoading={isApproving} + muted={platformApproveMuted} + title={platformApproveTitle} + /> + {showApproveAndNext && ( + + )} +
+ {isOwnPlatformPR && (
@@ -3622,10 +3724,11 @@ const ReviewApp: React.FC = () => { }} onConfirm={() => { if (!platformCommentDialog) return; - handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment, platformCommentDialog.nextPr); }} onCancel={() => setPlatformCommentDialog(null)} isSubmitting={isPlatformActioning} + confirmLabel={platformCommentDialog?.nextPr ? 'Approve & Next' : undefined} mrLabel={mrLabel} platformLabel={platformLabel} /> diff --git a/packages/review-editor/components/ReviewSubmissionDialog.tsx b/packages/review-editor/components/ReviewSubmissionDialog.tsx index 6a3ceaebc..d3655d5ee 100644 --- a/packages/review-editor/components/ReviewSubmissionDialog.tsx +++ b/packages/review-editor/components/ReviewSubmissionDialog.tsx @@ -49,6 +49,7 @@ interface ReviewSubmissionDialogProps { onConfirm: () => void; onCancel: () => void; isSubmitting: boolean; + confirmLabel?: string; mrLabel: string; platformLabel: string; } @@ -235,6 +236,7 @@ export function ReviewSubmissionDialog({ onConfirm, onCancel, isSubmitting, + confirmLabel, mrLabel, platformLabel, }: ReviewSubmissionDialogProps) { @@ -371,13 +373,11 @@ export function ReviewSubmissionDialog({ : 'bg-primary text-primary-foreground hover:opacity-90' }`} > - {isSubmitting - ? 'Posting...' - : hasFailed - ? 'Retry Failed' - : isApprove - ? 'Approve' - : 'Post Comments'} + {isSubmitting && 'Posting...'} + {!isSubmitting && hasFailed && 'Retry Failed'} + {!isSubmitting && !hasFailed && confirmLabel && confirmLabel} + {!isSubmitting && !hasFailed && !confirmLabel && isApprove && 'Approve'} + {!isSubmitting && !hasFailed && !confirmLabel && !isApprove && 'Post Comments'}
diff --git a/packages/review-editor/hooks/useApproveAndNextAffordance.ts b/packages/review-editor/hooks/useApproveAndNextAffordance.ts new file mode 100644 index 000000000..da7c7bc90 --- /dev/null +++ b/packages/review-editor/hooks/useApproveAndNextAffordance.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { toast } from 'sonner'; +import type { PRMetadata } from '@plannotator/shared/pr-types'; +import type { PRDiffScope, PRStackNode, PRStackTree } from '@plannotator/shared/pr-stack'; +import { storage } from '@plannotator/ui/utils/storage'; +import { isVSCodeWebview } from '../utils/runtimeSurface'; + +const APPROVE_AND_NEXT_TOAST_SEEN_KEY = 'plannotator-approve-next-toast-seen'; + +function findNextOpenStackNode(stackTree: PRStackTree | null, metadata: PRMetadata | null): PRStackNode | null { + if (!stackTree || !metadata) return null; + + const currentIndex = stackTree.nodes.findIndex(node => node.isCurrent || node.url === metadata.url); + if (currentIndex < 0) return null; + + return stackTree.nodes + .slice(currentIndex + 1) + .find(node => !node.isDefaultBranch && node.state === 'open' && !!node.url) ?? null; +} + +interface UseApproveAndNextAffordanceOptions { + isApproving: boolean; + isPlatformActioning: boolean; + isSendingFeedback: boolean; + mrLabel: string; + platformMode: boolean; + platformUser: string | null; + prDiffScope: PRDiffScope; + prMetadata: PRMetadata | null; + prStackTree: PRStackTree | null; +} + +export function useApproveAndNextAffordance({ + isApproving, + isPlatformActioning, + isSendingFeedback, + mrLabel, + platformMode, + platformUser, + prDiffScope, + prMetadata, + prStackTree, +}: UseApproveAndNextAffordanceOptions) { + const toastShown = useRef(false); + const nextOpenStackNode = useMemo( + () => findNextOpenStackNode(prStackTree, prMetadata), + [prStackTree, prMetadata], + ); + const vscodeWebview = isVSCodeWebview(); + const isOwnPlatformPR = !!platformUser && prMetadata?.author === platformUser; + const showApproveAndNext = platformMode && prDiffScope === 'layer' && !!nextOpenStackNode && !isOwnPlatformPR && !vscodeWebview; + const platformApproveDisabled = isSendingFeedback || isApproving || isPlatformActioning || isOwnPlatformPR; + const platformApproveMuted = isOwnPlatformPR && !isSendingFeedback && !isApproving && !isPlatformActioning; + const platformApproveTitle = isOwnPlatformPR + ? `You can't approve your own ${mrLabel}` + : 'Approve - no changes needed'; + const nextApproveLabel = nextOpenStackNode?.number != null + ? `${mrLabel} #${nextOpenStackNode.number}` + : nextOpenStackNode?.branch ?? `next ${mrLabel}`; + const platformApproveGroupClass = showApproveAndNext + ? 'inline-flex items-stretch [&>button:first-child]:rounded-r-none' + : 'inline-flex items-stretch'; + + useEffect(() => { + if (!showApproveAndNext || toastShown.current) return; + if (storage.getItem(APPROVE_AND_NEXT_TOAST_SEEN_KEY) === 'true') return; + + toastShown.current = true; + storage.setItem(APPROVE_AND_NEXT_TOAST_SEEN_KEY, 'true'); + toast('Stacked PR shortcut available', { + description: 'Use the arrow beside Approve to approve this PR and continue to the next open PR in the stack.', + duration: 7000, + position: 'top-right', + classNames: { toast: '!w-auto', description: '!text-foreground/70' }, + }); + }, [showApproveAndNext]); + + return { + isOwnPlatformPR, + nextApproveLabel, + nextOpenStackNode, + platformApproveDisabled, + platformApproveGroupClass, + platformApproveMuted, + platformApproveTitle, + showApproveAndNext, + }; +} diff --git a/packages/review-editor/hooks/usePRStack.ts b/packages/review-editor/hooks/usePRStack.ts index ca600d9d6..259c69dc0 100644 --- a/packages/review-editor/hooks/usePRStack.ts +++ b/packages/review-editor/hooks/usePRStack.ts @@ -76,9 +76,9 @@ export function usePRStack(callbacksRef: RefObject) { } }, [callbacksRef]); - const handlePRSwitch = useCallback(async (prUrl: string) => { + const handlePRSwitch = useCallback(async (prUrl: string): Promise => { const cb = callbacksRef.current; - if (!cb) return; + if (!cb) return false; setIsSwitchingPRScope(true); try { const res = await fetch('/api/pr-switch', { @@ -91,8 +91,10 @@ export function usePRStack(callbacksRef: RefObject) { throw new Error(data.error ?? 'Failed to switch PR'); } cb.applyPRResponse(data); + return true; } catch (err) { cb.onError(err instanceof Error ? err.message : 'Failed to switch PR'); + return false; } finally { setIsSwitchingPRScope(false); } diff --git a/packages/review-editor/utils/runtimeSurface.ts b/packages/review-editor/utils/runtimeSurface.ts new file mode 100644 index 000000000..4a3e6a646 --- /dev/null +++ b/packages/review-editor/utils/runtimeSurface.ts @@ -0,0 +1,3 @@ +export function isVSCodeWebview(): boolean { + return typeof window !== 'undefined' && (window as { __PLANNOTATOR_VSCODE?: boolean }).__PLANNOTATOR_VSCODE === true; +} diff --git a/packages/ui/hooks/useEditorAnnotations.ts b/packages/ui/hooks/useEditorAnnotations.ts index 0265be3cf..13aad5287 100644 --- a/packages/ui/hooks/useEditorAnnotations.ts +++ b/packages/ui/hooks/useEditorAnnotations.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { EditorAnnotation } from '../types'; const POLL_INTERVAL = 500; -const IS_VSCODE = typeof window !== 'undefined' && (window as any).__PLANNOTATOR_VSCODE === true; +const IS_VSCODE = typeof window !== 'undefined' && (window as { __PLANNOTATOR_VSCODE?: boolean }).__PLANNOTATOR_VSCODE === true; interface UseEditorAnnotationsReturn { editorAnnotations: EditorAnnotation[]; From 448ce9978f720e912485b52d2d3272612b42422c Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:00:39 -0300 Subject: [PATCH 2/2] Refine approve button split state styling --- packages/review-editor/App.tsx | 3 ++- packages/ui/components/ToolbarButtons.tsx | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index d23e30b67..c46781c44 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -3069,6 +3069,7 @@ const ReviewApp: React.FC = () => { isLoading={isApproving} muted={platformApproveMuted} title={platformApproveTitle} + className={showApproveAndNext ? 'rounded-r-none' : undefined} /> {showApproveAndNext && ( diff --git a/packages/ui/components/ToolbarButtons.tsx b/packages/ui/components/ToolbarButtons.tsx index 0ed81d9e2..5918861a1 100644 --- a/packages/ui/components/ToolbarButtons.tsx +++ b/packages/ui/components/ToolbarButtons.tsx @@ -57,6 +57,7 @@ export interface ApproveButtonProps { title?: string; dimmed?: boolean; muted?: boolean; + className?: string; } export const ApproveButton: React.FC = ({ @@ -70,6 +71,7 @@ export const ApproveButton: React.FC = ({ title, dimmed = false, muted = false, + className, }) => (