mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
0fd9826c2c
* feat(review): all-files diff view with lazy mounting Renders all diffs in a single scrollable pane using individual FileDiff components with sticky file headers, collapse toggles, and viewport-based lazy mounting via IntersectionObserver. For provenance purposes, this commit was AI assisted. * feat(review): convert all-files view to dock panel + fixes - Register AllFilesDiffView as a proper dock panel so it coexists with single-file tabs, AI chat, PR panels - Remove viewMode toggle in favor of dock panel lifecycle - Fix annotation toolbar positioning (mouse tracking) - Fix annotation PR/scope filtering for stacked PRs - Fix stale patch on file switch (deferred toolbar open) - Fix conventional comments boolean comparison - Fix SuggestionModal props to match DiffViewer pattern - Wire reviewBase for correct hunk expansion context - Fix pre-existing CSS bug on .has-annotations background - Add Shift+V/A keyboard shortcuts for viewed/stage toggle - Add [/] keyboard shortcuts for file-to-file scrolling - Add keyboard shortcuts to settings panel - Restore scroll-based file tree highlight via dock panel state For provenance purposes, this commit was AI assisted. * feat(review): sidebar toggles, keyboard shortcuts, click-drag selection - Move sidebar tab buttons (annotations/AI/agents) to header bar - Generalize useSidebar hook for both plan and review editors - Remove internal tab state from ReviewSidebar, receive from parent - Add file tree toggle button at far-left of header - Add click-and-drag line selection in diff content area - Change keyboard shortcuts from Shift+V/A to bare v/a/c v = toggle viewed (+ collapse in all-files mode) a = toggle git add/stage c = collapse/expand file (all-files mode) - Widen DiffOptionsPopover to prevent word-alt line break - Fix Shift+V/A targeting wrong file in all-files mode - Default to all-files panel on initial load - Hide dock headers when all-files panel is the only panel For provenance purposes, this commit was AI assisted. * feat(review): click-and-drag line selection in diff content area Extract shared selection helpers to utils/diffSelection.ts and add mouseup handlers to both DiffViewer and AllFilesDiffView so users can click and drag across code lines to open the annotation toolbar — not just the gutter. For provenance purposes, this commit was AI assisted. * fix(review): address AI review findings - Gate all-files keyboard shortcuts on isActive to prevent double-toggle when a single-file tab is focused - Fix getDiffSelection to iterate all diffs-container elements so click-drag selection works on files beyond the first - Clear needsInitialDiffPanel after opening all-files panel to prevent forced reactivation on diff switches - Clear activeFilePath and collapsedFiles when files change to prevent stale state across PR/diff switches - Add file.patch to LazyFileDiff fetch deps so file contents refetch when the diff context changes - Add toggleCollapse to keyboard effect dependency array For provenance purposes, this commit was AI assisted. * feat(review): add z to undo collapse with scroll-back Press z to reopen the last collapsed file and scroll back to it. Maintains a history stack so repeated z walks back through collapses. Skips already-expanded entries. History clears on diff/PR switch. For provenance purposes, this commit was AI assisted. * fix(review): review findings + sidebar toggles + folder icon - Fix stale context memo: add isAllFilesActive + handleAddAnnotationForFile to useMemo deps - Fix v handler: clear activeFilePath after collapse, only collapse when marking viewed ON (not when toggling OFF) - Fix click-drag copy regression: only trigger annotation toolbar for multi-line selections, preserve single-line/word selection for Cmd+C - Fix LazyFileDiff: clear stale file contents before refetch - Clean up App.tsx v/a handler: early return when isAllFilesActive - Add Cmd+B (file tree) and Cmd+. (sidebar) keyboard shortcuts - Add toggle menu items with kbd badges to Options dropdown - Replace file tree icon with simple folder - Add z (undo collapse) to keyboard shortcuts panel For provenance purposes, this commit was AI assisted. * chore: remove dead code from iterations - Remove unused FileTreeIcon import and component file - Remove unused AnnotationsIcon component from ReviewHeaderMenu - Remove unused onClose prop and close button from FileTree For provenance purposes, this commit was AI assisted. * fix(review): gate v/a on diff panel, keep annotation selection in all-files - Track isDiffPanelActive so v/a shortcuts only fire when viewing a single-file diff, not PR Comments/Summary/Agent panels - Annotation selection stays in all-files mode instead of switching to a single-file tab — lets the scroll-to-annotation effect handle it For provenance purposes, this commit was AI assisted.
2415 lines
105 KiB
TypeScript
2415 lines
105 KiB
TypeScript
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 { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
|
|
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-provider';
|
|
import { configStore, useConfigValue } from '@plannotator/ui/config';
|
|
import { loadDiffFont } from '@plannotator/ui/utils/diffFonts';
|
|
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
|
|
import { getAIProviderSettings, saveAIProviderSettings, getPreferredModel } from '@plannotator/ui/utils/aiProvider';
|
|
import { AISetupDialog } from '@plannotator/ui/components/AISetupDialog';
|
|
import { needsAISetup } from '@plannotator/ui/utils/aiSetup';
|
|
import { DiffTypeSetupDialog } from '@plannotator/ui/components/DiffTypeSetupDialog';
|
|
import { needsDiffTypeSetup } from '@plannotator/ui/utils/diffTypeSetup';
|
|
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 { 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 { 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 './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 { usePRSession, type PRSessionUpdate } from './hooks/usePRSession';
|
|
import { useAnnotationFactory } from './hooks/useAnnotationFactory';
|
|
import { DEMO_DIFF } from './demoData';
|
|
import { exportReviewFeedback } from './utils/exportFeedback';
|
|
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_ALL_FILES_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-provider';
|
|
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;
|
|
sharingEnabled?: boolean;
|
|
prStackInfo?: PRStackInfo | null;
|
|
prDiffScope?: PRDiffScope;
|
|
prDiffScopeOptions?: PRDiffScopeOption[];
|
|
}
|
|
|
|
// Simple diff parser to extract files from unified diff
|
|
function parseDiffToFiles(rawPatch: string): DiffFile[] {
|
|
const files: DiffFile[] = [];
|
|
const fileChunks = rawPatch.split(/^diff --git /m).filter(Boolean);
|
|
|
|
for (const chunk of fileChunks) {
|
|
const lines = chunk.split('\n');
|
|
const headerMatch = lines[0]?.match(/a\/(.+) b\/(.+)/);
|
|
if (!headerMatch) continue;
|
|
|
|
const oldPath = headerMatch[1];
|
|
const newPath = headerMatch[2];
|
|
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
|
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
|
}
|
|
|
|
files.push({
|
|
path: newPath,
|
|
oldPath: oldPath !== newPath ? oldPath : undefined,
|
|
patch: 'diff --git ' + chunk,
|
|
additions,
|
|
deletions,
|
|
});
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
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);
|
|
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 diffFontFamily = useConfigValue('diffFontFamily');
|
|
const diffFontSize = useConfigValue('diffFontSize');
|
|
|
|
// 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');
|
|
}
|
|
}, [diffFontFamily, diffFontSize]);
|
|
|
|
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 [diffType, setDiffType] = useState<string>('uncommitted');
|
|
const [gitContext, setGitContext] = useState<GitContext | 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 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);
|
|
|
|
// 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;
|
|
|
|
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) => {
|
|
openDiffFile(match.filePath);
|
|
}, [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 ||
|
|
!!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 [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 [showAISetup, setShowAISetup] = useState(false);
|
|
const [aiCheckComplete, setAiCheckComplete] = useState(false);
|
|
const [showDiffTypeSetup, setShowDiffTypeSetup] = useState(false);
|
|
const [diffTypeSetupPending, setDiffTypeSetupPending] = useState(false);
|
|
const aiChat = useAIChat({
|
|
patch: diffData?.rawPatch ?? '',
|
|
providerId: aiConfig.providerId,
|
|
model: aiConfig.model,
|
|
reasoningEffort: aiConfig.reasoningEffort,
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
setAiCheckComplete(true);
|
|
})
|
|
.catch(() => { setAiCheckComplete(true); });
|
|
}, []);
|
|
|
|
const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null }) => {
|
|
setAiConfig(prev => {
|
|
const next = { ...prev, ...config };
|
|
// If provider changed, load that provider's preferred model
|
|
if (config.providerId !== undefined && config.providerId !== prev.providerId) {
|
|
next.model = config.providerId ? getPreferredModel(config.providerId) : null;
|
|
}
|
|
// Persist provider selection
|
|
const saved = getAIProviderSettings();
|
|
saveAIProviderSettings({ ...saved, providerId: next.providerId });
|
|
return next;
|
|
});
|
|
aiChat.resetSession();
|
|
}, [aiChat]);
|
|
|
|
const handleAskAI = useCallback((question: string) => {
|
|
if (!pendingSelection || !files[activeFileIndex]) 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(files[activeFileIndex].patch, lineStart, lineEnd, side);
|
|
|
|
aiChat.ask({
|
|
prompt: question,
|
|
filePath: files[activeFileIndex].path,
|
|
lineStart,
|
|
lineEnd,
|
|
side,
|
|
selectedCode: selectedCode || undefined,
|
|
});
|
|
}, [pendingSelection, files, activeFileIndex, aiChat]);
|
|
|
|
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 (for toolbar history)
|
|
const aiHistoryForSelection = useMemo(() => {
|
|
if (!pendingSelection || !files[activeFileIndex]) return [];
|
|
const filePath = files[activeFileIndex].path;
|
|
const selStart = Math.min(pendingSelection.start, pendingSelection.end);
|
|
const selEnd = Math.max(pendingSelection.start, pendingSelection.end);
|
|
const side = pendingSelection.side === 'additions' ? 'new' : 'old';
|
|
return aiChat.messages.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, files, activeFileIndex, aiChat.messages]);
|
|
|
|
// 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) => {
|
|
aiChat.ask({ prompt: question });
|
|
}, [aiChat.ask]);
|
|
|
|
// Resizable panels
|
|
const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' });
|
|
const fileTreeResize = useResizablePanel({
|
|
storageKey: 'plannotator-filetree-width',
|
|
defaultWidth: 256, minWidth: 160, maxWidth: 400, side: 'left',
|
|
});
|
|
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); setIsDiffPanelActive(false); return; }
|
|
setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_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_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;
|
|
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]);
|
|
|
|
// Open the all-files panel on first load.
|
|
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+Shift+C to copy diff
|
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
|
|
e.preventDefault();
|
|
handleCopyDiff();
|
|
}
|
|
// 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;
|
|
diffType?: string;
|
|
base?: string;
|
|
gitContext?: GitContext;
|
|
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;
|
|
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,
|
|
sharingEnabled: data.sharingEnabled,
|
|
});
|
|
setFiles(apiFiles);
|
|
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 || 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);
|
|
// Mark diff type setup as pending on first run (local mode only)
|
|
if (data.diffType && !data.prMetadata && data.gitContext?.vcsType !== 'p4' && 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);
|
|
})
|
|
.finally(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
// Show diff type setup dialog only after AI setup dialog is dismissed (avoid stacking)
|
|
useEffect(() => {
|
|
if (diffTypeSetupPending && aiCheckComplete && !showAISetup) {
|
|
setDiffTypeSetupPending(false);
|
|
setShowDiffTypeSetup(true);
|
|
}
|
|
}, [diffTypeSetupPending, aiCheckComplete, showAISetup]);
|
|
|
|
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]);
|
|
|
|
// 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]);
|
|
|
|
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);
|
|
if (selectedAnnotationId === id) setSelectedAnnotationId(null);
|
|
return;
|
|
}
|
|
setAnnotations(prev => prev.filter(a => a.id !== id));
|
|
if (selectedAnnotationId === id) {
|
|
setSelectedAnnotationId(null);
|
|
}
|
|
}, [selectedAnnotationId, 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 canStageFiles = canStageRaw && !prMetadata;
|
|
|
|
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;
|
|
}) {
|
|
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);
|
|
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;
|
|
error?: string;
|
|
};
|
|
|
|
const nextFiles = parseDiffToFiles(data.rawPatch);
|
|
|
|
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);
|
|
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);
|
|
}
|
|
} 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.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,
|
|
};
|
|
});
|
|
}
|
|
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]);
|
|
|
|
// 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') {
|
|
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;
|
|
await fetchDiffSwitch(fullDiffType);
|
|
}, [diffType, activeWorktreePath, fetchDiffSwitch]);
|
|
|
|
// 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) return;
|
|
if (!hideWhitespaceInitialized.current) {
|
|
hideWhitespaceInitialized.current = true;
|
|
return;
|
|
}
|
|
fetchDiffSwitch(diffType, selectedBase, { preserveFile: true });
|
|
}, [diffHideWhitespace, origin]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Select annotation - switches file if needed and scrolls to it
|
|
const handleSelectAnnotation = useCallback((id: string | null) => {
|
|
if (!id) {
|
|
setSelectedAnnotationId(null);
|
|
return;
|
|
}
|
|
|
|
// Find the annotation
|
|
const annotation = allAnnotations.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 (!isAllFilesActive) {
|
|
const fileIndex = files.findIndex(f => f.path === annotation.filePath);
|
|
if (fileIndex !== -1) {
|
|
handleFileSwitch(fileIndex);
|
|
}
|
|
}
|
|
|
|
setSelectedAnnotationId(id);
|
|
}, [allAnnotations, files, isAllFilesActive, 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,
|
|
focusedFileIndex: activeFileIndex,
|
|
focusedFilePath: files[activeFileIndex]?.path ?? null,
|
|
diffStyle,
|
|
diffOverflow,
|
|
diffIndicators,
|
|
lineDiffType: diffLineDiffType,
|
|
disableLineNumbers: !diffShowLineNumbers,
|
|
disableBackground: !diffShowBackground,
|
|
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')
|
|
? committedBase ?? undefined
|
|
: undefined,
|
|
activeDiffBase,
|
|
feedbackDiffContext,
|
|
prReviewScope: prReviewScopeLabel,
|
|
prDiffScope,
|
|
allAnnotations,
|
|
externalAnnotations,
|
|
selectedAnnotationId,
|
|
pendingSelection,
|
|
onLineSelection: handleLineSelection,
|
|
onAddAnnotation: handleAddAnnotation,
|
|
onAddAnnotationForFile: handleAddAnnotationForFile,
|
|
onAddFileComment: handleAddFileComment,
|
|
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,
|
|
aiAvailable,
|
|
aiMessages: aiChat.messages,
|
|
onAskAI: handleAskAI,
|
|
isAILoading: aiChat.isCreatingSession || aiChat.isStreaming,
|
|
onViewAIResponse: handleViewAIResponse,
|
|
onClickAIMarker: handleClickAIMarker,
|
|
aiHistoryForSelection,
|
|
agentJobs: agentJobs.jobs,
|
|
prMetadata,
|
|
prContext,
|
|
isPRContextLoading,
|
|
prContextError,
|
|
fetchPRContext,
|
|
platformUser,
|
|
openDiffFile,
|
|
onAllFilesVisibleFileChange: setAllFilesVisibleFile,
|
|
isAllFilesActive,
|
|
openTourPanel: handleOpenTour,
|
|
}), [
|
|
files, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
|
|
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
|
|
diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope,
|
|
allAnnotations, externalAnnotations,
|
|
selectedAnnotationId, pendingSelection, handleLineSelection,
|
|
handleAddAnnotation, handleAddFileComment, handleEditAnnotation,
|
|
handleSelectAnnotation, handleDeleteAnnotation, viewedFiles,
|
|
handleToggleViewed, stagedFiles, stagingFile, stageFile,
|
|
canStageFiles, stageError, isSearchPending, debouncedSearchQuery,
|
|
activeFileSearchMatches, activeSearchMatchId, activeSearchMatch,
|
|
aiAvailable, aiChat.messages, aiChat.isCreatingSession, aiChat.isStreaming,
|
|
handleAskAI, handleViewAIResponse, handleClickAIMarker,
|
|
aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext,
|
|
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
|
|
handleOpenTour, isAllFilesActive, handleAddAnnotationForFile,
|
|
]);
|
|
|
|
// 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'}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{prMetadata ? (
|
|
<div className="min-w-0 flex items-center gap-2 md:gap-3">
|
|
{(gitContext || agentCwd) && (
|
|
<button
|
|
onClick={() => setShowWorktreeDialog(true)}
|
|
className="text-[10px] font-medium text-primary/80 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-colors cursor-pointer"
|
|
>
|
|
worktree
|
|
</button>
|
|
)}
|
|
<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>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
<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" />
|
|
{aiChat.messages.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}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<div className={`flex-1 flex overflow-hidden ${isResizing ? 'select-none' : ''}`}>
|
|
{shouldShowFileTree && isFileTreeOpen && (
|
|
<>
|
|
<FileTree
|
|
files={files}
|
|
activeFileIndex={activeFileIndex}
|
|
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={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}
|
|
onSelectBase={prMetadata ? undefined : handleBaseSelect}
|
|
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}
|
|
/>
|
|
<ResizeHandle {...fileTreeResize.handleProps} side="left" />
|
|
</>
|
|
)}
|
|
|
|
{/* 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 === '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>
|
|
{gitContext?.diffOptions && gitContext.diffOptions.length > 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 && (
|
|
<>
|
|
<ResizeHandle {...panelResize.handleProps} side="right" />
|
|
<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={aiChat.messages}
|
|
isAICreatingSession={aiChat.isCreatingSession}
|
|
isAIStreaming={aiChat.isStreaming}
|
|
onScrollToAILines={handleScrollToAILines}
|
|
activeFilePath={files[activeFileIndex]?.path}
|
|
scrollToQuestionId={scrollToQuestionId}
|
|
onAskGeneral={handleAskGeneral}
|
|
aiPermissionRequests={aiChat.permissionRequests}
|
|
onRespondToPermission={aiChat.respondToPermission}
|
|
aiProviders={aiProviders}
|
|
aiConfig={aiConfig}
|
|
onAIConfigChange={handleAIConfigChange}
|
|
hasAISession={!!aiChat.sessionId}
|
|
agentJobs={agentJobs.jobs}
|
|
agentCapabilities={agentJobs.capabilities}
|
|
onAgentLaunch={agentJobs.launchJob}
|
|
onAgentKillJob={agentJobs.killJob}
|
|
onAgentKillAll={agentJobs.killAll}
|
|
externalAnnotations={externalAnnotations}
|
|
onOpenJobDetail={handleOpenJobDetail}
|
|
onOpenPRPanel={handleOpenPRPanel}
|
|
/>
|
|
</>
|
|
)}
|
|
</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
|
|
/>
|
|
|
|
{/* AI setup dialog — first-run only */}
|
|
<AISetupDialog
|
|
isOpen={showAISetup}
|
|
providers={aiProviders}
|
|
onComplete={(providerId) => {
|
|
setShowAISetup(false);
|
|
handleAIConfigChange({ providerId });
|
|
}}
|
|
/>
|
|
|
|
{/* Diff type setup dialog — first-run only */}
|
|
{showDiffTypeSetup && (
|
|
<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)}
|
|
/>
|
|
|
|
{/* Update notification */}
|
|
<UpdateBanner origin={origin} isWSL={isWSL} />
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
</JobLogsProvider>
|
|
</ReviewStateProvider>
|
|
</TooltipProvider>
|
|
</ThemeProvider>
|
|
);
|
|
};
|
|
|
|
export default ReviewApp;
|