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
164 changes: 134 additions & 30 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -341,7 +342,7 @@ const ReviewApp: React.FC = () => {
const [isPlatformActioning, setIsPlatformActioning] = useState(false);
const [platformActionError, setPlatformActionError] = useState<string | null>(null);
const [platformUser, setPlatformUser] = useState<string | null>(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');
Expand Down Expand Up @@ -381,7 +382,6 @@ const ReviewApp: React.FC = () => {
return () => clearTimeout(t);
}
}, [updateInfo?.updateAvailable, updateInfo?.dismissed]);

const identity = useConfigValue('displayName');

const clearPendingSelection = useCallback(() => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(', ');
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 (
<ThemeProvider defaultTheme="dark">
Expand Down Expand Up @@ -2967,24 +3059,35 @@ const ReviewApp: React.FC = () => {
/>
)}
<div className="relative group/approve">
<ApproveButton
onClick={() => {
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 && (
<div className={platformApproveGroupClass}>
<ApproveButton
onClick={() => {
if (isOwnPlatformPR) return;
openPlatformDialog('approve');
}}
disabled={platformApproveDisabled}
isLoading={isApproving}
muted={platformApproveMuted}
title={platformApproveTitle}
className={showApproveAndNext ? 'rounded-r-none' : undefined}
/>
{showApproveAndNext && (
<button
type="button"
onClick={() => {
if (!nextOpenStackNode) return;
openPlatformDialog('approve', nextOpenStackNode);
}}
disabled={platformApproveDisabled}
title={`Approve and review ${nextApproveLabel}`}
aria-label={`Approve and review ${nextApproveLabel}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-l-none rounded-r-md border-l border-success-foreground/35 bg-success text-success-foreground hover:opacity-90 disabled:cursor-not-allowed disabled:border-muted-foreground/25 disabled:bg-muted disabled:text-muted-foreground"
>
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
{isOwnPlatformPR && (
<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">
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
Expand Down Expand Up @@ -3622,10 +3725,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}
/>
Expand Down
14 changes: 7 additions & 7 deletions packages/review-editor/components/ReviewSubmissionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface ReviewSubmissionDialogProps {
onConfirm: () => void;
onCancel: () => void;
isSubmitting: boolean;
confirmLabel?: string;
mrLabel: string;
platformLabel: string;
}
Expand Down Expand Up @@ -235,6 +236,7 @@ export function ReviewSubmissionDialog({
onConfirm,
onCancel,
isSubmitting,
confirmLabel,
mrLabel,
platformLabel,
}: ReviewSubmissionDialogProps) {
Expand Down Expand Up @@ -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'}
</button>
</div>
</div>
Expand Down
88 changes: 88 additions & 0 deletions packages/review-editor/hooks/useApproveAndNextAffordance.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading