Files
backnotprop__plannotator/packages/review-editor/App.tsx
T
Michael Ramos afac44b940 feat(review): migrate all-files code review to Pierre CodeView (#885)
Replaces the all-files renderer with Pierre's virtualized CodeView (one scroller, identical look, scales to huge diffs); deletes the legacy AllFilesDiffView/LazyFileDiff and their flag; single-file panels stay on FileDiff. @pierre/diffs pinned exact 1.2.8 (1.2.9's tree is broken on npm — see #880).

Also ships: diff staleness detection ('Diff out of date · Refresh' toolbar notice backed by shared per-VCS fingerprints + GET /api/diff/fresh on both Bun and Pi servers), a stale-content guard so files edited mid-review degrade to raw-patch view instead of breaking virtualization, the Ask AI wrong-file fix (pre-existing), and toolbar/header UI polish (global settings cog, aligned sem badges, no all-files split dragger).

Hardened through five review waves; tested with 1384 passing tests incl. real-git fingerprint and patch-consistency suites. Known minor gaps documented in the PR.
2026-06-10 21:07:58 -07:00

2763 lines
123 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider';
import { TooltipProvider } from '@plannotator/ui/components/Tooltip';
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
import { Settings } from '@plannotator/ui/components/Settings';
import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
import { AgentReviewActions } from './components/AgentReviewActions';
import { DiffOptionsPopover } from './components/DiffOptionsPopover';
import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck';
import { storage } from '@plannotator/ui/utils/storage';
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
import { GitHubIcon } from '@plannotator/ui/components/GitHubIcon';
import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon';
import { RepoIcon } from '@plannotator/ui/components/RepoIcon';
import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon';
import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-types';
import type { SemanticDiffAdvert } from '@plannotator/shared/semantic-diff-types';
import { configStore, useConfigValue } from '@plannotator/ui/config';
import { loadDiffFont } from '@plannotator/ui/utils/diffFonts';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
import {
getAIProviderSettings,
resolveAIModelForProvider,
resolveAIProviderSelection,
saveAIProviderSelection,
} from '@plannotator/ui/utils/aiProvider';
import { DiffTypeSetupDialog } from '@plannotator/ui/components/DiffTypeSetupDialog';
import { needsDiffTypeSetup } from '@plannotator/ui/utils/diffTypeSetup';
import { LookAndFeelAnnouncementDialog } from '@plannotator/ui/components/LookAndFeelAnnouncementDialog';
import {
markLookAndFeelAnnouncementSeen,
needsLookAndFeelAnnouncement,
} from '@plannotator/ui/utils/lookAndFeelAnnouncement';
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types';
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
import { useGitAdd } from './hooks/useGitAdd';
import { generateId } from './utils/generateId';
import { useAIChat } from './hooks/useAIChat';
import { toast, Toaster } from 'sonner';
import { useCodeNav, type CodeNavRequest } from './hooks/useCodeNav';
import { extractLinesFromPatch } from './utils/patchParser';
import { isTypingTarget, useReviewSearch, type ReviewSearchMatch } from './hooks/useReviewSearch';
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations';
import { useAgentJobs } from '@plannotator/ui/hooks/useAgentJobs';
import { exportEditorAnnotations } from '@plannotator/ui/utils/parser';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
import { FolderTree } from 'lucide-react';
import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockview-react';
import { ReviewHeaderMenu } from './components/ReviewHeaderMenu';
import { ReviewSidebar } from './components/ReviewSidebar';
import type { ReviewSidebarTab } from './components/ReviewSidebar';
import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon';
import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon';
import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
import { FileTree } from './components/FileTree';
import { StackedPRLabel } from './components/StackedPRLabel';
import { PRSelector } from './components/PRSelector';
import { PRSwitchOverlay } from './components/PRSwitchOverlay';
import { usePRStack } from './hooks/usePRStack';
import { useDiffFreshness } from './hooks/useDiffFreshness';
import { usePRSession, type PRSessionUpdate } from './hooks/usePRSession';
import { useAnnotationFactory } from './hooks/useAnnotationFactory';
import { DEMO_DIFF } from './demoData';
import { exportReviewFeedback } from './utils/exportFeedback';
import { parseDiffToFiles } from './utils/diffParser';
import { ReviewSubmissionDialog, buildReviewSubmission, type ReviewSubmission, type SubmissionTarget } from './components/ReviewSubmissionDialog';
import { ReviewStateProvider, type ReviewState } from './dock/ReviewStateContext';
import { JobLogsProvider } from './dock/JobLogsContext';
import { reviewPanelComponents } from './dock/reviewPanelComponents';
import { ReviewDockTabRenderer } from './dock/ReviewDockTabRenderer';
import { usePRContext } from './hooks/usePRContext';
import {
REVIEW_PANEL_TYPES,
REVIEW_DIFF_PANEL_ID,
makeReviewAgentJobPanelId,
getReviewDiffPanelFilePath,
isReviewDiffPanelId,
REVIEW_PR_SUMMARY_PANEL_ID,
REVIEW_PR_COMMENTS_PANEL_ID,
REVIEW_PR_CHECKS_PANEL_ID,
REVIEW_SEMANTIC_DIFF_PANEL_ID,
REVIEW_ALL_FILES_PANEL_ID,
REVIEW_CODE_NAV_PANEL_ID,
} from './dock/reviewPanelTypes';
import type { DiffFile } from './types';
import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types';
import type { PRMetadata } from '@plannotator/shared/pr-types';
import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, 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';
declare const __APP_VERSION__: string;
interface DiffData {
files: DiffFile[];
rawPatch: string;
gitRef: string;
origin?: Origin;
diffType?: string;
gitContext?: GitContext;
diffOptions?: DiffOption[];
sharingEnabled?: boolean;
prStackInfo?: PRStackInfo | null;
prDiffScope?: PRDiffScope;
prDiffScopeOptions?: PRDiffScopeOption[];
semanticDiff?: SemanticDiffAdvert;
}
function getFileTabTitle(filePath: string): string {
return filePath.split('/').pop() ?? filePath;
}
const ReviewApp: React.FC = () => {
const { resolvedMode } = useTheme();
const [diffData, setDiffData] = useState<DiffData | null>(null);
const [files, setFiles] = useState<DiffFile[]>([]);
const [activeFileIndex, setActiveFileIndex] = useState(0);
const [annotations, setAnnotations] = useState<CodeAnnotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isAllFilesActive, setIsAllFilesActive] = useState(false);
// Mirror ref: handlers captured by Pierre slot portals (which only republish
// on item version bumps) and early-declared callbacks read the CURRENT value
// at call time instead of a stale closure capture.
const isAllFilesActiveRef = useRef(isAllFilesActive);
isAllFilesActiveRef.current = isAllFilesActive;
const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false);
const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false);
const [isDiffPanelActive, setIsDiffPanelActive] = useState(false);
const [allFilesVisibleFile, setAllFilesVisibleFile] = useState<string | null>(null);
const [pendingSelection, setPendingSelection] = useState<SelectedLineRange | null>(null);
const [showExportModal, setShowExportModal] = useState(false);
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false);
const [openSettingsMenu, setOpenSettingsMenu] = useState(false);
const [showNoAnnotationsDialog, setShowNoAnnotationsDialog] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const diffStyle = useConfigValue('diffStyle');
const diffOverflow = useConfigValue('diffOverflow');
const diffIndicators = useConfigValue('diffIndicators');
const diffLineDiffType = useConfigValue('diffLineDiffType');
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
const diffShowBackground = useConfigValue('diffShowBackground');
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
const diffFontFamily = useConfigValue('diffFontFamily');
const diffFontSize = useConfigValue('diffFontSize');
const diffTabSize = useConfigValue('diffTabSize');
// Global plan-look preference; surfaced here only by the shared 0.20.0
// look-and-feel announcement (the grid/clean chooser applies to plan review).
const gridEnabled = useConfigValue('gridEnabled');
// Load custom diff font and override --font-mono for surrounding review elements
useEffect(() => {
if (diffFontFamily) {
loadDiffFont(diffFontFamily);
document.documentElement.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`);
} else {
document.documentElement.style.removeProperty('--diff-font-override');
}
if (diffFontSize) {
document.documentElement.style.setProperty('--diff-font-size-override', diffFontSize);
} else {
document.documentElement.style.removeProperty('--diff-font-size-override');
}
document.documentElement.style.setProperty('--diffs-tab-size', String(diffTabSize));
}, [diffFontFamily, diffFontSize, diffTabSize]);
const reviewSidebar = useSidebar<ReviewSidebarTab>(true, 'annotations');
const [isFileTreeOpen, setIsFileTreeOpen] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
const [copyRawDiffStatus, setCopyRawDiffStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const [hideViewedFiles, setHideViewedFiles] = useState(false);
const [origin, setOrigin] = useState<Origin | null>(null);
const [gitUser, setGitUser] = useState<string | undefined>();
const [isWSL, setIsWSL] = useState(false);
const [reviewMode, setReviewMode] = useState<string | null>(null);
const [diffType, setDiffType] = useState<string>('uncommitted');
const [gitContext, setGitContext] = useState<GitContext | null>(null);
const [workspaceDiffOptions, setWorkspaceDiffOptions] = useState<DiffOption[] | null>(null);
// Two bases:
// selectedBase — what the picker is currently showing (UI intent).
// Updates immediately when the user picks, so the chip
// feels responsive.
// committedBase — the base the server last computed the patch against.
// Drives file-content fetches. Only updates after
// /api/diff/switch returns, so we never pair an old
// patch with a new base's file contents (race that
// produced "trailing context mismatch" warnings).
const [selectedBase, setSelectedBase] = useState<string | null>(null);
const [committedBase, setCommittedBase] = useState<string | null>(null);
const [agentCwd, setAgentCwd] = useState<string | null>(null);
const [isLoadingDiff, setIsLoadingDiff] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [isSendingFeedback, setIsSendingFeedback] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false);
const [showApproveWarning, setShowApproveWarning] = useState(false);
const [showExitWarning, setShowExitWarning] = useState(false);
const [sharingEnabled, setSharingEnabled] = useState(true);
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
useEffect(() => {
document.title = repoInfo ? `${repoInfo.display} · Code Review` : "Code Review";
}, [repoInfo]);
const { prMetadata, prStackInfo, prStackTree, prDiffScope, prDiffScopeOptions, updatePRSession } = usePRSession();
const { withPRContext } = useAnnotationFactory(prMetadata, prStackInfo ? prDiffScope : undefined);
const prStackCallbacksRef = useRef<import('./hooks/usePRStack').PRStackCallbacks | null>(null);
const {
isSwitchingPRScope,
handleScopeSelect: handlePRDiffScopeSelect,
handlePRSwitch,
} = usePRStack(prStackCallbacksRef);
const [reviewDestination, setReviewDestination] = useState<'agent' | 'platform'>(() => {
const stored = storage.getItem('plannotator-review-dest');
return stored === 'agent' ? 'agent' : 'platform'; // 'github' (legacy) → 'platform'
});
const [showDestinationMenu, setShowDestinationMenu] = useState(false);
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 [platformGeneralComment, setPlatformGeneralComment] = useState('');
const [platformOpenPR, setPlatformOpenPR] = useState(() => {
const platformSetting = storage.getItem('plannotator-platform-open-pr');
if (platformSetting !== null) return platformSetting !== 'false';
const legacyGitHubSetting = storage.getItem('plannotator-github-open-pr');
if (legacyGitHubSetting !== null) {
storage.setItem('plannotator-platform-open-pr', legacyGitHubSetting);
return legacyGitHubSetting !== 'false';
}
return true;
});
// Derived: Platform mode is active when destination is platform AND we have PR/MR metadata
const platformMode = reviewDestination === 'platform' && !!prMetadata;
// Platform-aware labels
const platformLabel = prMetadata ? getPlatformLabel(prMetadata) : 'GitHub';
const mrLabel = prMetadata ? getMRLabel(prMetadata) : 'PR';
const mrNumberLabel = prMetadata ? getMRNumberLabel(prMetadata) : '';
const displayRepo = prMetadata ? getDisplayRepo(prMetadata) : '';
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
const updateInfo = useUpdateCheck();
const updateToastShown = useRef(false);
useEffect(() => {
if (updateInfo?.updateAvailable && !updateInfo.dismissed && !updateToastShown.current) {
updateToastShown.current = true;
const t = setTimeout(() => {
toast('A new version of Plannotator is available', {
description: 'Open the Options menu to update.',
duration: 4000,
position: 'top-right',
classNames: { toast: '!w-auto', description: '!text-foreground/70' },
});
}, 1500);
return () => clearTimeout(t);
}
}, [updateInfo?.updateAvailable, updateInfo?.dismissed]);
const identity = useConfigValue('displayName');
const clearPendingSelection = useCallback(() => {
setPendingSelection(null);
}, []);
// VS Code editor annotations (only polls when inside VS Code webview)
const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations();
// External annotations (SSE-based, for any external tool)
// TODO: Replace !!origin with a dedicated isApiMode boolean (set on /api/diff success/failure).
// origin is an identity field, not a connectivity signal — the standalone dev server
// (apps/review/) doesn't set it, so external annotations are silently disabled there.
// The same !!origin proxy is used elsewhere in this file (draft hook, feedback guard, conditional UI)
// so this should be addressed as a broader refactor.
const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<CodeAnnotation>({ enabled: !!origin });
const agentJobs = useAgentJobs({ enabled: !!origin });
// Tour dialog state — opens as an overlay instead of a dock panel
const [tourDialogJobId, setTourDialogJobId] = useState<string | null>(null);
// Dockview center panel API for the review workspace.
const [dockApi, setDockApi] = useState<DockviewApi | null>(null);
const filesRef = useRef(files);
filesRef.current = files;
const needsInitialDiffPanel = useRef(true);
const semanticDiffAutoFallbackPending = useRef(false);
// PR context (lifted from sidebar so center dock PR panels can access it)
const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext: fetchPRContext } = usePRContext(prMetadata ?? null);
// Sync activeFileIndex from dockview's active panel (wired in handleDockReady)
const openDiffFile = useCallback((filePath: string) => {
const file = files.find(candidate => candidate.path === filePath);
if (!file) return;
semanticDiffAutoFallbackPending.current = false;
if (!dockApi) {
const fileIndex = files.findIndex(candidate => candidate.path === filePath);
if (fileIndex !== -1) {
setActiveFileIndex(fileIndex);
}
return;
}
const existing = dockApi.getPanel(REVIEW_DIFF_PANEL_ID);
if (existing) {
const existingFilePath = getReviewDiffPanelFilePath(existing.params);
if (existingFilePath === filePath) {
if (dockApi.activePanel?.id !== REVIEW_DIFF_PANEL_ID) {
existing.api.setActive();
}
const fileIndex = files.findIndex(candidate => candidate.path === filePath);
if (fileIndex !== -1) {
setActiveFileIndex(fileIndex);
}
needsInitialDiffPanel.current = false;
return;
}
setPendingSelection(null);
existing.api.updateParameters({ filePath });
existing.api.setTitle(getFileTabTitle(file.path));
existing.api.setActive();
} else {
setPendingSelection(null);
dockApi.addPanel({
id: REVIEW_DIFF_PANEL_ID,
component: REVIEW_PANEL_TYPES.DIFF,
title: getFileTabTitle(file.path),
params: { filePath },
});
}
setActiveFileIndex(files.findIndex(candidate => candidate.path === filePath));
needsInitialDiffPanel.current = false;
}, [dockApi, files]);
const handleRevealSearchMatch = useCallback((match: ReviewSearchMatch) => {
// Respect the surface the user is in. When the all-files panel is active,
// reveal IN PLACE — AllFilesCodeView scrolls to + highlights the active
// match via the activeSearchMatch prop. When a single-file panel is active
// (or there's no dock yet), open the match's file there instead (legacy
// behavior). Unconditionally activating the all-files panel here would
// teleport the user out of their tab just for typing a query (reveal also
// fires on first-match auto-activation, not only on explicit clicks).
if (dockApi && isAllFilesActiveRef.current) {
return;
}
openDiffFile(match.filePath);
}, [dockApi, openDiffFile]);
const {
searchQuery,
debouncedSearchQuery,
isSearchPending,
isSearchOpen,
activeSearchMatchId,
activeSearchMatch,
activeFileSearchMatches,
searchMatches,
searchGroups,
searchInputRef,
openSearch,
closeSearch,
clearSearch,
stepSearchMatch,
handleSearchInputChange,
handleSelectSearchMatch,
} = useReviewSearch({
files,
activeFilePath: files[activeFileIndex]?.path ?? null,
onRevealMatch: handleRevealSearchMatch,
});
const hasSearchableFiles = files.length > 0;
const shouldShowFileTree =
hasSearchableFiles ||
(reviewMode === 'workspace' && !!workspaceDiffOptions?.length) ||
!!gitContext?.diffOptions?.length ||
!!gitContext?.worktrees?.length;
// Merge local + SSE annotations, deduping draft-restored externals against
// live SSE versions. Prefer the SSE version when both exist (same source,
// type, and originalText). This avoids the timing issues of an effect-based
// cleanup — draft-restored externals persist until SSE actually re-delivers them.
const allAnnotations = useMemo(() => {
if (externalAnnotations.length === 0) return annotations;
const local = annotations.filter(a => {
if (!a.source) return true;
return !externalAnnotations.some(ext =>
ext.source === a.source &&
ext.type === a.type &&
ext.filePath === a.filePath &&
ext.lineStart === a.lineStart &&
ext.lineEnd === a.lineEnd &&
ext.side === a.side
);
});
return [...local, ...externalAnnotations];
}, [annotations, externalAnnotations]);
const allAnnotationsRef = useRef(allAnnotations);
allAnnotationsRef.current = allAnnotations;
// Auto-save code annotation drafts
const { draftBanner, restoreDraft, dismissDraft } = useCodeAnnotationDraft({
annotations: allAnnotations,
viewedFiles,
isApiMode: !!origin,
submitted: !!submitted,
});
const handleRestoreDraft = useCallback(() => {
const restored = restoreDraft();
if (restored.annotations.length > 0) setAnnotations(restored.annotations);
if (restored.viewedFiles.length > 0) setViewedFiles(new Set(restored.viewedFiles));
}, [restoreDraft]);
// AI Chat
const [aiAvailable, setAiAvailable] = useState(false);
const [aiProviders, setAiProviders] = useState<Array<{ id: string; name: string; capabilities: Record<string, boolean>; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]);
const [aiDefaultProvider, setAiDefaultProvider] = useState<string | null>(null);
const [aiConfig, setAiConfig] = useState(() => {
const saved = getAIProviderSettings();
const pid = saved.providerId;
return {
providerId: pid,
model: pid ? (saved.preferredModels[pid] ?? null) : null,
reasoningEffort: null as string | null,
};
});
const [showDiffTypeSetup, setShowDiffTypeSetup] = useState(false);
const [diffTypeSetupPending, setDiffTypeSetupPending] = useState(false);
// The 0.20.0 release / look-and-feel announcement also runs in code review.
// Seen-state is a shared cookie (host-scoped), so dismissing it in either app
// suppresses it in the other — it appears once across both.
const [showLookAndFeel, setShowLookAndFeel] = useState(needsLookAndFeelAnnouncement);
const dismissLookAndFeel = useCallback(() => {
markLookAndFeelAnnouncementSeen();
setShowLookAndFeel(false);
}, []);
const aiChat = useAIChat({
patch: diffData?.rawPatch ?? '',
providerId: aiConfig.providerId,
model: aiConfig.model,
reasoningEffort: aiConfig.reasoningEffort,
});
const {
messages: aiMessages,
isCreatingSession: aiIsCreatingSession,
isStreaming: aiIsStreaming,
permissionRequests: aiPermissionRequests,
respondToPermission: respondToAIPermission,
ask: askAI,
resetSession: resetAISession,
sessionId: aiSessionId,
} = aiChat;
const codeNav = useCodeNav();
const handleCodeNavRequest = useCallback((request: CodeNavRequest) => {
if (!gitContext && !agentCwd) {
toast('Code navigation requires a local checkout', {
description: 'Re-run with --local for PR reviews',
duration: 4000,
});
return;
}
codeNav.resolve(request);
if (!dockApi) return;
const existing = dockApi.getPanel(REVIEW_CODE_NAV_PANEL_ID);
if (existing) {
existing.api.setTitle(`References: ${request.symbol}`);
existing.api.setActive();
} else {
const refPanel = isSemanticDiffActive
? REVIEW_SEMANTIC_DIFF_PANEL_ID
: isAllFilesActive
? REVIEW_ALL_FILES_PANEL_ID
: REVIEW_DIFF_PANEL_ID;
dockApi.addPanel({
id: REVIEW_CODE_NAV_PANEL_ID,
component: REVIEW_PANEL_TYPES.CODE_NAV,
title: `References: ${request.symbol}`,
position: { direction: 'below', referencePanel: refPanel },
initialHeight: 250,
});
}
}, [codeNav.resolve, dockApi, isAllFilesActive, isSemanticDiffActive, gitContext, agentCwd]);
// Check AI capabilities on mount
useEffect(() => {
fetch('/api/ai/capabilities')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.available) {
setAiAvailable(true);
const providers = data.providers ?? [];
setAiProviders(providers);
setAiDefaultProvider(data.defaultProvider ?? null);
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (!aiAvailable || aiProviders.length === 0) return;
setAiConfig(prev => {
const saved = getAIProviderSettings();
const selection = resolveAIProviderSelection({
providers: aiProviders,
origin,
settings: saved,
serverDefaultProvider: aiDefaultProvider,
});
if (prev.providerId === selection.providerId && prev.model === selection.model) return prev;
return { ...prev, providerId: selection.providerId, model: selection.model };
});
}, [aiAvailable, aiProviders, aiDefaultProvider, origin]);
const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => {
setAiConfig(prev => {
const saved = getAIProviderSettings();
const providerId = config.providerId !== undefined ? config.providerId : prev.providerId;
const providerChanged = config.providerId !== undefined && config.providerId !== prev.providerId;
const provider = aiProviders.find(p => p.id === providerId) ?? null;
const model = providerChanged
? (config.model !== undefined ? config.model : resolveAIModelForProvider(provider, saved.preferredModels))
: (config.model !== undefined ? config.model : prev.model);
const next = { ...prev, ...config, providerId, model };
saveAIProviderSelection({
providerId: next.providerId,
model: next.model,
origin,
settings: saved,
});
return next;
});
resetAISession();
}, [aiProviders, origin, resetAISession]);
// File-aware Ask AI: the all-files surface resolves the owning file itself
// (its toolbar selection lives in a file the single-file panel may never
// have focused), so it must NOT go through activeFileIndex.
const handleAskAIForFile = useCallback((filePath: string, question: string) => {
if (!pendingSelection) return;
const file = files.find(f => f.path === filePath);
if (!file) return;
const lineStart = Math.min(pendingSelection.start, pendingSelection.end);
const lineEnd = Math.max(pendingSelection.start, pendingSelection.end);
const side = pendingSelection.side === 'additions' ? 'new' : 'old';
const selectedCode = extractLinesFromPatch(file.patch, lineStart, lineEnd, side);
askAI({
prompt: question,
filePath,
lineStart,
lineEnd,
side,
selectedCode: selectedCode || undefined,
});
}, [askAI, files, pendingSelection]);
// Single-file surface: the focused file IS files[activeFileIndex].
const handleAskAI = useCallback((question: string) => {
const file = files[activeFileIndex];
if (!file) return;
handleAskAIForFile(file.path, question);
}, [activeFileIndex, files, handleAskAIForFile]);
const handleViewAIResponse = useCallback((questionId?: string) => {
reviewSidebar.open('ai');
if (questionId) {
setScrollToQuestionId(questionId);
setTimeout(() => setScrollToQuestionId(null), 500);
}
}, []);
const handleScrollToAILines = useCallback((filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => {
openDiffFile(filePath);
// Set a selection to highlight the lines
setPendingSelection({
start: lineStart,
end: lineEnd,
side: side === 'new' ? 'additions' : 'deletions',
});
}, [openDiffFile]);
// AI messages overlapping the current selection in a GIVEN file (toolbar
// history). File-aware so the all-files surface can ask for its own active
// file instead of inheriting the single-file panel's focus.
const getAIHistoryForFile = useCallback((filePath: string) => {
if (!pendingSelection) return [];
const selStart = Math.min(pendingSelection.start, pendingSelection.end);
const selEnd = Math.max(pendingSelection.start, pendingSelection.end);
const side = pendingSelection.side === 'additions' ? 'new' : 'old';
return aiMessages.filter(m => {
const q = m.question;
return q.filePath === filePath && q.side === side &&
q.lineStart != null && q.lineEnd != null &&
q.lineStart <= selEnd && q.lineEnd >= selStart;
});
}, [pendingSelection, aiMessages]);
// Single-file surface variant (focused file = files[activeFileIndex]).
const aiHistoryForSelection = useMemo(() => {
const file = files[activeFileIndex];
return file ? getAIHistoryForFile(file.path) : [];
}, [files, activeFileIndex, getAIHistoryForFile]);
// Click AI marker in diff → scroll sidebar to that Q&A
const [scrollToQuestionId, setScrollToQuestionId] = useState<string | null>(null);
const handleClickAIMarker = useCallback((questionId: string) => {
setScrollToQuestionId(questionId);
reviewSidebar.open('ai');
// Clear after a tick so it can re-trigger for the same question
setTimeout(() => setScrollToQuestionId(null), 500);
}, []);
// General AI question from sidebar input
const handleAskGeneral = useCallback((question: string) => {
askAI({ prompt: question });
}, [askAI]);
// Resizable panels
const panelResize = useResizablePanel({
storageKey: 'plannotator-review-panel-width',
onSnapClose: () => reviewSidebar.close(),
});
const fileTreeResize = useResizablePanel({
storageKey: 'plannotator-filetree-width',
defaultWidth: 256, minWidth: 160, maxWidth: 400, side: 'left',
onSnapClose: () => setIsFileTreeOpen(false),
});
const isResizing = panelResize.isDragging || fileTreeResize.isDragging;
// Dockview ready handler — stores API and wires active panel tracking.
// Initial panel creation happens in the effect below once dockApi is set.
const handleDockReady = useCallback((event: DockviewReadyEvent) => {
setDockApi(event.api);
// Sync activeFileIndex when user switches between dock tabs
event.api.onDidActivePanelChange((panel) => {
if (!panel) {
setIsAllFilesActive(false);
setIsSemanticDiffActive(false);
setIsDiffPanelActive(false);
return;
}
setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID);
setIsSemanticDiffActive(panel.id === REVIEW_SEMANTIC_DIFF_PANEL_ID);
setIsDiffPanelActive(isReviewDiffPanelId(panel.id));
if (!isReviewDiffPanelId(panel.id)) return;
const filePath = getReviewDiffPanelFilePath(panel.params);
if (!filePath) return;
const fileIndex = filesRef.current.findIndex(file => file.path === filePath);
if (fileIndex !== -1) {
setActiveFileIndex(fileIndex);
}
});
// Hide Dockview chrome only for the dedicated single diff tab.
// Any lone non-diff panel still needs a visible header so it can be
// dragged, closed, and used as a way back out of the dock.
const updateHeaders = () => {
const lonePanel =
event.api.totalPanels === 1 && event.api.groups.length === 1
? event.api.groups[0]?.panels[0]
: undefined;
const hideHeaders =
lonePanel?.id === REVIEW_DIFF_PANEL_ID ||
lonePanel?.id === REVIEW_SEMANTIC_DIFF_PANEL_ID ||
lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID;
for (const group of event.api.groups) {
group.header.hidden = hideHeaders;
}
};
event.api.onDidAddPanel(updateHeaders);
event.api.onDidRemovePanel(updateHeaders);
event.api.onDidAddGroup(updateHeaders);
event.api.onDidRemoveGroup(updateHeaders);
event.api.onDidMovePanel(updateHeaders);
event.api.onDidLayoutChange(updateHeaders);
updateHeaders();
}, []);
// Open agent job detail as center dock panel
const handleOpenJobDetail = useCallback((jobId: string) => {
const api = dockApi;
if (!api) return;
const panelId = makeReviewAgentJobPanelId(jobId);
const existing = api.getPanel(panelId);
if (existing) {
existing.api.setActive();
return;
}
const job = agentJobs.jobs.find(j => j.id === jobId);
api.addPanel({
id: panelId,
component: REVIEW_PANEL_TYPES.AGENT_JOB_DETAIL,
title: job?.label ?? `Job ${jobId.slice(0, 8)}`,
params: { jobId },
});
}, [dockApi, agentJobs.jobs]);
// Open tour as a dialog overlay
const handleOpenTour = useCallback((jobId: string) => {
setTourDialogJobId(jobId);
}, []);
// Dev-only: Cmd/Ctrl+Shift+T toggles the demo tour for fast UI iteration.
useEffect(() => {
if (!import.meta.env.DEV) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'T' || e.key === 't')) {
e.preventDefault();
setTourDialogJobId(prev => (prev === DEMO_TOUR_ID ? null : DEMO_TOUR_ID));
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Auto-open tour dialog when a tour job completes
const tourAutoOpenRef = useRef(new Set<string>());
useEffect(() => {
for (const job of agentJobs.jobs) {
if (
job.provider === 'tour' &&
job.status === 'done' &&
!tourAutoOpenRef.current.has(job.id)
) {
tourAutoOpenRef.current.add(job.id);
setTourDialogJobId(job.id);
}
}
}, [agentJobs.jobs]);
// Open PR panel as center dock panel
const handleOpenPRPanel = useCallback((type: 'summary' | 'comments' | 'checks') => {
const api = dockApi;
if (!api) return;
const config = {
summary: { id: REVIEW_PR_SUMMARY_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_SUMMARY, title: 'PR Summary' },
comments: { id: REVIEW_PR_COMMENTS_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_COMMENTS, title: 'PR Comments' },
checks: { id: REVIEW_PR_CHECKS_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_CHECKS, title: 'PR Checks' },
}[type];
const existing = api.getPanel(config.id);
if (existing) {
existing.api.setActive();
return;
}
api.addPanel({
id: config.id,
component: config.component,
title: config.title,
});
}, [dockApi]);
const openAllFilesPanel = useCallback(() => {
if (!dockApi) return;
semanticDiffAutoFallbackPending.current = false;
const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID);
if (existing) { existing.api.setActive(); return; }
dockApi.addPanel({
id: REVIEW_ALL_FILES_PANEL_ID,
component: REVIEW_PANEL_TYPES.ALL_FILES,
title: 'All files',
});
}, [dockApi]);
const openSemanticDiffPanel = useCallback((options?: { autoFallbackOnError?: boolean }) => {
if (!dockApi) return;
semanticDiffAutoFallbackPending.current = options?.autoFallbackOnError === true;
if (!semanticDiffAvailable) {
openAllFilesPanel();
return;
}
const existing = dockApi.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID);
if (existing) { existing.api.setActive(); return; }
dockApi.addPanel({
id: REVIEW_SEMANTIC_DIFF_PANEL_ID,
component: REVIEW_PANEL_TYPES.SEMANTIC_DIFF,
title: 'Semantic diff',
});
}, [dockApi, openAllFilesPanel, semanticDiffAvailable]);
const handleSemanticDiffUnavailable = useCallback(() => {
semanticDiffAutoFallbackPending.current = false;
setSemanticDiffAvailable(false);
dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
openAllFilesPanel();
}, [dockApi, openAllFilesPanel]);
const handleSemanticDiffLoadSuccess = useCallback(() => {
semanticDiffAutoFallbackPending.current = false;
}, []);
const handleSemanticDiffLoadError = useCallback(() => {
if (!semanticDiffAutoFallbackPending.current) return false;
if (dockApi?.activePanel?.id !== REVIEW_SEMANTIC_DIFF_PANEL_ID) {
// The user has already moved on; don't steal focus by auto-opening All files.
semanticDiffAutoFallbackPending.current = false;
return false;
}
semanticDiffAutoFallbackPending.current = false;
dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
openAllFilesPanel();
return true;
}, [dockApi, openAllFilesPanel]);
const applySemanticDiffAdvert = useCallback((semanticDiff?: SemanticDiffAdvert) => {
if (!semanticDiff) return;
const available = semanticDiff.available === true;
setSemanticDiffAvailable(available);
if (!available) {
semanticDiffAutoFallbackPending.current = false;
dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
if (isSemanticDiffActive) openAllFilesPanel();
}
}, [dockApi, isSemanticDiffActive, openAllFilesPanel]);
// Open the All files overview on first load. Semantic diff stays available via
// the file-tree nav entry, but it's no longer the default landing view.
useEffect(() => {
if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return;
needsInitialDiffPanel.current = false;
openAllFilesPanel();
}, [dockApi, files, openAllFilesPanel]);
// Global keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl+F to focus file search when diff files are available.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) {
if (hasSearchableFiles) {
e.preventDefault();
setIsFileTreeOpen(true);
openSearch();
}
return;
}
// Enter/F3 to step through search matches
if ((e.key === 'Enter' || e.key === 'F3') && searchMatches.length > 0 && !isSearchPending && !isTypingTarget(e.target)) {
e.preventDefault();
stepSearchMatch(e.shiftKey ? -1 : 1);
return;
}
// Escape closes modals or clears search
if (e.key === 'Escape') {
if (showDestinationMenu) {
setShowDestinationMenu(false);
} else if (showExportModal) {
setShowExportModal(false);
} else if (isSearchOpen) {
if (searchQuery) {
clearSearch();
} else {
closeSearch();
}
} else if (searchQuery) {
clearSearch();
}
}
// Cmd/Ctrl+B to toggle file tree
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) {
e.preventDefault();
setIsFileTreeOpen(prev => !prev);
}
// Cmd/Ctrl+. to toggle sidebar
if ((e.metaKey || e.ctrlKey) && e.key === '.' && !isTypingTarget(e.target)) {
e.preventDefault();
if (reviewSidebar.isOpen) reviewSidebar.close();
else reviewSidebar.open();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showExportModal, showDestinationMenu, isSearchOpen, searchQuery, searchMatches, isSearchPending, openSearch, stepSearchMatch, clearSearch, closeSearch, hasSearchableFiles, reviewSidebar.isOpen, reviewSidebar.open, reviewSidebar.close, isFileTreeOpen]);
// Load diff content - try API first, fall back to demo
useEffect(() => {
fetch('/api/diff')
.then(res => {
if (!res.ok) throw new Error('Not in API mode');
return res.json();
})
.then((data: {
rawPatch: string;
gitRef: string;
origin?: Origin;
mode?: string;
diffType?: string;
base?: string;
gitContext?: GitContext;
diffOptions?: DiffOption[];
agentCwd?: string;
sharingEnabled?: boolean;
repoInfo?: { display: string; branch?: string };
prMetadata?: PRMetadata;
prStackInfo?: PRStackInfo | null;
prStackTree?: PRStackTree | null;
prDiffScope?: PRDiffScope;
prDiffScopeOptions?: PRDiffScopeOption[];
platformUser?: string;
viewedFiles?: string[];
error?: string;
isWSL?: boolean;
semanticDiff?: SemanticDiffAdvert;
serverConfig?: { displayName?: string; gitUser?: string };
}) => {
// Initialize config store with server-provided values (config file > cookie > default)
configStore.init(data.serverConfig);
// gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable
setGitUser(data.serverConfig?.gitUser);
const apiFiles = parseDiffToFiles(data.rawPatch);
setDiffData({
files: apiFiles,
rawPatch: data.rawPatch,
gitRef: data.gitRef,
origin: data.origin,
diffType: data.diffType,
gitContext: data.gitContext,
diffOptions: data.diffOptions,
sharingEnabled: data.sharingEnabled,
});
setFiles(apiFiles);
setReviewMode(data.mode ?? null);
setWorkspaceDiffOptions(data.mode === 'workspace' ? (data.diffOptions ?? []) : null);
if (data.origin) setOrigin(data.origin);
if (data.diffType) setDiffType(data.diffType);
if (data.gitContext) {
setGitContext(data.gitContext);
// Prefer the server's active base (survives page refresh / reconnect)
// over the detected default, so the picker rehydrates to what the
// server is actually using.
const initial = data.base || data.gitContext.defaultBranch || data.gitContext.compareTarget?.fallback || null;
setSelectedBase(initial);
setCommittedBase(initial);
}
if (data.agentCwd) setAgentCwd(data.agentCwd);
if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled);
if (data.repoInfo) setRepoInfo(data.repoInfo);
updatePRSession({
...(data.prMetadata && { prMetadata: data.prMetadata }),
...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }),
...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }),
...(data.prDiffScope && { prDiffScope: data.prDiffScope }),
...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }),
});
if (data.platformUser) setPlatformUser(data.platformUser);
// Initialize viewed files from GitHub's state (set before draft restore so draft takes precedence)
if (data.viewedFiles && data.viewedFiles.length > 0) {
setViewedFiles(new Set(data.viewedFiles));
}
if (data.error) setDiffError(data.error);
if (data.isWSL) setIsWSL(true);
setSemanticDiffAvailable(data.semanticDiff?.available === true);
// Mark diff type setup as pending on first run (local mode only)
if (data.diffType && data.mode !== 'workspace' && !data.prMetadata && data.gitContext && data.gitContext.vcsType !== 'p4' && data.gitContext.vcsType !== 'jj' && needsDiffTypeSetup()) {
setDiffTypeSetupPending(true);
}
})
.catch(() => {
// Not in API mode - use demo content
const demoFiles = parseDiffToFiles(DEMO_DIFF);
setDiffData({
files: demoFiles,
rawPatch: DEMO_DIFF,
gitRef: 'demo',
});
setFiles(demoFiles);
setWorkspaceDiffOptions(null);
setSemanticDiffAvailable(false);
})
.finally(() => setIsLoading(false));
}, []);
// Show diff type setup after the initial diff payload marks it pending.
useEffect(() => {
if (diffTypeSetupPending) {
setDiffTypeSetupPending(false);
setShowDiffTypeSetup(true);
}
}, [diffTypeSetupPending]);
const handleDiffStyleChange = useCallback((style: 'split' | 'unified') => {
configStore.set('diffStyle', style);
}, []);
// Handle line selection from diff viewer
const handleLineSelection = useCallback((range: SelectedLineRange | null) => {
setPendingSelection(range);
}, []);
const handleAddAnnotationForFile = useCallback((
filePath: string,
type: CodeAnnotationType,
text?: string,
suggestedCode?: string,
originalCode?: string,
conventionalLabel?: ConventionalLabel,
decorations?: ConventionalDecoration[],
tokenMeta?: TokenAnnotationMeta
) => {
if (!pendingSelection) return;
const lineStart = Math.min(pendingSelection.start, pendingSelection.end);
const lineEnd = Math.max(pendingSelection.start, pendingSelection.end);
const newAnnotation: CodeAnnotation = {
id: generateId(),
type,
scope: 'line',
filePath,
lineStart,
lineEnd,
side: pendingSelection.side === 'additions' ? 'new' : 'old',
text,
suggestedCode,
originalCode,
...(tokenMeta && {
charStart: tokenMeta.charStart,
charEnd: tokenMeta.charEnd,
tokenText: tokenMeta.tokenText,
}),
createdAt: Date.now(),
author: identity,
conventionalLabel,
decorations,
};
setAnnotations(prev => [...prev, withPRContext(newAnnotation)]);
setPendingSelection(null);
}, [pendingSelection, identity, withPRContext]);
const handleAddAnnotation = useCallback((
type: CodeAnnotationType,
text?: string,
suggestedCode?: string,
originalCode?: string,
conventionalLabel?: ConventionalLabel,
decorations?: ConventionalDecoration[],
tokenMeta?: TokenAnnotationMeta
) => {
if (!files[activeFileIndex]) return;
handleAddAnnotationForFile(files[activeFileIndex].path, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta);
}, [files, activeFileIndex, handleAddAnnotationForFile]);
const handleAddFileComment = useCallback((text: string) => {
const activeFile = files[activeFileIndex];
const trimmed = text.trim();
if (!activeFile || !trimmed) return;
const newAnnotation: CodeAnnotation = {
id: generateId(),
type: 'comment',
scope: 'file',
filePath: activeFile.path,
lineStart: 1,
lineEnd: 1,
side: 'new',
text: trimmed,
createdAt: Date.now(),
author: identity,
};
setAnnotations(prev => [...prev, withPRContext(newAnnotation)]);
}, [files, activeFileIndex, identity, withPRContext]);
const handleAddFileCommentForFile = useCallback((filePath: string, text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const newAnnotation: CodeAnnotation = {
id: generateId(),
type: 'comment',
scope: 'file',
filePath,
lineStart: 1,
lineEnd: 1,
side: 'new',
text: trimmed,
createdAt: Date.now(),
author: identity,
};
setAnnotations(prev => [...prev, withPRContext(newAnnotation)]);
}, [identity, withPRContext]);
// Edit annotation
const handleEditAnnotation = useCallback((
id: string,
text?: string,
suggestedCode?: string,
originalCode?: string,
conventionalLabel?: ConventionalLabel | null,
decorations?: ConventionalDecoration[],
) => {
const ann = allAnnotationsRef.current.find(a => a.id === id);
const updates: Partial<CodeAnnotation> = {
...(text !== undefined && { text }),
...(suggestedCode !== undefined && { suggestedCode }),
...(originalCode !== undefined && { originalCode }),
// null clears the label; undefined means "not provided, keep existing"
...(conventionalLabel !== undefined && { conventionalLabel: conventionalLabel ?? undefined }),
...(decorations !== undefined && { decorations }),
};
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
updateExternalAnnotation(id, updates);
return;
}
setAnnotations(prev => prev.map(a =>
a.id === id ? { ...a, ...updates } : a
));
}, [updateExternalAnnotation, externalAnnotations]);
// selectedAnnotationId is cleared via a functional update (not a captured
// value): this handler is captured by Pierre slot portals (inline annotation
// delete buttons) that only republish on item version bumps — a closure over
// the state value goes stale and would leave a dangling selection id after
// deleting the currently-selected annotation.
const handleDeleteAnnotation = useCallback((id: string) => {
const ann = allAnnotationsRef.current.find(a => a.id === id);
if (ann?.source && externalAnnotations.some(e => e.id === id)) {
deleteExternalAnnotation(id);
setSelectedAnnotationId(prev => (prev === id ? null : prev));
return;
}
setAnnotations(prev => prev.filter(a => a.id !== id));
setSelectedAnnotationId(prev => (prev === id ? null : prev));
}, [deleteExternalAnnotation, externalAnnotations]);
// Handle identity change - update author on existing annotations
const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => {
setAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
}, []);
// Switch file in the dedicated center diff panel.
const handleFilePreview = useCallback((index: number) => {
const file = files[index];
if (!file) return;
openDiffFile(file.path);
}, [files, openDiffFile]);
// Double-click currently behaves the same as single-click.
const handleFilePinned = useCallback((index: number) => {
const file = files[index];
if (!file) return;
openDiffFile(file.path);
}, [files, openDiffFile]);
// Legacy file switch (used by handleSelectAnnotation, diff switch, etc.)
const handleFileSwitch = useCallback((index: number) => {
const file = files[index];
if (file) {
openDiffFile(file.path);
}
}, [files, openDiffFile]);
const handleToggleViewed = useCallback((filePath: string) => {
setViewedFiles(prev => {
const next = new Set(prev);
const willBeViewed = !prev.has(filePath);
if (willBeViewed) {
next.add(filePath);
} else {
next.delete(filePath);
}
// Sync viewed state to GitHub (fire and forget — best effort)
// Capture willBeViewed inside the callback to ensure correctness with React batching
if (prMetadata && prMetadata.platform === 'github') {
fetch('/api/pr-viewed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePaths: [filePath], viewed: willBeViewed }),
}).catch(() => {
// Silently ignore — viewed sync is best-effort
});
}
return next;
});
}, [prMetadata]);
// Derive worktree path and base diff type from the composite diffType string
const { activeWorktreePath, activeDiffBase } = useMemo(() => {
if (diffType.startsWith('worktree:')) {
const rest = diffType.slice('worktree:'.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon !== -1) {
const sub = rest.slice(lastColon + 1);
if (['uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) {
return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub };
}
}
return { activeWorktreePath: rest, activeDiffBase: 'uncommitted' };
}
return { activeWorktreePath: null, activeDiffBase: diffType };
}, [diffType]);
// Git add/staging logic
const handleFileViewedFromStage = useCallback(
(path: string) => setViewedFiles(prev => new Set(prev).add(path)),
[],
);
const { stagedFiles, stagingFile, canStageFiles: canStageRaw, stageFile, resetStagedFiles, stageError } = useGitAdd({
activeDiffBase,
onFileViewed: handleFileViewedFromStage,
});
// Staging is never available in PR review mode — the server rejects it and the UI shouldn't offer it.
const canStageInWorkspace = reviewMode !== 'workspace' || workspaceDiffOptions?.some((option) => option.id === 'workspace-staged');
const canStageFiles = canStageRaw && !prMetadata && canStageInWorkspace;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || isTypingTarget(e.target)) return;
if (!isDiffPanelActive) return;
const filePath = files[activeFileIndex]?.path;
if (!filePath) return;
if (e.key === 'v') {
e.preventDefault();
handleToggleViewed(filePath);
} else if (e.key === 'a' && canStageFiles) {
e.preventDefault();
stageFile(filePath);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [files, activeFileIndex, isDiffPanelActive, handleToggleViewed, canStageFiles, stageFile]);
// Shared function: apply a PR response (used by both initial load and PR switch)
function applyPRResponse(data: PRSessionUpdate & {
rawPatch: string; gitRef: string;
repoInfo?: { display: string; branch?: string };
viewedFiles?: string[]; error?: string;
semanticDiff?: SemanticDiffAdvert;
}) {
const isPRSwitch = !!data.prMetadata;
const nextFiles = parseDiffToFiles(data.rawPatch);
dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close();
needsInitialDiffPanel.current = true;
setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev);
setFiles(nextFiles);
if (isPRSwitch) {
setActiveFileIndex(0);
} else {
const currentFile = files[activeFileIndex];
const preserved = currentFile ? nextFiles.findIndex(f => f.path === currentFile.path) : -1;
setActiveFileIndex(preserved >= 0 ? preserved : 0);
}
setPendingSelection(null);
updatePRSession({
...(data.prMetadata && { prMetadata: data.prMetadata }),
...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }),
...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }),
...(data.prDiffScope && { prDiffScope: data.prDiffScope }),
...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }),
});
if (data.repoInfo) setRepoInfo(data.repoInfo);
if (data.prMetadata) {
setViewedFiles(data.viewedFiles ? new Set(data.viewedFiles) : new Set());
}
setDiffError(data.error || null);
applySemanticDiffAdvert(data.semanticDiff);
resetStagedFiles();
}
prStackCallbacksRef.current = {
applyPRResponse,
onError: (message) => setDiffError(message),
};
// Shared helper: fetch a diff switch and update state.
// Returns true on success, false on failure — callers that optimistically
// updated UI state (e.g. the base picker) can use this to revert.
const fetchDiffSwitch = useCallback(async (fullDiffType: string, baseOverride?: string, options?: { preserveFile?: boolean }): Promise<boolean> => {
setIsLoadingDiff(true);
try {
const res = await fetch('/api/diff/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diffType: fullDiffType,
// Server ignores base for modes that don't use it (uncommitted/staged/etc),
// so forwarding unconditionally is safe and keeps the request shape uniform.
...((baseOverride ?? selectedBase) && { base: baseOverride ?? selectedBase }),
hideWhitespace: diffHideWhitespace,
}),
});
if (!res.ok) throw new Error('Failed to switch diff');
const data = await res.json() as {
rawPatch: string;
gitRef: string;
diffType: string;
base?: string;
gitContext?: GitContext;
diffOptions?: DiffOption[];
error?: string;
semanticDiff?: SemanticDiffAdvert;
};
const nextFiles = parseDiffToFiles(data.rawPatch);
applySemanticDiffAdvert(data.semanticDiff);
if (options?.preserveFile) {
// Whitespace toggle: update patch in-place, keep the active file.
// If the current file was removed (whitespace-only), retarget the
// dock panel to the first remaining file.
setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev);
if (data.diffOptions) setWorkspaceDiffOptions(data.diffOptions);
setFiles(nextFiles);
const currentPath = files[activeFileIndex]?.path;
const nextIdx = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1;
if (nextIdx !== -1) {
setActiveFileIndex(nextIdx);
} else if (nextFiles.length > 0) {
setActiveFileIndex(0);
openDiffFile(nextFiles[0].path);
}
// Line numbers can shift when whitespace handling changes, so a
// selection anchored to the old patch is stale — clear it (the
// non-preserve branch below already does).
setPendingSelection(null);
} else {
dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close();
needsInitialDiffPanel.current = true;
setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev);
setFiles(nextFiles);
setDiffType(data.diffType);
if (data.diffOptions) setWorkspaceDiffOptions(data.diffOptions);
if (data.base) {
setSelectedBase(data.base);
setCommittedBase(data.base);
}
// Merge only the per-cwd fields so the sidebar reflects the worktree
// we're now in. Keep the original `worktrees` list (already filtered to
// exclude the server's startup cwd — replacing it with the new context's
// list would duplicate the "Main repo" entry) and `availableBranches`
// (shared across worktrees of the same repo).
//
// IMPORTANT: we deliberately do NOT overwrite `currentBranch`. The
// WorktreePicker's top "launch" row uses it as a label, and that row
// represents the cwd plannotator was launched in — not whichever
// worktree is currently active. Freezing `currentBranch` at its
// initial-load value keeps that label truthful. `defaultBranch` and
// `diffOptions` update because they describe the active diff, which
// other UI (empty-state text, diff-type picker) should see fresh.
if (data.gitContext) {
setGitContext((prev) => {
if (!prev) return data.gitContext!;
return {
...prev,
defaultBranch: data.gitContext!.defaultBranch,
diffOptions: data.gitContext!.diffOptions,
compareTarget: data.gitContext!.compareTarget,
jjEvologs: data.gitContext!.jjEvologs,
// HEAD differs per worktree, so refresh the commit-baseline picker.
recentCommits: data.gitContext!.recentCommits,
};
});
}
setActiveFileIndex(0);
setPendingSelection(null);
resetStagedFiles();
}
setDiffError(data.error || null);
return true;
} catch (err) {
console.error('Failed to switch diff:', err);
setDiffError(err instanceof Error ? err.message : 'Failed to switch diff');
return false;
} finally {
setIsLoadingDiff(false);
}
}, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile, applySemanticDiffAdvert]);
// Switch the base branch the current diff compares against.
// Only triggers a refetch when the active mode actually uses a base.
// Optimistically updates the picker; reverts if the server-side switch
// fails so the chip doesn't lie about what the viewer is actually showing.
const handleBaseSelect = useCallback(
async (branch: string) => {
if (branch === selectedBase) return;
const previous = selectedBase;
setSelectedBase(branch);
if (activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') {
const ok = await fetchDiffSwitch(diffType, branch);
if (!ok) setSelectedBase(previous);
}
},
[selectedBase, activeDiffBase, diffType, fetchDiffSwitch],
);
// Switch diff type (uncommitted, last-commit, branch) — composes worktree prefix if active
const handleDiffSwitch = useCallback(async (baseDiffType: string) => {
const fullDiffType = activeWorktreePath
? `worktree:${activeWorktreePath}:${baseDiffType}`
: baseDiffType;
if (fullDiffType === diffType) return;
// For evolog, default to the second entry (previous state of @) so the
// server doesn't fall back to the jj bookmark/trunk revset.
// When leaving evolog, restore the base to the detected compare target
// so other base-dependent modes (jj-line) don't inherit a commit ID.
const enteringEvolog =
baseDiffType === 'jj-evolog' && gitContext?.jjEvologs && gitContext.jjEvologs.length >= 2;
const leavingEvolog =
!enteringEvolog && activeDiffBase === 'jj-evolog' && gitContext?.defaultBranch;
const baseOverride = enteringEvolog
? gitContext!.jjEvologs![1].commitId
: leavingEvolog
? gitContext!.defaultBranch
: undefined;
if (baseOverride) setSelectedBase(baseOverride);
await fetchDiffSwitch(fullDiffType, baseOverride);
}, [diffType, activeWorktreePath, fetchDiffSwitch, gitContext]);
// Switch worktree context (or back to main repo). Preserves the current
// diff mode across the switch — if the reviewer was looking at "PR Diff"
// in the main repo, they should keep looking at "PR Diff" in the target
// worktree rather than being silently snapped back to "Uncommitted".
const handleWorktreeSwitch = useCallback(async (worktreePath: string | null) => {
if (worktreePath === activeWorktreePath) return;
const fullDiffType = worktreePath
? `worktree:${worktreePath}:${activeDiffBase}`
: activeDiffBase;
await fetchDiffSwitch(fullDiffType);
}, [activeWorktreePath, activeDiffBase, fetchDiffSwitch]);
// Re-fetch diff when hideWhitespace toggles so the server applies git diff -w.
// Preserves the active file since only whitespace hunks change.
const hideWhitespaceInitialized = useRef(false);
useEffect(() => {
if (!origin || (!gitContext && reviewMode !== 'workspace')) return;
if (!hideWhitespaceInitialized.current) {
hideWhitespaceInitialized.current = true;
return;
}
fetchDiffSwitch(diffType, selectedBase, { preserveFile: true });
}, [diffHideWhitespace, origin, reviewMode]); // eslint-disable-line react-hooks/exhaustive-deps
// --- Diff staleness ---------------------------------------------------------
// Files changing mid-review (an agent editing/committing while the user
// reviews) make the snapshot on screen stale. The hook polls the server's
// cheap fingerprint check; the toolbar shows a non-blocking notice and the
// user refreshes when THEY are ready — never automatically (annotations are
// line-anchored; rug-pulling the diff under them is worse than staleness).
const diffFreshness = useDiffFreshness({
enabled: !!origin,
resetKey: diffData?.rawPatch ?? '',
});
const handleRefreshStaleDiff = useCallback(() => {
if (prMetadata) {
// Only the full-stack scope can go stale locally — the layer diff is
// computed platform-side and its fingerprint never flips.
if (prDiffScope === 'full-stack') handlePRDiffScopeSelect('full-stack');
return;
}
// Same params, fresh snapshot. preserveFile keeps the reviewer on the
// file they were reading.
void fetchDiffSwitch(diffType, selectedBase, { preserveFile: true });
}, [prMetadata, prDiffScope, handlePRDiffScopeSelect, fetchDiffSwitch, diffType, selectedBase]);
// Select annotation - switches file if needed and scrolls to it.
// isAllFilesActive is read through the ref (declared with the state): this
// handler is baked into Pierre slot portals, which only republish on item
// version bumps — a stale captured value would yank the user out of the
// all-files tab into the single-file panel when they click an annotation.
const handleSelectAnnotation = useCallback((id: string | null) => {
if (!id) {
setSelectedAnnotationId(null);
return;
}
// Find the annotation
const annotation = allAnnotationsRef.current.find(a => a.id === id);
if (!annotation) {
setSelectedAnnotationId(id);
return;
}
// In all-files mode, just set the selection — the panel's scroll-to-annotation
// effect handles expanding and scrolling. In single-file mode, switch to the file.
if (!isAllFilesActiveRef.current) {
const fileIndex = files.findIndex(f => f.path === annotation.filePath);
if (fileIndex !== -1) {
handleFileSwitch(fileIndex);
}
}
setSelectedAnnotationId(id);
}, [files, handleFileSwitch]);
// Diff context bundled into local-mode feedback headers so the receiving
// agent knows which diff the annotations are anchored to. Uses committedBase
// (what the server actually computed) and activeDiffBase/activeWorktreePath
// (derived from the committed diffType). Skipped in PR mode — the PR header
// already carries the relevant context.
// Declared before reviewStateValue because both reviewStateValue and the
// feedbackMarkdown memo below read it; moving it below either would put it
// in the TDZ when those memos run on first render.
const feedbackDiffContext = useMemo(
() =>
prMetadata || !activeDiffBase
? undefined
: {
mode: activeDiffBase,
base: committedBase ?? undefined,
worktreePath: activeWorktreePath,
},
[prMetadata, activeDiffBase, committedBase, activeWorktreePath],
);
const prReviewScopeLabel = useMemo(() => {
if (!prMetadata || !prStackInfo) return undefined;
if (prDiffScope === 'full-stack') {
return `Diff vs \`${prMetadata.defaultBranch ?? 'default branch'}\``;
}
return `Diff vs \`${prMetadata.baseBranch}\``;
}, [prMetadata, prStackInfo, prDiffScope]);
// Build ReviewState value for dock panel context
const reviewStateValue = useMemo<ReviewState>(() => ({
files,
rawPatch: diffData?.rawPatch ?? '',
focusedFileIndex: activeFileIndex,
focusedFilePath: files[activeFileIndex]?.path ?? null,
diffStyle,
diffOverflow,
diffIndicators,
lineDiffType: diffLineDiffType,
disableLineNumbers: !diffShowLineNumbers,
disableBackground: !diffShowBackground,
expandUnchanged: diffExpandUnchanged,
fontFamily: diffFontFamily || undefined,
fontSize: diffFontSize || undefined,
// Only propagate base for modes where it affects old/new content. Avoids
// needless file-content re-fetches when switching to uncommitted/staged/etc.
// Uses committedBase (not selectedBase) so file-content queries wait for
// the new patch to arrive before refetching — otherwise the viewer can
// briefly pair an old patch with the new base's content.
reviewBase:
(activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog')
? committedBase ?? undefined
: undefined,
activeDiffBase,
feedbackDiffContext,
prReviewScope: prReviewScopeLabel,
prDiffScope,
allAnnotations,
externalAnnotations,
selectedAnnotationId,
pendingSelection,
onLineSelection: handleLineSelection,
onAddAnnotation: handleAddAnnotation,
onAddAnnotationForFile: handleAddAnnotationForFile,
onAddFileComment: handleAddFileComment,
onAddFileCommentForFile: handleAddFileCommentForFile,
onEditAnnotation: handleEditAnnotation,
onSelectAnnotation: handleSelectAnnotation,
onDeleteAnnotation: handleDeleteAnnotation,
viewedFiles,
onToggleViewed: handleToggleViewed,
stagedFiles,
stagingFile,
onStage: stageFile,
canStageFiles,
stageError,
searchQuery: isSearchPending ? '' : debouncedSearchQuery,
isSearchPending,
debouncedSearchQuery,
activeFileSearchMatches,
activeSearchMatchId,
activeSearchMatch: activeSearchMatch?.filePath === files[activeFileIndex]?.path ? activeSearchMatch : null,
searchMatches,
allFilesActiveSearchMatch: activeSearchMatch,
aiAvailable,
aiMessages,
onAskAI: handleAskAI,
onAskAIForFile: handleAskAIForFile,
isAILoading: aiIsCreatingSession || aiIsStreaming,
onViewAIResponse: handleViewAIResponse,
onClickAIMarker: handleClickAIMarker,
aiHistoryForSelection,
getAIHistoryForFile,
agentJobs: agentJobs.jobs,
prMetadata,
prContext,
isPRContextLoading,
prContextError,
fetchPRContext,
platformUser,
openDiffFile,
onAllFilesVisibleFileChange: setAllFilesVisibleFile,
isAllFilesActive,
isSemanticDiffActive,
semanticDiffAvailable,
onSemanticDiffUnavailable: handleSemanticDiffUnavailable,
onSemanticDiffLoadError: handleSemanticDiffLoadError,
onSemanticDiffLoadSuccess: handleSemanticDiffLoadSuccess,
openTourPanel: handleOpenTour,
onCodeNavRequest: handleCodeNavRequest,
codeNavResult: codeNav.result,
codeNavIsLoading: codeNav.isLoading,
codeNavActiveSymbol: codeNav.activeSymbol,
}), [
files, diffData?.rawPatch, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope,
allAnnotations, externalAnnotations,
selectedAnnotationId, pendingSelection, handleLineSelection,
handleAddAnnotation, handleAddFileComment, handleAddFileCommentForFile, handleEditAnnotation,
handleSelectAnnotation, handleDeleteAnnotation, viewedFiles,
handleToggleViewed, stagedFiles, stagingFile, stageFile,
canStageFiles, stageError, isSearchPending, debouncedSearchQuery,
activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, searchMatches,
aiAvailable, aiMessages, aiIsCreatingSession, aiIsStreaming,
handleAskAI, handleAskAIForFile, handleViewAIResponse, handleClickAIMarker,
aiHistoryForSelection, getAIHistoryForFile, agentJobs.jobs, prMetadata, prContext,
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
handleOpenTour, isAllFilesActive, isSemanticDiffActive, semanticDiffAvailable,
handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile,
handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol,
]);
// Separate context for high-frequency job logs — prevents re-rendering all panels on every SSE event
const jobLogsValue = useMemo(() => ({ jobLogs: agentJobs.jobLogs }), [agentJobs.jobLogs]);
// Copy raw diff to clipboard
const handleCopyDiff = useCallback(async () => {
if (!diffData) return;
try {
await navigator.clipboard.writeText(diffData.rawPatch);
setCopyRawDiffStatus('success');
setTimeout(() => setCopyRawDiffStatus('idle'), 2000);
} catch (err) {
console.error('Failed to copy:', err);
setCopyRawDiffStatus('error');
setTimeout(() => setCopyRawDiffStatus('idle'), 2000);
}
}, [diffData]);
// Copy feedback markdown to clipboard
const handleCopyFeedback = useCallback(async () => {
if (allAnnotations.length === 0) {
setShowNoAnnotationsDialog(true);
return;
}
try {
const feedback = exportReviewFeedback(allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel);
await navigator.clipboard.writeText(feedback);
setCopyFeedback('Feedback copied!');
setTimeout(() => setCopyFeedback(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
setCopyFeedback('Failed to copy');
setTimeout(() => setCopyFeedback(null), 2000);
}
}, [allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel]);
const feedbackMarkdown = useMemo(() => {
let output = exportReviewFeedback(allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel);
if (editorAnnotations.length > 0) {
output += exportEditorAnnotations(editorAnnotations);
}
return output;
}, [allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel, editorAnnotations]);
const totalAnnotationCount = allAnnotations.length + editorAnnotations.length;
// Send feedback to OpenCode via API
const handleSendFeedback = useCallback(async () => {
if (totalAnnotationCount === 0) {
setShowNoAnnotationsDialog(true);
return;
}
setIsSendingFeedback(true);
try {
const agentSwitchSettings = getAgentSwitchSettings();
const effectiveAgent = getEffectiveAgentName(agentSwitchSettings);
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
approved: false,
feedback: feedbackMarkdown,
annotations: allAnnotations,
...(effectiveAgent && { agentSwitch: effectiveAgent }),
}),
});
if (res.ok) {
setSubmitted('feedback');
} else {
throw new Error('Failed to send');
}
} catch (err) {
console.error('Failed to send feedback:', err);
setCopyFeedback('Failed to send');
setTimeout(() => setCopyFeedback(null), 2000);
setIsSendingFeedback(false);
}
}, [totalAnnotationCount, feedbackMarkdown, allAnnotations]);
// Exit review session without sending any feedback
const handleExit = useCallback(async () => {
setIsExiting(true);
try {
const res = await fetch('/api/exit', { method: 'POST' });
if (res.ok) {
setSubmitted('exited');
} else {
throw new Error('Failed to exit');
}
} catch (error) {
console.error('Failed to exit review:', error);
setIsExiting(false);
}
}, []);
// Approve without feedback (LGTM)
const handleApprove = useCallback(async () => {
setIsApproving(true);
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
approved: true,
feedback: 'LGTM - no changes requested.', // unused — integrations branch on `approved` flag
annotations: [],
}),
});
if (res.ok) {
setSubmitted('approved');
} else {
throw new Error('Failed to send');
}
} catch (err) {
console.error('Failed to approve:', err);
setCopyFeedback('Failed to send');
setTimeout(() => setCopyFeedback(null), 2000);
setIsApproving(false);
}
}, []);
// Submit reviews to one or more PRs via /api/pr-action
const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', plan: ReviewSubmission, generalComment?: string) => {
setIsPlatformActioning(true);
setPlatformActionError(null);
try {
const bodyForTarget = (target: SubmissionTarget) => {
const parts: string[] = [];
if (generalComment) parts.push(generalComment);
parts.push('Review from Plannotator');
if (target.fileScopedBody) parts.push(target.fileScopedBody);
return parts.join('\n\n');
};
// For approve, only post to the currently viewed PR.
// For comment with no targets but a general comment, create a minimal target.
let targets = plan.targets;
if (action === 'approve' || (targets.length === 0 && generalComment?.trim())) {
const currentTarget = plan.targets.find(t => t.prUrl === prMetadata?.url);
targets = currentTarget ? [currentTarget] : [{
prUrl: prMetadata?.url ?? '',
prNumber: prMetadata ? (prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid) : 0,
prTitle: prMetadata?.title ?? '',
prRepo: prMetadata ? getDisplayRepo(prMetadata) : '',
fileComments: [], fileScopedBody: '',
fileCount: 0, annotationCount: 0, status: 'pending' as const,
}];
}
const openUrls: string[] = [];
const results = await Promise.allSettled(
targets.map(async (target): Promise<SubmissionTarget> => {
if (target.status === 'success') return target;
try {
const prRes = await fetch('/api/pr-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action,
body: bodyForTarget(target),
fileComments: target.fileComments,
targetPrUrl: target.prUrl || undefined,
}),
});
const prData = await prRes.json() as { ok?: boolean; prUrl?: string; error?: string };
if (!prRes.ok || prData.error) {
return { ...target, status: 'failed', error: prData.error ?? 'Failed to submit' };
}
if (prData.prUrl) openUrls.push(prData.prUrl);
return { ...target, status: 'success' };
} catch (err) {
return { ...target, status: 'failed', error: err instanceof Error ? err.message : 'Network error' };
}
}),
);
const updatedTargets = results.map((r, i) => r.status === 'fulfilled' ? r.value : { ...targets[i], status: 'failed' as const, error: 'Unexpected error' });
const allOk = updatedTargets.every(t => t.status === 'success');
if (!allOk) {
setPlatformCommentDialog(prev => prev ? {
...prev,
plan: { ...plan, targets: updatedTargets },
} : null);
return;
}
setPlatformCommentDialog(null);
setSubmitted(action === 'approve' ? 'approved' : 'feedback');
if (platformOpenPR) {
for (const url of openUrls) window.open(url, '_blank');
}
const agentSwitchSettings = getAgentSwitchSettings();
const effectiveAgent = getEffectiveAgentName(agentSwitchSettings);
const prLinks = openUrls.join(', ');
const statusMessage = action === 'approve'
? `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} approved on ${platformLabel}${prLinks ? ': ' + prLinks : ''}`
: `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} reviewed on ${platformLabel}${prLinks ? ': ' + prLinks : ''}`;
fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
approved: false,
feedback: statusMessage,
annotations: [],
...(effectiveAgent && { agentSwitch: effectiveAgent }),
}),
}).catch(() => {});
} catch (err) {
setPlatformActionError(err instanceof Error ? err.message : 'Failed to submit review');
} finally {
setIsPlatformActioning(false);
}
}, [platformOpenPR, platformLabel, mrLabel, prMetadata]);
const openPlatformDialog = useCallback((action: 'approve' | 'comment') => {
const diffPaths = new Set(files.map(f => f.path));
const prMeta = prMetadata ? {
number: prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid,
title: prMetadata.title,
repo: getDisplayRepo(prMetadata),
} : undefined;
const plan = buildReviewSubmission(allAnnotations, editorAnnotations, prMetadata?.url, diffPaths, prMeta);
setPlatformGeneralComment('');
setPlatformCommentDialog({ action, plan });
}, [allAnnotations, editorAnnotations, files, prMetadata]);
// Double-tap Option/Alt to toggle review destination (PR mode only)
useEffect(() => {
if (!prMetadata) return;
let lastAltUp = 0;
const DOUBLE_TAP_WINDOW = 300;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Alt' || e.repeat) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key !== 'Alt') return;
const now = Date.now();
if (now - lastAltUp < DOUBLE_TAP_WINDOW) {
setReviewDestination(prev => {
const next = prev === 'platform' ? 'agent' : 'platform';
storage.setItem('plannotator-review-dest', next);
setPlatformActionError(null);
return next;
});
lastAltUp = 0;
} else {
lastAltUp = now;
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [prMetadata]);
// Cmd/Ctrl+Enter keyboard shortcut to approve or send feedback
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return;
// If the platform post dialog is open, Cmd+Enter submits it
if (platformCommentDialog) {
if (submitted || isPlatformActioning) return;
const isApproveAction = platformCommentDialog.action === 'approve';
const hasTargets = platformCommentDialog.plan.targets.length > 0;
const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim();
if (!canSubmit) return;
e.preventDefault();
handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment);
return;
}
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return;
if (submitted || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return;
if (!origin) return; // Demo mode
e.preventDefault();
if (platformMode) {
// GitHub mode: No annotations → Approve on GitHub, otherwise → Post Review
const isOwnPR = !!platformUser && prMetadata?.author === platformUser;
if (totalAnnotationCount === 0 && !isOwnPR) {
openPlatformDialog('approve');
} else {
openPlatformDialog('comment');
}
} else {
// Agent mode: No annotations → Approve, otherwise → Send Feedback
if (totalAnnotationCount === 0) {
handleApprove();
} else {
handleSendFeedback();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning,
platformCommentDialog, platformGeneralComment,
submitted, isSendingFeedback, isApproving, isExiting, isPlatformActioning,
origin, platformMode, platformLabel, platformUser, prMetadata, totalAnnotationCount, openPlatformDialog,
handleApprove, handleSendFeedback, handlePlatformAction
]);
if (isLoading) {
return (
<ThemeProvider defaultTheme="dark">
<div className="h-screen flex items-center justify-center bg-background">
<div className="text-muted-foreground text-sm">Loading diff...</div>
</div>
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="dark">
<TooltipProvider delayDuration={200} skipDelayDuration={100}>
<ReviewStateProvider value={reviewStateValue}>
<JobLogsProvider value={jobLogsValue}>
{isSwitchingPRScope && <PRSwitchOverlay />}
<div className="h-screen flex flex-col bg-background overflow-hidden">
{/* Header */}
<header className="py-1 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl z-50">
<div className="min-w-0 flex items-center gap-2 md:gap-3 -ml-1.5 md:-ml-3">
{shouldShowFileTree && (
<>
<button
onClick={() => setIsFileTreeOpen(prev => !prev)}
className={`p-1 rounded-md transition-all focus-visible:outline-none ${
isFileTreeOpen
? 'text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isFileTreeOpen ? 'Hide file tree' : 'Show file tree'}
>
<FolderTree className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
{prMetadata ? (
<div className="min-w-0 flex items-center gap-2 md:gap-3">
<span
className="text-xs text-muted-foreground/60 inline-flex items-center gap-1 whitespace-nowrap"
>
<RepoIcon className="w-3 h-3 flex-shrink-0" />
{displayRepo}
</span>
<PRSelector
mrNumberLabel={mrNumberLabel}
prTitle={prMetadata.title}
currentNumber={prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid}
onSelect={handlePRSwitch}
disabled={isSwitchingPRScope}
/>
<StackedPRLabel
metadata={prMetadata}
mrNumberLabel={mrNumberLabel}
stackInfo={prStackInfo}
stackTree={prStackTree}
scope={prDiffScope}
scopeOptions={prDiffScopeOptions}
isSwitchingScope={isSwitchingPRScope}
onSelectScope={handlePRDiffScopeSelect}
onNavigatePR={handlePRSwitch}
/>
<div className="hidden md:flex items-center gap-0.5 ml-1">
<button onClick={() => handleOpenPRPanel('summary')} className="p-1 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted/30 transition-colors duration-150" title="PR Summary">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</button>
<button onClick={() => handleOpenPRPanel('comments')} className="p-1 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted/30 transition-colors duration-150" title="PR Comments">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</button>
<button onClick={() => handleOpenPRPanel('checks')} className="p-1 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted/30 transition-colors duration-150" title="PR Checks">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</button>
</div>
</div>
) : repoInfo ? (
<div className="min-w-0 flex items-center gap-2 md:gap-3">
{repoInfo.branch && (
<span
className="text-xs font-mono text-foreground truncate"
title={repoInfo.branch}
>
{repoInfo.branch}
</span>
)}
<span
className="text-xs text-muted-foreground/60 inline-flex items-center gap-1 truncate max-w-[220px]"
title={repoInfo.display}
>
<RepoIcon className="w-3 h-3 flex-shrink-0" />
{repoInfo.display}
</span>
</div>
) : (
<span className="text-xs text-muted-foreground/70">Review</span>
)}
</div>
<div className="flex items-center gap-1 md:gap-2">
{/* Diff style toggle */}
<div className="flex items-center gap-1 bg-muted rounded-lg p-0.5">
<button
onClick={() => handleDiffStyleChange('split')}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
diffStyle === 'split'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Split
</button>
<button
onClick={() => handleDiffStyleChange('unified')}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
diffStyle === 'unified'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Unified
</button>
</div>
{origin ? (
<>
{/* Destination dropdown (PR mode only) */}
{prMetadata && (
<div className="relative">
<button
onClick={() => setShowDestinationMenu(prev => !prev)}
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
title={reviewDestination === 'platform' ? `Posting to ${platformLabel} ${mrLabel}` : 'Sending to agent session'}
>
{reviewDestination === 'platform' ? (
<>
{prMetadata?.platform === 'gitlab' ? <GitLabIcon className="w-3.5 h-3.5" /> : <GitHubIcon className="w-3.5 h-3.5" />}
<span>{platformLabel}</span>
</>
) : 'Agent'}
<svg className="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showDestinationMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowDestinationMenu(false)} />
<div className="absolute right-0 top-full mt-1 py-1 bg-popover border border-border rounded-lg shadow-xl z-50 min-w-[160px]">
<button
onClick={() => {
setReviewDestination('platform');
storage.setItem('plannotator-review-dest', 'platform');
setShowDestinationMenu(false);
setPlatformActionError(null);
}}
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
reviewDestination === 'platform'
? 'text-foreground bg-muted/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/30'
}`}
>
<div className="font-medium">{platformLabel}</div>
<div className="text-muted-foreground/60">Post to {mrLabel}</div>
</button>
<button
onClick={() => {
setReviewDestination('agent');
storage.setItem('plannotator-review-dest', 'agent');
setShowDestinationMenu(false);
setPlatformActionError(null);
}}
className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
reviewDestination === 'agent'
? 'text-foreground bg-muted/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/30'
}`}
>
<div className="font-medium">Agent</div>
<div className="text-muted-foreground/60">Send to session</div>
</button>
<div className="border-t border-border/50 mt-1 pt-1 px-3 py-1">
<span className="text-[10px] text-muted-foreground/40">
<kbd className="inline-flex items-center justify-center min-w-[18px] h-[16px] px-1 rounded bg-muted border border-border/60 border-b-[2px] text-[9px] font-mono leading-none text-foreground/60 shadow-sm">{altKey}</kbd>
<kbd className="inline-flex items-center justify-center min-w-[18px] h-[16px] px-1 rounded bg-muted border border-border/60 border-b-[2px] text-[9px] font-mono leading-none text-foreground/60 shadow-sm ml-0.5">{altKey}</kbd>
<span className="ml-1.5">to toggle</span>
</span>
</div>
</div>
</>
)}
</div>
)}
{/* GitHub error message */}
{platformActionError && (
<div
className="text-xs text-destructive px-2 py-1 bg-destructive/10 rounded border border-destructive/20 max-w-[200px] truncate"
title={platformActionError}
>
{platformActionError}
</div>
)}
{reviewMode === 'workspace' && diffError && (
<div
className="text-xs text-amber-700 dark:text-amber-300 px-2 py-1 bg-amber-500/10 rounded border border-amber-500/25 max-w-[240px] truncate"
title={diffError}
>
{files.length > 0 ? 'Some workspace changes could not be loaded' : 'Workspace changes could not be loaded'}
</div>
)}
{/* Diff staleness notice — files changed since this snapshot
was computed (agent editing mid-review). Non-blocking; the
user refreshes when ready. */}
{diffFreshness.isStale && !isLoadingDiff && (
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300 px-2 py-1 bg-amber-500/10 rounded border border-amber-500/25">
<span className="hidden md:inline">Diff out of date</span>
<span className="md:hidden">Stale</span>
<button
onClick={handleRefreshStaleDiff}
className="font-medium underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-100 transition-colors"
title="Re-run the diff with the current settings"
>
Refresh
</button>
<button
onClick={diffFreshness.dismiss}
className="text-amber-700/60 dark:text-amber-300/60 hover:text-amber-900 dark:hover:text-amber-100 transition-colors leading-none"
title="Dismiss"
aria-label="Dismiss stale diff notice"
>
×
</button>
</div>
)}
{/* Agent mode: Close/SendFeedback flip + Approve */}
{!platformMode ? (
<AgentReviewActions
totalAnnotationCount={totalAnnotationCount}
isSendingFeedback={isSendingFeedback}
isApproving={isApproving}
isExiting={isExiting}
onSendFeedback={handleSendFeedback}
onApprove={() => totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()}
onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()}
/>
) : (
<>
{/* Platform mode: Close + Post Comments + Approve */}
<ExitButton
onClick={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()}
disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning}
isLoading={isExiting}
/>
<FeedbackButton
onClick={() => openPlatformDialog('comment')}
disabled={isSendingFeedback || isApproving || isPlatformActioning}
isLoading={isSendingFeedback || isPlatformActioning}
label="Post Comments"
shortLabel="Post"
loadingLabel="Posting..."
shortLoadingLabel="Posting..."
title="Post review to platform"
/>
<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="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" />
You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}.
</div>
)}
</div>
</>
)}
</>
) : (
<button
onClick={handleCopyFeedback}
className="px-2 py-1 md:px-2.5 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors flex items-center gap-1.5"
title="Copy feedback for LLM"
>
{copyFeedback === 'Feedback copied!' ? (
<>
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="hidden md:inline">Copied!</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="hidden md:inline">Copy Feedback</span>
</>
)}
</button>
)}
{/* Global diff display options. These settings apply to every
file's diff, so they live once in the toolbar instead of being
repeated in every FileHeader. */}
<DiffOptionsPopover />
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
{/* Sidebar tab toggles */}
<button
onClick={() => reviewSidebar.toggleTab('annotations')}
className={`relative p-1.5 rounded-md transition-all ${
reviewSidebar.isOpen && reviewSidebar.activeTab === 'annotations'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="Annotations"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
{totalAnnotationCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] flex items-center justify-center rounded-full bg-primary text-[8px] font-bold text-primary-foreground px-0.5">
{totalAnnotationCount > 99 ? '99+' : totalAnnotationCount}
</span>
)}
</button>
{aiAvailable && (
<button
onClick={() => reviewSidebar.toggleTab('ai')}
className={`relative p-1.5 rounded-md transition-all ${
reviewSidebar.isOpen && reviewSidebar.activeTab === 'ai'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="AI Chat"
>
<SparklesIcon className="w-4 h-4" />
{aiMessages.length > 0 && !(reviewSidebar.isOpen && reviewSidebar.activeTab === 'ai') && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
)}
{agentJobs.capabilities?.available && (
<button
onClick={() => reviewSidebar.toggleTab('agents')}
className={`relative p-1.5 rounded-md transition-all ${
reviewSidebar.isOpen && reviewSidebar.activeTab === 'agents'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="Review Agents"
>
<ReviewAgentsIcon className="w-4 h-4" />
{agentJobs.jobs.some(j => j.status === 'running' || j.status === 'starting') && !(reviewSidebar.isOpen && reviewSidebar.activeTab === 'agents') && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
)}
</button>
)}
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
<ReviewHeaderMenu
onOpenSettings={() => setOpenSettingsMenu(true)}
onOpenExport={() => setShowExportModal(true)}
onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)}
onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()}
isFileTreeOpen={isFileTreeOpen}
isSidebarOpen={reviewSidebar.isOpen}
appVersion={appVersion}
updateInfo={updateInfo}
origin={origin}
isWSL={isWSL}
/>
</div>
</header>
{/* Main content */}
<div className={`flex-1 flex overflow-hidden ${isResizing ? 'select-none' : ''}`}>
{shouldShowFileTree && isFileTreeOpen && (
<div className="contents group/sidebar">
<FileTree
files={files}
activeFileIndex={activeFileIndex}
onSelectSemanticDiff={() => openSemanticDiffPanel()}
isSemanticDiffActive={isSemanticDiffActive}
semanticDiffAvailable={semanticDiffAvailable}
onSelectAllFiles={openAllFilesPanel}
isAllFilesActive={isAllFilesActive}
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
onSelectFile={handleFilePreview}
onDoubleClickFile={handleFilePinned}
annotations={allAnnotations}
viewedFiles={viewedFiles}
onToggleViewed={handleToggleViewed}
hideViewedFiles={hideViewedFiles}
onToggleHideViewed={() => setHideViewedFiles(prev => !prev)}
enableKeyboardNav={!showExportModal && hasSearchableFiles}
diffOptions={reviewMode === 'workspace' ? (workspaceDiffOptions ?? undefined) : gitContext?.diffOptions}
activeDiffType={activeDiffBase}
onSelectDiff={handleDiffSwitch}
isLoadingDiff={isLoadingDiff}
width={fileTreeResize.width}
worktrees={gitContext?.worktrees}
activeWorktreePath={activeWorktreePath}
onSelectWorktree={handleWorktreeSwitch}
currentBranch={gitContext?.currentBranch}
availableBranches={prMetadata ? undefined : gitContext?.availableBranches}
selectedBase={prMetadata ? undefined : selectedBase ?? undefined}
detectedBase={prMetadata ? undefined : gitContext?.defaultBranch || gitContext?.compareTarget?.fallback}
onSelectBase={prMetadata ? undefined : handleBaseSelect}
compareTarget={gitContext?.compareTarget}
recentCommits={prMetadata ? undefined : gitContext?.recentCommits}
jjEvologs={prMetadata ? undefined : gitContext?.jjEvologs}
detectedEvoBase={prMetadata ? undefined : gitContext?.jjEvologs?.[1]?.commitId}
stagedFiles={stagedFiles}
onCopyRawDiff={handleCopyDiff}
canCopyRawDiff={!!diffData?.rawPatch}
copyRawDiffStatus={copyRawDiffStatus}
searchQuery={hasSearchableFiles ? searchQuery : ''}
isSearchOpen={hasSearchableFiles ? isSearchOpen : false}
isSearchPending={isSearchPending}
searchInputRef={hasSearchableFiles ? searchInputRef : undefined}
onOpenSearch={hasSearchableFiles ? openSearch : undefined}
onSearchChange={hasSearchableFiles ? handleSearchInputChange : undefined}
onSearchClear={hasSearchableFiles ? clearSearch : undefined}
onSearchClose={hasSearchableFiles ? closeSearch : undefined}
searchGroups={hasSearchableFiles ? searchGroups : []}
searchMatches={hasSearchableFiles ? searchMatches : []}
activeSearchMatchId={hasSearchableFiles ? activeSearchMatchId : null}
onSelectSearchMatch={hasSearchableFiles ? handleSelectSearchMatch : undefined}
onStepSearchMatch={hasSearchableFiles ? stepSearchMatch : undefined}
repoRoot={prMetadata ? null : (activeWorktreePath ?? agentCwd ?? gitContext?.cwd ?? null)}
/>
<ResizeHandle {...fileTreeResize.handleProps} className="z-10" side="left" onCollapse={() => setIsFileTreeOpen(false)} />
</div>
)}
{/* Center dock area */}
<div className="flex-1 min-w-0 overflow-hidden relative">
<ConfirmDialog
isOpen={!!draftBanner}
onClose={dismissDraft}
onConfirm={handleRestoreDraft}
title="Draft Recovered"
message={draftBanner ? (() => {
const parts: string[] = [];
if (draftBanner.count > 0) parts.push(`${draftBanner.count} annotation${draftBanner.count !== 1 ? 's' : ''}`);
if (draftBanner.viewedCount > 0) parts.push(`${draftBanner.viewedCount} viewed file${draftBanner.viewedCount !== 1 ? 's' : ''}`);
return `Found ${parts.join(' and ')} from ${draftBanner.timeAgo}. Would you like to restore them?`;
})() : ''}
confirmText="Restore"
cancelText="Dismiss"
showCancel
/>
{files.length > 0 ? (
<DockviewReact
className={`h-full ${resolvedMode === 'light' ? 'dockview-theme-light' : 'dockview-theme-dark'}`}
components={reviewPanelComponents}
defaultTabComponent={ReviewDockTabRenderer}
onReady={handleDockReady}
disableFloatingGroups
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-3 max-w-md px-8">
<div className={`mx-auto w-12 h-12 rounded-full flex items-center justify-center ${diffError ? 'bg-destructive/10' : 'bg-muted/50'}`}>
{diffError ? (
<svg className="w-6 h-6 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
) : (
<svg className="w-6 h-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)}
</div>
<div>
{diffError ? (
<>
<h3 className="text-sm font-medium text-destructive">Failed to load diff</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-sm break-words line-clamp-3">{diffError}</p>
</>
) : (
<>
<h3 className="text-sm font-medium text-foreground">No changes</h3>
<p className="text-xs text-muted-foreground mt-1">
{activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`}
{activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."}
{activeDiffBase === 'unstaged' && "No unstaged changes. All changes are staged."}
{activeDiffBase === 'last-commit' && `No changes in the last commit${activeWorktreePath ? ' in this worktree' : ''}.`}
{activeDiffBase === 'jj-current' && "No changes in the current jj change."}
{activeDiffBase === 'jj-last' && "No changes in the last jj change."}
{activeDiffBase === 'workspace-current' && "No current changes in the workspace repositories."}
{activeDiffBase === 'workspace-staged' && "No staged changes in the workspace repositories."}
{activeDiffBase === 'workspace-unstaged' && "No unstaged changes in the workspace repositories."}
{activeDiffBase === 'workspace-last' && "No changes in the last change across workspace repositories."}
{activeDiffBase === 'jj-line' && `No changes in your line of work vs ${selectedBase || gitContext?.defaultBranch || '@-'}.`}
{activeDiffBase === 'jj-evolog' && `No changes since evolution ${selectedBase ? selectedBase.slice(0, 8) : 'previous'} — the change looks the same as before.`}
{activeDiffBase === 'jj-all' && "No files at the current jj change."}
{activeDiffBase === 'branch' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`}
{activeDiffBase === 'merge-base' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`}
{activeDiffBase === 'all' && `No tracked files${activeWorktreePath ? ' in this worktree' : ' in this repository'}.`}
</p>
</>
)}
</div>
{((reviewMode === 'workspace' ? workspaceDiffOptions : gitContext?.diffOptions)?.length ?? 0) > 1 && (
<p className="text-xs text-muted-foreground/60">
Try selecting a different view from the dropdown.
</p>
)}
</div>
</div>
)}
</div>
{/* Resize Handle + Sidebar */}
{reviewSidebar.isOpen && (
<div className="contents group/sidebar">
<ResizeHandle {...panelResize.handleProps} className="z-10" side="right" onCollapse={() => reviewSidebar.close()} />
<ReviewSidebar
isOpen
onClose={reviewSidebar.close}
activeTab={reviewSidebar.activeTab}
annotations={allAnnotations}
files={files}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
feedbackMarkdown={feedbackMarkdown}
width={panelResize.width}
editorAnnotations={editorAnnotations}
onDeleteEditorAnnotation={deleteEditorAnnotation}
prMetadata={prMetadata}
aiAvailable={aiAvailable}
aiMessages={aiMessages}
isAICreatingSession={aiIsCreatingSession}
isAIStreaming={aiIsStreaming}
onScrollToAILines={handleScrollToAILines}
activeFilePath={files[activeFileIndex]?.path}
scrollToQuestionId={scrollToQuestionId}
onAskGeneral={handleAskGeneral}
aiPermissionRequests={aiPermissionRequests}
onRespondToPermission={respondToAIPermission}
aiProviders={aiProviders}
aiConfig={aiConfig}
onAIConfigChange={handleAIConfigChange}
hasAISession={!!aiSessionId}
agentJobs={agentJobs.jobs}
agentCapabilities={agentJobs.capabilities}
onAgentLaunch={agentJobs.launchJob}
onAgentKillJob={agentJobs.killJob}
onAgentKillAll={agentJobs.killAll}
externalAnnotations={externalAnnotations}
onOpenJobDetail={handleOpenJobDetail}
onOpenPRPanel={handleOpenPRPanel}
/>
</div>
)}
</div>
{/* Export Modal */}
{showExportModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-xl w-full max-w-2xl flex flex-col max-h-[80vh] shadow-2xl">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-semibold text-sm">Export Review Feedback</h3>
<button
onClick={() => setShowExportModal(false)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="text-xs text-muted-foreground mb-2">
{allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''}
</div>
<pre className="export-code-block whitespace-pre-wrap">
{feedbackMarkdown}
</pre>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<button
onClick={async () => {
await navigator.clipboard.writeText(feedbackMarkdown);
}}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-colors"
>
Copy to Clipboard
</button>
</div>
</div>
</div>
)}
<div className="hidden" aria-hidden="true">
<Settings
taterMode={false}
onTaterModeChange={() => {}}
onIdentityChange={handleIdentityChange}
origin={origin}
mode="review"
aiProviders={aiProviders}
gitUser={gitUser}
externalOpen={openSettingsMenu}
onExternalClose={() => setOpenSettingsMenu(false)}
/>
</div>
{/* Worktree info dialog */}
{(gitContext?.cwd || agentCwd) && prMetadata && (
<ConfirmDialog
isOpen={showWorktreeDialog}
onClose={() => setShowWorktreeDialog(false)}
title="Local Worktree"
wide
message={
<div className="space-y-3">
<p>This PR is checked out locally so review agents have full file access.</p>
<div>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-semibold">Path</span>
<button
onClick={() => navigator.clipboard.writeText((agentCwd || gitContext?.cwd)!)}
className="mt-1 w-full text-left font-mono text-xs bg-muted/50 border border-border/50 rounded-md px-3 py-2 text-foreground hover:bg-muted transition-colors cursor-pointer break-all"
title="Click to copy"
>
{agentCwd || gitContext?.cwd}
</button>
</div>
<p className="text-xs text-muted-foreground/60">Automatically removed when this review session ends.</p>
</div>
}
variant="info"
/>
)}
{/* No annotations dialog */}
<ConfirmDialog
isOpen={showNoAnnotationsDialog}
onClose={() => setShowNoAnnotationsDialog(false)}
title="No Annotations"
message="You haven't made any annotations yet. There's nothing to copy."
variant="info"
/>
{/* Approve with annotations warning */}
<ConfirmDialog
isOpen={showApproveWarning}
onClose={() => setShowApproveWarning(false)}
onConfirm={() => {
setShowApproveWarning(false);
handleApprove();
}}
title="Annotations Won't Be Sent"
message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you approve.</>}
subMessage="To send your feedback, use Send Feedback instead."
confirmText="Approve Anyway"
cancelText="Cancel"
variant="warning"
showCancel
/>
<ConfirmDialog
isOpen={showExitWarning}
onClose={() => setShowExitWarning(false)}
onConfirm={() => {
setShowExitWarning(false);
handleExit();
}}
title="Annotations Won't Be Sent"
message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you close.</>}
subMessage="To send your feedback, use Send Feedback instead."
confirmText="Close Anyway"
cancelText="Cancel"
variant="warning"
showCancel
/>
{/* 0.20.0 look-and-feel / release announcement. Shared with the plan
editor via a host-scoped cookie, so it shows once across both apps.
Takes precedence over the diff-type setup so the two never stack. */}
<LookAndFeelAnnouncementDialog
isOpen={showLookAndFeel}
gridEnabled={gridEnabled}
onToggleGrid={(v) => configStore.set('gridEnabled', v)}
onDismiss={dismissLookAndFeel}
/>
{/* Diff type setup dialog — first-run only */}
{showDiffTypeSetup && !showLookAndFeel && (
<DiffTypeSetupDialog
onComplete={(selected) => {
setShowDiffTypeSetup(false);
if (selected !== diffType) handleDiffSwitch(selected);
}}
/>
)}
{/* Completion overlay - shown after approve/feedback/exit */}
<CompletionOverlay
submitted={submitted}
title={
submitted === 'approved' ? 'Changes Approved'
: submitted === 'exited' ? 'Session Closed'
: 'Feedback Sent'
}
subtitle={
submitted === 'exited'
? 'Review session closed without feedback.'
: platformMode
? submitted === 'approved'
? `Your approval was submitted to ${platformLabel}.`
: `Your feedback was submitted to ${platformLabel}.`
: submitted === 'approved'
? `${getAgentName(origin)} will proceed with the changes.`
: `${getAgentName(origin)} will address your review feedback.`
}
agentLabel={getAgentName(origin)}
/>
{/* GitHub general comment dialog */}
<ReviewSubmissionDialog
isOpen={!!platformCommentDialog}
action={platformCommentDialog?.action ?? 'comment'}
submission={platformCommentDialog?.plan ?? { targets: [], orphans: [] }}
generalComment={platformGeneralComment}
onGeneralCommentChange={setPlatformGeneralComment}
platformOpenPR={platformOpenPR}
onPlatformOpenPRChange={(checked) => {
setPlatformOpenPR(checked);
storage.setItem('plannotator-platform-open-pr', String(checked));
}}
onConfirm={() => {
if (!platformCommentDialog) return;
handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment);
}}
onCancel={() => setPlatformCommentDialog(null)}
isSubmitting={isPlatformActioning}
mrLabel={mrLabel}
platformLabel={platformLabel}
/>
</div>
{/* Tour dialog overlay */}
<TourDialog jobId={tourDialogJobId} onClose={() => setTourDialogJobId(null)} />
{/* Dev-only: open a fully-formed demo tour without running the agent.
Stripped from production builds via import.meta.env.DEV. */}
{import.meta.env.DEV && (
<button
onClick={() => setTourDialogJobId(tourDialogJobId === DEMO_TOUR_ID ? null : DEMO_TOUR_ID)}
title="Open the demo tour (dev only). Cmd+Shift+T also works."
className="fixed bottom-3 right-3 z-[60] px-2.5 py-1 rounded-md bg-foreground/80 text-background text-[10px] font-mono uppercase tracking-wider shadow-lg hover:bg-foreground transition-colors"
>
{tourDialogJobId === DEMO_TOUR_ID ? 'Close tour' : 'Demo tour'}
</button>
)}
<Toaster
position="bottom-center"
toastOptions={{
style: {
'--normal-bg': 'var(--card)',
'--normal-border': 'var(--border)',
'--normal-text': 'var(--foreground)',
} as React.CSSProperties,
}}
/>
</JobLogsProvider>
</ReviewStateProvider>
</TooltipProvider>
</ThemeProvider>
);
};
export default ReviewApp;