diff --git a/packages/core/guide-viewer-manifest.ts b/packages/core/guide-viewer-manifest.ts index a3446bd79..46c306651 100644 --- a/packages/core/guide-viewer-manifest.ts +++ b/packages/core/guide-viewer-manifest.ts @@ -5,10 +5,10 @@ import type { GuideViewerAssets } from "./guide-format"; export const GUIDE_VIEWER_MANIFEST: Omit = { - js: "viewer.CTfggrYt.js", - css: "viewer.BdruF6Mj.css", - jsIntegrity: "sha384-It85Hkx0/d1Xme4SJjt3shHybLPGucRF/OODzE84mbOmGH8fK66eFNzgFRXe2W4Z", - cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx", + js: "viewer.uL52DYtG.js", + css: "viewer.2q74Zm1i.css", + jsIntegrity: "sha384-uRtNq4DUwcGj8XipC2+e1WVTjrtIWMVVL9X3rlnSZQjbOP1mw9SC8GV67sH3sCYC", + cssIntegrity: "sha384-kpEQJ6JOE4oDHBIoRTDPTS2D0XmrqGry4zR4n+wnQKVl6dpD6t7bDGNz8bKuLW3i", langs: { "astro": "chunks/astro.BykyiR6i.js", "c": "chunks/c.BIGW1oBm.js", diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index d5a023d52..cdd841a87 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -51,7 +51,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, @@ -70,6 +70,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'; @@ -131,7 +132,7 @@ import { DestinationSpotlight } from './components/DestinationSpotlight'; import { needsDestinationSpotlight, markDestinationSpotlightSeen } from './utils/destinationSpotlight'; 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 { copyTextToClipboard } from '@plannotator/ui/utils/clipboard'; import { TourDialog } from './components/tour/TourDialog'; @@ -582,7 +583,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 [platformReviewRecovery, setPlatformReviewRecovery] = useState<{ rootPrUrl: string; @@ -627,7 +628,6 @@ const ReviewApp: React.FC = () => { return () => clearTimeout(t); } }, [updateInfo?.updateAvailable, updateInfo?.dismissed]); - const identity = useConfigValue('displayName'); const clearPendingSelection = useCallback(() => { @@ -801,7 +801,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, @@ -3206,8 +3205,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); @@ -3271,12 +3316,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('review'); const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); const prLinks = openUrls.join(', '); @@ -3299,9 +3370,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, @@ -3336,11 +3407,12 @@ const ReviewApp: React.FC = () => { setPlatformCommentDialog({ action: recovery.action, plan: restoreReviewSubmission(plan, recovery), + ...(nextPr && recovery.action === 'approve' && { nextPr }), }); return; } setPlatformGeneralComment(seededGeneralComment); - setPlatformCommentDialog({ action, plan }); + setPlatformCommentDialog({ action, plan, ...(nextPr && { nextPr }) }); }, [allAnnotations, visibleEditorAnnotations, files, prMetadata, visibleDescriptionAnnotations, visibleCommentAnnotations, prContext?.body, platformReviewRecovery]); // Double-tap Option/Alt to toggle review destination (PR mode only) @@ -3398,7 +3470,7 @@ const ReviewApp: React.FC = () => { const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim(); if (!canSubmit || retryBlocked) return; e.preventDefault(); - handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment, platformCommentDialog.nextPr); return; } @@ -3438,6 +3510,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, + }); + // Cmd/Ctrl+Shift+Y keyboard shortcut to copy feedback, mirroring the // Copy Feedback button in the header. useEffect(() => { @@ -3918,25 +4011,36 @@ 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" - } - labelBreakpoint="lg" - /> - {platformUser && prMetadata?.author === platformUser && ( +
+ { + if (isOwnPlatformPR) return; + openPlatformDialog('approve'); + }} + disabled={platformApproveDisabled} + isLoading={isApproving} + muted={platformApproveMuted} + title={platformApproveTitle} + className={showApproveAndNext ? 'rounded-r-none' : undefined} + labelBreakpoint="lg" + /> + {showApproveAndNext && ( + + )} +
+ {isOwnPlatformPR && (
@@ -4716,10 +4820,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} recoveryPersistsRefresh={platformRecoveryPersistsRefresh} mrLabel={mrLabel} platformLabel={platformLabel} diff --git a/packages/review-editor/components/ReviewSubmissionDialog.tsx b/packages/review-editor/components/ReviewSubmissionDialog.tsx index bb8f1ce44..3c74f2d89 100644 --- a/packages/review-editor/components/ReviewSubmissionDialog.tsx +++ b/packages/review-editor/components/ReviewSubmissionDialog.tsx @@ -72,6 +72,7 @@ interface ReviewSubmissionDialogProps { onConfirm: () => void; onCancel: () => void; isSubmitting: boolean; + confirmLabel?: string; recoveryPersistsRefresh: boolean; mrLabel: string; platformLabel: string; @@ -320,6 +321,7 @@ export function ReviewSubmissionDialog({ onConfirm, onCancel, isSubmitting, + confirmLabel, recoveryPersistsRefresh, mrLabel, platformLabel, @@ -335,6 +337,15 @@ export function ReviewSubmissionDialog({ const hasFailed = submission.targets.some(t => t.status === 'failed'); const hasPartial = submission.targets.some(t => t.status === 'partial'); const hasBlocked = submission.targets.some(t => t.status === 'blocked'); + const confirmButtonLabel = isSubmitting + ? 'Posting...' + : hasBlocked + ? 'Retry blocked' + : hasPartial + ? 'Retry Unposted' + : hasFailed + ? 'Retry Failed' + : confirmLabel ?? (isApprove ? 'Approve' : 'Post Comments'); const bodyLocked = hasPartial || hasBlocked; return ( @@ -573,17 +584,7 @@ export function ReviewSubmissionDialog({ : 'bg-primary text-primary-foreground hover:opacity-90' }`} > - {isSubmitting - ? 'Posting...' - : hasBlocked - ? 'Retry blocked' - : hasPartial - ? 'Retry Unposted' - : hasFailed - ? 'Retry Failed' - : isApprove - ? 'Approve' - : 'Post Comments'} + {confirmButtonLabel}
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/components/ToolbarButtons.tsx b/packages/ui/components/ToolbarButtons.tsx index e7f7e87e8..2f011dc09 100644 --- a/packages/ui/components/ToolbarButtons.tsx +++ b/packages/ui/components/ToolbarButtons.tsx @@ -67,6 +67,7 @@ export interface ApproveButtonProps { title?: string; dimmed?: boolean; muted?: boolean; + className?: string; labelBreakpoint?: ToolbarLabelBreakpoint; } @@ -81,6 +82,7 @@ export const ApproveButton: React.FC = ({ title, dimmed = false, muted = false, + className, labelBreakpoint = 'md', }) => (