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
8 changes: 4 additions & 4 deletions packages/core/guide-viewer-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import type { GuideViewerAssets } from "./guide-format";

export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
js: "viewer.CpDlIFcA.js",
css: "viewer.BdruF6Mj.css",
jsIntegrity: "sha384-AL8vNhcGQZd8DuYJQfpqGrNTVWBusWhMZEyeki9HMOj6ryXhrvqzPj7EuR7DOt9I",
cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx",
js: "viewer.AbymhdkZ.js",
css: "viewer.2q74Zm1i.css",
jsIntegrity: "sha384-LRKOSkSbvEkB8wRmWA1cM1aOvKn3YGSmD4CTmR+NaaB/4Nu/VU0xnoV7eHA5Cgm1",
cssIntegrity: "sha384-kpEQJ6JOE4oDHBIoRTDPTS2D0XmrqGry4zR4n+wnQKVl6dpD6t7bDGNz8bKuLW3i",
langs: {
"astro": "chunks/astro.BykyiR6i.js",
"c": "chunks/c.BIGW1oBm.js",
Expand Down
167 changes: 136 additions & 31 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -582,7 +583,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 [platformReviewRecovery, setPlatformReviewRecovery] = useState<{
rootPrUrl: string;
Expand Down Expand Up @@ -627,7 +628,6 @@ const ReviewApp: React.FC = () => {
return () => clearTimeout(t);
}
}, [updateInfo?.updateAvailable, updateInfo?.dismissed]);

const identity = useConfigValue('displayName');

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

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

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -3918,25 +4011,36 @@ 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"
}
labelBreakpoint="lg"
/>
{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}
labelBreakpoint="lg"
/>
{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 @@ -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}
Expand Down
23 changes: 12 additions & 11 deletions packages/review-editor/components/ReviewSubmissionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ interface ReviewSubmissionDialogProps {
onConfirm: () => void;
onCancel: () => void;
isSubmitting: boolean;
confirmLabel?: string;
recoveryPersistsRefresh: boolean;
mrLabel: string;
platformLabel: string;
Expand Down Expand Up @@ -320,6 +321,7 @@ export function ReviewSubmissionDialog({
onConfirm,
onCancel,
isSubmitting,
confirmLabel,
recoveryPersistsRefresh,
mrLabel,
platformLabel,
Expand All @@ -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 (
Expand Down Expand Up @@ -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}
</button>
</div>
</div>
Expand Down
Loading
Loading