From 0f2b82445a5e66b1edbf55357ce15eaee986cfdb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 18 May 2026 17:25:14 -0700 Subject: [PATCH 01/32] Copy review-editor and editor as new embeddable packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/plannotator-code-review — copy of packages/review-editor packages/plannotator-plan-review — copy of packages/editor Unmodified copies to start. These will be refactored to strip standalone providers (ThemeProvider, TooltipProvider, Toaster) and accept session-scoped API context from the frontend shell. The original packages remain untouched for the legacy single-file HTML flow. --- bun.lock | 37 + packages/plannotator-code-review/App.tsx | 2504 +++++++++++++++++ .../components/AIConfigBar.tsx | 275 ++ .../components/AITab.tsx | 398 +++ .../components/AgentReviewActions.tsx | 75 + .../components/AllFilesDiffView.tsx | 552 ++++ .../components/AnnotationToolbar.tsx | 274 ++ .../components/AskAIInput.tsx | 138 + .../components/BaseBranchPicker.tsx | 360 +++ .../components/ConventionalLabelPicker.tsx | 161 ++ .../components/CopyButton.tsx | 69 + .../components/CountBadge.tsx | 13 + .../components/DiffHunkPreview.tsx | 150 + .../components/DiffOptionsPopover.tsx | 168 ++ .../components/DiffTypePicker.tsx | 122 + .../components/DiffViewer.tsx | 652 +++++ .../components/EvoLogPicker.tsx | 162 ++ .../components/FileHeader.tsx | 209 ++ .../components/FileTree.tsx | 628 +++++ .../components/FileTreeNode.tsx | 189 ++ .../components/HighlightedCode.tsx | 19 + .../components/InlineAIMarker.tsx | 56 + .../components/InlineAnnotation.tsx | 83 + .../components/LazyFileDiff.tsx | 118 + .../components/LiveLogViewer.tsx | 95 + .../components/PRChecksTab.tsx | 173 ++ .../components/PRCommentsTab.tsx | 573 ++++ .../components/PRSelector.tsx | 150 + .../components/PRSummaryTab.tsx | 205 ++ .../components/PRSwitchOverlay.tsx | 12 + .../components/PermissionCard.tsx | 128 + .../components/ReviewHeaderMenu.tsx | 214 ++ .../components/ReviewSidebar.tsx | 447 +++ .../components/ReviewSubmissionDialog.tsx | 379 +++ .../components/ScrollFade.tsx | 54 + .../components/SparklesIcon.tsx | 45 + .../components/StackedPRLabel.tsx | 349 +++ .../components/SuggestionBlock.tsx | 27 + .../components/SuggestionDiff.tsx | 26 + .../components/SuggestionModal.tsx | 129 + .../components/ToolbarHost.tsx | 154 + .../components/WorktreePicker.tsx | 191 ++ .../components/tour/QAChecklist.tsx | 89 + .../components/tour/TourDialog.tsx | 561 ++++ .../components/tour/TourStopCard.tsx | 291 ++ packages/plannotator-code-review/demoData.ts | 407 +++ packages/plannotator-code-review/demoTour.ts | 198 ++ .../dock/JobLogsContext.tsx | 23 + .../dock/ReviewDockTabRenderer.tsx | 41 + .../dock/ReviewStateContext.tsx | 130 + .../dock/panels/ReviewAgentJobDetailPanel.tsx | 476 ++++ .../dock/panels/ReviewAllFilesDiffPanel.tsx | 49 + .../dock/panels/ReviewCodeNavPanel.tsx | 266 ++ .../dock/panels/ReviewDiffPanel.tsx | 120 + .../dock/panels/ReviewPRChecksPanel.tsx | 47 + .../dock/panels/ReviewPRCommentsPanel.tsx | 46 + .../dock/panels/ReviewPRSummaryPanel.tsx | 52 + .../dock/reviewPanelComponents.ts | 22 + .../dock/reviewPanelTypes.ts | 43 + .../hooks/tour/useTourData.ts | 106 + .../hooks/useAIChat.ts | 326 +++ .../hooks/useAnnotationFactory.ts | 24 + .../hooks/useAnnotationToolbar.ts | 370 +++ .../hooks/useCodeNav.ts | 49 + .../hooks/useCodeNavPreview.ts | 82 + .../hooks/useGitAdd.ts | 79 + .../hooks/usePRContext.ts | 51 + .../hooks/usePRSession.ts | 43 + .../hooks/usePRStack.ts | 75 + .../hooks/usePierreTheme.ts | 286 ++ .../hooks/useReviewSearch.ts | 154 + .../hooks/useTabIndent.ts | 21 + packages/plannotator-code-review/index.css | 1291 +++++++++ packages/plannotator-code-review/package.json | 27 + packages/plannotator-code-review/shortcuts.ts | 113 + packages/plannotator-code-review/types.ts | 7 + .../utils/buildCodeNavRequest.ts | 17 + .../utils/buildFileTree.ts | 167 ++ .../utils/detectLanguage.ts | 14 + .../utils/diffSelection.ts | 40 + .../utils/exportFeedback.test.ts | 299 ++ .../utils/exportFeedback.ts | 222 ++ .../utils/formatLineRange.ts | 17 + .../utils/formatRelativeTime.ts | 21 + .../utils/generateId.ts | 2 + .../utils/patchParser.ts | 58 + .../utils/renderChatMarkdown.tsx | 28 + .../utils/renderInlineMarkdown.test.tsx | 45 + .../utils/renderInlineMarkdown.tsx | 85 + .../utils/reviewSearch.test.ts | 95 + .../utils/reviewSearch.ts | 206 ++ .../utils/reviewSearchHighlight.ts | 244 ++ packages/plannotator-plan-review/App.tsx | 2243 +++++++++++++++ .../components/AppHeader.tsx | 331 +++ packages/plannotator-plan-review/demoPlan.ts | 386 +++ .../demoPlanDiffDemo.ts | 299 ++ .../hooks/useCheckboxOverrides.ts | 118 + packages/plannotator-plan-review/index.css | 202 ++ packages/plannotator-plan-review/package.json | 18 + packages/plannotator-plan-review/shortcuts.ts | 136 + .../plannotator-plan-review/wideMode.test.ts | 87 + packages/plannotator-plan-review/wideMode.ts | 48 + 102 files changed, 22156 insertions(+) create mode 100644 packages/plannotator-code-review/App.tsx create mode 100644 packages/plannotator-code-review/components/AIConfigBar.tsx create mode 100644 packages/plannotator-code-review/components/AITab.tsx create mode 100644 packages/plannotator-code-review/components/AgentReviewActions.tsx create mode 100644 packages/plannotator-code-review/components/AllFilesDiffView.tsx create mode 100644 packages/plannotator-code-review/components/AnnotationToolbar.tsx create mode 100644 packages/plannotator-code-review/components/AskAIInput.tsx create mode 100644 packages/plannotator-code-review/components/BaseBranchPicker.tsx create mode 100644 packages/plannotator-code-review/components/ConventionalLabelPicker.tsx create mode 100644 packages/plannotator-code-review/components/CopyButton.tsx create mode 100644 packages/plannotator-code-review/components/CountBadge.tsx create mode 100644 packages/plannotator-code-review/components/DiffHunkPreview.tsx create mode 100644 packages/plannotator-code-review/components/DiffOptionsPopover.tsx create mode 100644 packages/plannotator-code-review/components/DiffTypePicker.tsx create mode 100644 packages/plannotator-code-review/components/DiffViewer.tsx create mode 100644 packages/plannotator-code-review/components/EvoLogPicker.tsx create mode 100644 packages/plannotator-code-review/components/FileHeader.tsx create mode 100644 packages/plannotator-code-review/components/FileTree.tsx create mode 100644 packages/plannotator-code-review/components/FileTreeNode.tsx create mode 100644 packages/plannotator-code-review/components/HighlightedCode.tsx create mode 100644 packages/plannotator-code-review/components/InlineAIMarker.tsx create mode 100644 packages/plannotator-code-review/components/InlineAnnotation.tsx create mode 100644 packages/plannotator-code-review/components/LazyFileDiff.tsx create mode 100644 packages/plannotator-code-review/components/LiveLogViewer.tsx create mode 100644 packages/plannotator-code-review/components/PRChecksTab.tsx create mode 100644 packages/plannotator-code-review/components/PRCommentsTab.tsx create mode 100644 packages/plannotator-code-review/components/PRSelector.tsx create mode 100644 packages/plannotator-code-review/components/PRSummaryTab.tsx create mode 100644 packages/plannotator-code-review/components/PRSwitchOverlay.tsx create mode 100644 packages/plannotator-code-review/components/PermissionCard.tsx create mode 100644 packages/plannotator-code-review/components/ReviewHeaderMenu.tsx create mode 100644 packages/plannotator-code-review/components/ReviewSidebar.tsx create mode 100644 packages/plannotator-code-review/components/ReviewSubmissionDialog.tsx create mode 100644 packages/plannotator-code-review/components/ScrollFade.tsx create mode 100644 packages/plannotator-code-review/components/SparklesIcon.tsx create mode 100644 packages/plannotator-code-review/components/StackedPRLabel.tsx create mode 100644 packages/plannotator-code-review/components/SuggestionBlock.tsx create mode 100644 packages/plannotator-code-review/components/SuggestionDiff.tsx create mode 100644 packages/plannotator-code-review/components/SuggestionModal.tsx create mode 100644 packages/plannotator-code-review/components/ToolbarHost.tsx create mode 100644 packages/plannotator-code-review/components/WorktreePicker.tsx create mode 100644 packages/plannotator-code-review/components/tour/QAChecklist.tsx create mode 100644 packages/plannotator-code-review/components/tour/TourDialog.tsx create mode 100644 packages/plannotator-code-review/components/tour/TourStopCard.tsx create mode 100644 packages/plannotator-code-review/demoData.ts create mode 100644 packages/plannotator-code-review/demoTour.ts create mode 100644 packages/plannotator-code-review/dock/JobLogsContext.tsx create mode 100644 packages/plannotator-code-review/dock/ReviewDockTabRenderer.tsx create mode 100644 packages/plannotator-code-review/dock/ReviewStateContext.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewAgentJobDetailPanel.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewAllFilesDiffPanel.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewCodeNavPanel.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewDiffPanel.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewPRChecksPanel.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewPRCommentsPanel.tsx create mode 100644 packages/plannotator-code-review/dock/panels/ReviewPRSummaryPanel.tsx create mode 100644 packages/plannotator-code-review/dock/reviewPanelComponents.ts create mode 100644 packages/plannotator-code-review/dock/reviewPanelTypes.ts create mode 100644 packages/plannotator-code-review/hooks/tour/useTourData.ts create mode 100644 packages/plannotator-code-review/hooks/useAIChat.ts create mode 100644 packages/plannotator-code-review/hooks/useAnnotationFactory.ts create mode 100644 packages/plannotator-code-review/hooks/useAnnotationToolbar.ts create mode 100644 packages/plannotator-code-review/hooks/useCodeNav.ts create mode 100644 packages/plannotator-code-review/hooks/useCodeNavPreview.ts create mode 100644 packages/plannotator-code-review/hooks/useGitAdd.ts create mode 100644 packages/plannotator-code-review/hooks/usePRContext.ts create mode 100644 packages/plannotator-code-review/hooks/usePRSession.ts create mode 100644 packages/plannotator-code-review/hooks/usePRStack.ts create mode 100644 packages/plannotator-code-review/hooks/usePierreTheme.ts create mode 100644 packages/plannotator-code-review/hooks/useReviewSearch.ts create mode 100644 packages/plannotator-code-review/hooks/useTabIndent.ts create mode 100644 packages/plannotator-code-review/index.css create mode 100644 packages/plannotator-code-review/package.json create mode 100644 packages/plannotator-code-review/shortcuts.ts create mode 100644 packages/plannotator-code-review/types.ts create mode 100644 packages/plannotator-code-review/utils/buildCodeNavRequest.ts create mode 100644 packages/plannotator-code-review/utils/buildFileTree.ts create mode 100644 packages/plannotator-code-review/utils/detectLanguage.ts create mode 100644 packages/plannotator-code-review/utils/diffSelection.ts create mode 100644 packages/plannotator-code-review/utils/exportFeedback.test.ts create mode 100644 packages/plannotator-code-review/utils/exportFeedback.ts create mode 100644 packages/plannotator-code-review/utils/formatLineRange.ts create mode 100644 packages/plannotator-code-review/utils/formatRelativeTime.ts create mode 100644 packages/plannotator-code-review/utils/generateId.ts create mode 100644 packages/plannotator-code-review/utils/patchParser.ts create mode 100644 packages/plannotator-code-review/utils/renderChatMarkdown.tsx create mode 100644 packages/plannotator-code-review/utils/renderInlineMarkdown.test.tsx create mode 100644 packages/plannotator-code-review/utils/renderInlineMarkdown.tsx create mode 100644 packages/plannotator-code-review/utils/reviewSearch.test.ts create mode 100644 packages/plannotator-code-review/utils/reviewSearch.ts create mode 100644 packages/plannotator-code-review/utils/reviewSearchHighlight.ts create mode 100644 packages/plannotator-plan-review/App.tsx create mode 100644 packages/plannotator-plan-review/components/AppHeader.tsx create mode 100644 packages/plannotator-plan-review/demoPlan.ts create mode 100644 packages/plannotator-plan-review/demoPlanDiffDemo.ts create mode 100644 packages/plannotator-plan-review/hooks/useCheckboxOverrides.ts create mode 100644 packages/plannotator-plan-review/index.css create mode 100644 packages/plannotator-plan-review/package.json create mode 100644 packages/plannotator-plan-review/shortcuts.ts create mode 100644 packages/plannotator-plan-review/wideMode.test.ts create mode 100644 packages/plannotator-plan-review/wideMode.ts diff --git a/bun.lock b/bun.lock index b760313d2..a58baf8cd 100644 --- a/bun.lock +++ b/bun.lock @@ -259,6 +259,39 @@ "tailwindcss": "^4.1.18", }, }, + "packages/plannotator-code-review": { + "name": "@plannotator/code-review", + "version": "0.0.1", + "dependencies": { + "@pierre/diffs": "^1.1.12", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-tooltip": "^1.2.8", + "highlight.js": "^11.11.1", + "motion": "^12.38.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + }, + }, + "packages/plannotator-plan-review": { + "name": "@plannotator/plan-review", + "version": "0.0.1", + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "sonner": "^2.0.7", + "tailwindcss": "^4.1.18", + }, + }, "packages/review-editor": { "name": "@plannotator/review-editor", "version": "0.0.1", @@ -849,6 +882,8 @@ "@plannotator/ai": ["@plannotator/ai@workspace:packages/ai"], + "@plannotator/code-review": ["@plannotator/code-review@workspace:packages/plannotator-code-review"], + "@plannotator/debug-frontend": ["@plannotator/debug-frontend@workspace:apps/debug-frontend"], "@plannotator/debug-tui": ["@plannotator/debug-tui@workspace:apps/debug-tui"], @@ -867,6 +902,8 @@ "@plannotator/pi-extension": ["@plannotator/pi-extension@workspace:apps/pi-extension"], + "@plannotator/plan-review": ["@plannotator/plan-review@workspace:packages/plannotator-plan-review"], + "@plannotator/portal": ["@plannotator/portal@workspace:apps/portal"], "@plannotator/review": ["@plannotator/review@workspace:apps/review"], diff --git a/packages/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx new file mode 100644 index 000000000..05c98b85f --- /dev/null +++ b/packages/plannotator-code-review/App.tsx @@ -0,0 +1,2504 @@ +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-types'; +import { configStore, useConfigValue } from '@plannotator/ui/config'; +import { loadDiffFont } from '@plannotator/ui/utils/diffFonts'; +import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; +import { getAIProviderSettings, 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 { toast, Toaster } from 'sonner'; +import { useCodeNav, type CodeNavRequest } from './hooks/useCodeNav'; +import { extractLinesFromPatch } from './utils/patchParser'; +import { isTypingTarget, useReviewSearch, type ReviewSearchMatch } from './hooks/useReviewSearch'; +import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; +import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; +import { useAgentJobs } from '@plannotator/ui/hooks/useAgentJobs'; +import { exportEditorAnnotations } from '@plannotator/ui/utils/parser'; +import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; +import { 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, + REVIEW_CODE_NAV_PANEL_ID, +} from './dock/reviewPanelTypes'; +import type { DiffFile } from './types'; +import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; +import type { PRMetadata } from '@plannotator/shared/pr-types'; +import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; +import { altKey } from '@plannotator/ui/utils/platform'; +import { TourDialog } from './components/tour/TourDialog'; +import { DEMO_TOUR_ID } from './demoTour'; + +declare const __APP_VERSION__: string; + +interface DiffData { + files: DiffFile[]; + rawPatch: string; + gitRef: string; + origin?: Origin; + diffType?: string; + gitContext?: GitContext; + 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(null); + const [files, setFiles] = useState([]); + const [activeFileIndex, setActiveFileIndex] = useState(0); + const [annotations, setAnnotations] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [isAllFilesActive, setIsAllFilesActive] = useState(false); + const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); + const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null); + const [pendingSelection, setPendingSelection] = useState(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'); + const diffTabSize = useConfigValue('diffTabSize'); + + // Load custom diff font and override --font-mono for surrounding review elements + useEffect(() => { + if (diffFontFamily) { + loadDiffFont(diffFontFamily); + document.documentElement.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`); + } else { + document.documentElement.style.removeProperty('--diff-font-override'); + } + if (diffFontSize) { + document.documentElement.style.setProperty('--diff-font-size-override', diffFontSize); + } else { + document.documentElement.style.removeProperty('--diff-font-size-override'); + } + document.documentElement.style.setProperty('--diffs-tab-size', String(diffTabSize)); + }, [diffFontFamily, diffFontSize, diffTabSize]); + + const reviewSidebar = useSidebar(true, 'annotations'); + const [isFileTreeOpen, setIsFileTreeOpen] = useState(true); + const [copyFeedback, setCopyFeedback] = useState(null); + const [copyRawDiffStatus, setCopyRawDiffStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [viewedFiles, setViewedFiles] = useState>(new Set()); + const [hideViewedFiles, setHideViewedFiles] = useState(false); + const [origin, setOrigin] = useState(null); + const [gitUser, setGitUser] = useState(); + const [isWSL, setIsWSL] = useState(false); + const [diffType, setDiffType] = useState('uncommitted'); + const [gitContext, setGitContext] = useState(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(null); + const [committedBase, setCommittedBase] = useState(null); + const [agentCwd, setAgentCwd] = useState(null); + const [isLoadingDiff, setIsLoadingDiff] = useState(false); + const [diffError, setDiffError] = useState(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(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(null); + const [platformUser, setPlatformUser] = useState(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 (HTTP mutations + daemon WebSocket events) + // 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({ enabled: !!origin }); + const agentJobs = useAgentJobs({ enabled: !!origin }); + + // Tour dialog state — opens as an overlay instead of a dock panel + const [tourDialogJobId, setTourDialogJobId] = useState(null); + + // Dockview center panel API for the review workspace. + const [dockApi, setDockApi] = useState(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 + live annotations, deduping draft-restored externals against + // live WebSocket versions. Prefer the live version when both exist (same source, + // type, and originalText). This avoids the timing issues of an effect-based + // cleanup — draft-restored externals persist until live events re-deliver 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; 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, + }); + + const codeNav = useCodeNav(); + + const handleCodeNavRequest = useCallback((request: CodeNavRequest) => { + if (!gitContext && !agentCwd) { + toast('Code navigation requires a local checkout', { + description: 'Re-run with --local for PR reviews', + duration: 4000, + }); + return; + } + codeNav.resolve(request); + if (!dockApi) return; + const existing = dockApi.getPanel(REVIEW_CODE_NAV_PANEL_ID); + if (existing) { + existing.api.setTitle(`References: ${request.symbol}`); + existing.api.setActive(); + } else { + const refPanel = isAllFilesActive + ? REVIEW_ALL_FILES_PANEL_ID + : REVIEW_DIFF_PANEL_ID; + dockApi.addPanel({ + id: REVIEW_CODE_NAV_PANEL_ID, + component: REVIEW_PANEL_TYPES.CODE_NAV, + title: `References: ${request.symbol}`, + position: { direction: 'below', referencePanel: refPanel }, + initialHeight: 250, + }); + } + }, [codeNav.resolve, dockApi, isAllFilesActive, gitContext, agentCwd]); + + // Check AI capabilities on mount + useEffect(() => { + fetch('/api/ai/capabilities') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.available) { + setAiAvailable(true); + const providers = data.providers ?? []; + setAiProviders(providers); + } + 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(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()); + 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+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 || data.gitContext.compareTarget?.fallback || null; + setSelectedBase(initial); + setCommittedBase(initial); + } + if (data.agentCwd) setAgentCwd(data.agentCwd); + if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); + if (data.repoInfo) setRepoInfo(data.repoInfo); + updatePRSession({ + ...(data.prMetadata && { prMetadata: data.prMetadata }), + ...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }), + ...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }), + ...(data.prDiffScope && { prDiffScope: data.prDiffScope }), + ...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }), + }); + if (data.platformUser) setPlatformUser(data.platformUser); + // Initialize viewed files from GitHub's state (set before draft restore so draft takes precedence) + if (data.viewedFiles && data.viewedFiles.length > 0) { + setViewedFiles(new Set(data.viewedFiles)); + } + if (data.error) setDiffError(data.error); + if (data.isWSL) setIsWSL(true); + // Mark diff type setup as pending on first run (local mode only) + if (data.diffType && !data.prMetadata && data.gitContext?.vcsType !== 'p4' && data.gitContext?.vcsType !== 'jj' && needsDiffTypeSetup()) { + setDiffTypeSetupPending(true); + } + }) + .catch(() => { + // Not in API mode - use demo content + const demoFiles = parseDiffToFiles(DEMO_DIFF); + setDiffData({ + files: demoFiles, + rawPatch: DEMO_DIFF, + gitRef: 'demo', + }); + setFiles(demoFiles); + }) + .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]); + + const handleAddFileCommentForFile = useCallback((filePath: string, text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + + const newAnnotation: CodeAnnotation = { + id: generateId(), + type: 'comment', + scope: 'file', + filePath, + lineStart: 1, + lineEnd: 1, + side: 'new', + text: trimmed, + createdAt: Date.now(), + author: identity, + }; + + setAnnotations(prev => [...prev, withPRContext(newAnnotation)]); + }, [identity, withPRContext]); + + // Edit annotation + const handleEditAnnotation = useCallback(( + id: string, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel | null, + decorations?: ConventionalDecoration[], + ) => { + const ann = allAnnotationsRef.current.find(a => a.id === id); + const updates: Partial = { + ...(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[]; agentCwd?: string | null; 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.agentCwd !== undefined) setAgentCwd(data.agentCwd); + 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 => { + 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, + compareTarget: data.gitContext!.compareTarget, + jjEvologs: data.gitContext!.jjEvologs, + // HEAD differs per worktree, so refresh the commit-baseline picker. + recentCommits: data.gitContext!.recentCommits, + }; + }); + } + setActiveFileIndex(0); + setPendingSelection(null); + resetStagedFiles(); + } + setDiffError(data.error || null); + return true; + } catch (err) { + console.error('Failed to switch diff:', err); + setDiffError(err instanceof Error ? err.message : 'Failed to switch diff'); + return false; + } finally { + setIsLoadingDiff(false); + } + }, [dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, files, activeFileIndex, openDiffFile]); + + // Switch the base branch the current diff compares against. + // Only triggers a refetch when the active mode actually uses a base. + // Optimistically updates the picker; reverts if the server-side switch + // fails so the chip doesn't lie about what the viewer is actually showing. + const handleBaseSelect = useCallback( + async (branch: string) => { + if (branch === selectedBase) return; + const previous = selectedBase; + setSelectedBase(branch); + if (activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') { + const ok = await fetchDiffSwitch(diffType, branch); + if (!ok) setSelectedBase(previous); + } + }, + [selectedBase, activeDiffBase, diffType, fetchDiffSwitch], + ); + + // Switch diff type (uncommitted, last-commit, branch) — composes worktree prefix if active + const handleDiffSwitch = useCallback(async (baseDiffType: string) => { + const fullDiffType = activeWorktreePath + ? `worktree:${activeWorktreePath}:${baseDiffType}` + : baseDiffType; + if (fullDiffType === diffType) return; + // For evolog, default to the second entry (previous state of @) so the + // server doesn't fall back to the jj bookmark/trunk revset. + // When leaving evolog, restore the base to the detected compare target + // so other base-dependent modes (jj-line) don't inherit a commit ID. + const enteringEvolog = + baseDiffType === 'jj-evolog' && gitContext?.jjEvologs && gitContext.jjEvologs.length >= 2; + const leavingEvolog = + !enteringEvolog && activeDiffBase === 'jj-evolog' && gitContext?.defaultBranch; + const baseOverride = enteringEvolog + ? gitContext!.jjEvologs![1].commitId + : leavingEvolog + ? gitContext!.defaultBranch + : undefined; + if (baseOverride) setSelectedBase(baseOverride); + await fetchDiffSwitch(fullDiffType, baseOverride); + }, [diffType, activeWorktreePath, fetchDiffSwitch, gitContext]); + + // Switch worktree context (or back to main repo). Preserves the current + // diff mode across the switch — if the reviewer was looking at "PR Diff" + // in the main repo, they should keep looking at "PR Diff" in the target + // worktree rather than being silently snapped back to "Uncommitted". + const handleWorktreeSwitch = useCallback(async (worktreePath: string | null) => { + if (worktreePath === activeWorktreePath) return; + const fullDiffType = worktreePath + ? `worktree:${worktreePath}:${activeDiffBase}` + : activeDiffBase; + await fetchDiffSwitch(fullDiffType); + }, [activeWorktreePath, activeDiffBase, fetchDiffSwitch]); + + // Re-fetch diff when hideWhitespace toggles so the server applies git diff -w. + // Preserves the active file since only whitespace hunks change. + const hideWhitespaceInitialized = useRef(false); + useEffect(() => { + if (!origin || !gitContext) 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(() => ({ + 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' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') + ? committedBase ?? undefined + : undefined, + activeDiffBase, + feedbackDiffContext, + prReviewScope: prReviewScopeLabel, + prDiffScope, + allAnnotations, + externalAnnotations, + selectedAnnotationId, + pendingSelection, + onLineSelection: handleLineSelection, + onAddAnnotation: handleAddAnnotation, + onAddAnnotationForFile: handleAddAnnotationForFile, + onAddFileComment: handleAddFileComment, + onAddFileCommentForFile: handleAddFileCommentForFile, + onEditAnnotation: handleEditAnnotation, + onSelectAnnotation: handleSelectAnnotation, + onDeleteAnnotation: handleDeleteAnnotation, + viewedFiles, + onToggleViewed: handleToggleViewed, + stagedFiles, + stagingFile, + onStage: stageFile, + canStageFiles, + stageError, + searchQuery: isSearchPending ? '' : debouncedSearchQuery, + isSearchPending, + debouncedSearchQuery, + activeFileSearchMatches, + activeSearchMatchId, + activeSearchMatch: activeSearchMatch?.filePath === files[activeFileIndex]?.path ? activeSearchMatch : null, + 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, + onCodeNavRequest: handleCodeNavRequest, + codeNavResult: codeNav.result, + codeNavIsLoading: codeNav.isLoading, + codeNavActiveSymbol: codeNav.activeSymbol, + }), [ + files, activeFileIndex, diffStyle, diffOverflow, diffIndicators, + diffLineDiffType, diffShowLineNumbers, diffShowBackground, + diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, + allAnnotations, externalAnnotations, + selectedAnnotationId, pendingSelection, handleLineSelection, + handleAddAnnotation, handleAddFileComment, handleAddFileCommentForFile, handleEditAnnotation, + handleSelectAnnotation, handleDeleteAnnotation, viewedFiles, + handleToggleViewed, stagedFiles, stagingFile, stageFile, + canStageFiles, stageError, isSearchPending, debouncedSearchQuery, + activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, + aiAvailable, aiChat.messages, aiChat.isCreatingSession, aiChat.isStreaming, + handleAskAI, handleViewAIResponse, handleClickAIMarker, + aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, + isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, + handleOpenTour, isAllFilesActive, handleAddAnnotationForFile, + handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol, + ]); + + // Separate context for high-frequency job logs — prevents re-rendering all panels on every live 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 => { + 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 ( + +
+
Loading diff...
+
+
+ ); + } + + return ( + + + + + {isSwitchingPRScope && } +
+ {/* Header */} +
+
+ {shouldShowFileTree && ( + <> + +
+ + )} + {prMetadata ? ( +
+ + + {displayRepo} + + + +
+ + + +
+
+ ) : repoInfo ? ( +
+ {repoInfo.branch && ( + + {repoInfo.branch} + + )} + + + {repoInfo.display} + +
+ ) : ( + Review + )} +
+ +
+ {/* Diff style toggle */} +
+ + +
+ + {origin ? ( + <> + {/* Destination dropdown (PR mode only) */} + {prMetadata && ( +
+ + {showDestinationMenu && ( + <> +
setShowDestinationMenu(false)} /> +
+ + +
+ + {altKey} + {altKey} + to toggle + +
+
+ + )} +
+ )} + + {/* GitHub error message */} + {platformActionError && ( +
+ {platformActionError} +
+ )} + + {/* Agent mode: Close/SendFeedback flip + Approve */} + {!platformMode ? ( + totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} + onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + /> + ) : ( + <> + {/* Platform mode: Close + Post Comments + Approve */} + totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning} + isLoading={isExiting} + /> + openPlatformDialog('comment')} + disabled={isSendingFeedback || isApproving || isPlatformActioning} + isLoading={isSendingFeedback || isPlatformActioning} + label="Post Comments" + shortLabel="Post" + loadingLabel="Posting..." + shortLoadingLabel="Posting..." + title="Post review to platform" + /> +
+ { + 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 && ( +
+
+
+ You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}. +
+ )} +
+ + )} + + ) : ( + + )} + +
+ + setOpenSettingsMenu(true)} + onOpenExport={() => setShowExportModal(true)} + onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} + onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} + isFileTreeOpen={isFileTreeOpen} + isSidebarOpen={reviewSidebar.isOpen} + appVersion={appVersion} + /> + +
+ + {/* Sidebar tab toggles */} + + {aiAvailable && ( + + )} + {agentJobs.capabilities?.available && ( + + )} +
+
+ + {/* Main content */} +
+ {shouldShowFileTree && isFileTreeOpen && ( + <> + 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 || gitContext?.compareTarget?.fallback} + onSelectBase={prMetadata ? undefined : handleBaseSelect} + compareTarget={gitContext?.compareTarget} + recentCommits={prMetadata ? undefined : gitContext?.recentCommits} + jjEvologs={prMetadata ? undefined : gitContext?.jjEvologs} + detectedEvoBase={prMetadata ? undefined : gitContext?.jjEvologs?.[1]?.commitId} + stagedFiles={stagedFiles} + onCopyRawDiff={handleCopyDiff} + canCopyRawDiff={!!diffData?.rawPatch} + copyRawDiffStatus={copyRawDiffStatus} + searchQuery={hasSearchableFiles ? searchQuery : ''} + isSearchOpen={hasSearchableFiles ? isSearchOpen : false} + isSearchPending={isSearchPending} + searchInputRef={hasSearchableFiles ? searchInputRef : undefined} + onOpenSearch={hasSearchableFiles ? openSearch : undefined} + onSearchChange={hasSearchableFiles ? handleSearchInputChange : undefined} + onSearchClear={hasSearchableFiles ? clearSearch : undefined} + onSearchClose={hasSearchableFiles ? closeSearch : undefined} + searchGroups={hasSearchableFiles ? searchGroups : []} + searchMatches={hasSearchableFiles ? searchMatches : []} + activeSearchMatchId={hasSearchableFiles ? activeSearchMatchId : null} + onSelectSearchMatch={hasSearchableFiles ? handleSelectSearchMatch : undefined} + onStepSearchMatch={hasSearchableFiles ? stepSearchMatch : undefined} + repoRoot={prMetadata ? null : (activeWorktreePath ?? agentCwd ?? gitContext?.cwd ?? null)} + /> + + + )} + + {/* Center dock area */} +
+ { + 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 ? ( + + ) : ( +
+
+
+ {diffError ? ( + + + + ) : ( + + + + )} +
+
+ {diffError ? ( + <> +

Failed to load diff

+

{diffError}

+ + ) : ( + <> +

No changes

+

+ {activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`} + {activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."} + {activeDiffBase === 'unstaged' && "No unstaged changes. All changes are staged."} + {activeDiffBase === 'last-commit' && `No changes in the last commit${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'jj-current' && "No changes in the current jj change."} + {activeDiffBase === 'jj-last' && "No changes in the last jj change."} + {activeDiffBase === 'jj-line' && `No changes in your line of work vs ${selectedBase || gitContext?.defaultBranch || '@-'}.`} + {activeDiffBase === 'jj-evolog' && `No changes since evolution ${selectedBase ? selectedBase.slice(0, 8) : 'previous'} — the change looks the same as before.`} + {activeDiffBase === 'jj-all' && "No files at the current jj change."} + {activeDiffBase === 'branch' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'merge-base' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'all' && `No tracked files${activeWorktreePath ? ' in this worktree' : ' in this repository'}.`} +

+ + )} +
+ {gitContext?.diffOptions && gitContext.diffOptions.length > 1 && ( +

+ Try selecting a different view from the dropdown. +

+ )} +
+
+ )} +
+ + {/* Resize Handle + Sidebar */} + {reviewSidebar.isOpen && ( + <> + + + + )} +
+ + {/* Export Modal */} + {showExportModal && ( +
+
+
+

Export Review Feedback

+ +
+
+
+ {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''} +
+
+                  {feedbackMarkdown}
+                
+
+
+ +
+
+
+ )} + + + + {/* Worktree info dialog */} + {(gitContext?.cwd || agentCwd) && prMetadata && ( + setShowWorktreeDialog(false)} + title="Local Worktree" + wide + message={ +
+

This PR is checked out locally so review agents have full file access.

+
+ Path + +
+

Automatically removed when this review session ends.

+
+ } + variant="info" + /> + )} + + {/* No annotations dialog */} + setShowNoAnnotationsDialog(false)} + title="No Annotations" + message="You haven't made any annotations yet. There's nothing to copy." + variant="info" + /> + + {/* Approve with annotations warning */} + 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 + /> + + 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 */} + { + setShowAISetup(false); + handleAIConfigChange({ providerId }); + }} + /> + + {/* Diff type setup dialog — first-run only */} + {showDiffTypeSetup && ( + { + setShowDiffTypeSetup(false); + if (selected !== diffType) handleDiffSwitch(selected); + }} + /> + )} + + {/* Completion overlay - shown after approve/feedback/exit */} + + + {/* Update notification */} + + + {/* GitHub general comment dialog */} + { + 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} + /> +
+ + {/* Tour dialog overlay */} + 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 && ( + + )} + + +
+
+
+
+ ); +}; + +export default ReviewApp; diff --git a/packages/plannotator-code-review/components/AIConfigBar.tsx b/packages/plannotator-code-review/components/AIConfigBar.tsx new file mode 100644 index 000000000..d3b2986d7 --- /dev/null +++ b/packages/plannotator-code-review/components/AIConfigBar.tsx @@ -0,0 +1,275 @@ +import type React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { getProviderMeta } from '@plannotator/ui/components/ProviderIcons'; + +interface AIProviderModel { + id: string; + label: string; + default?: boolean; +} + +interface AIProviderInfo { + id: string; + name: string; + models?: AIProviderModel[]; +} + +const REASONING_EFFORTS = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Max' }, +] as const; + +interface AIConfigBarProps { + providers: AIProviderInfo[]; + selectedProviderId: string | null; + selectedModel: string | null; + selectedReasoningEffort: string | null; + onProviderChange: (providerId: string) => void; + onModelChange: (model: string) => void; + onReasoningEffortChange: (effort: string | null) => void; + hasSession: boolean; +} + +export const AIConfigBar: React.FC = ({ + providers, + selectedProviderId, + selectedModel, + selectedReasoningEffort, + onProviderChange, + onModelChange, + onReasoningEffortChange, + hasSession, +}) => { + const [showSessionNote, setShowSessionNote] = useState(false); + const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null); + const [modelSearch, setModelSearch] = useState(''); + const barRef = useRef(null); + const searchInputRef = useRef(null); + + // Flash "New chat session" briefly when config changes while a session exists + useEffect(() => { + if (showSessionNote) { + const t = setTimeout(() => setShowSessionNote(false), 2000); + return () => clearTimeout(t); + } + }, [showSessionNote]); + + // Close menu on click outside + useEffect(() => { + if (!openMenu) return; + const handler = (e: MouseEvent) => { + if (barRef.current && !barRef.current.contains(e.target as Node)) { + setOpenMenu(null); + setModelSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [openMenu]); + + if (providers.length === 0) { + return ( +
+ No AI providers available +
+ ); + } + + const effectiveProviderId = selectedProviderId ?? providers[0]?.id; + const currentProvider = providers.find(p => p.id === effectiveProviderId) ?? providers[0]; + if (!currentProvider) return null; + + const meta = getProviderMeta(currentProvider.name); + const Icon = meta.icon; + const models = currentProvider.models ?? []; + const defaultModel = models.find(m => m.default) ?? models[0]; + const effectiveModel = selectedModel ?? defaultModel?.id; + const currentModelLabel = models.find(m => m.id === effectiveModel)?.label ?? defaultModel?.label; + + const handleProviderSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onProviderChange(id); + setOpenMenu(null); + }; + + const handleModelSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onModelChange(id); + setOpenMenu(null); + setModelSearch(''); + }; + + const handleEffortSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onReasoningEffortChange(id); + setOpenMenu(null); + }; + + const chevron = ( + + + + ); + + return ( +
+ {/* Provider selector */} + {providers.length > 1 ? ( +
+ + + {openMenu === 'provider' && ( +
+ {providers.map(p => { + const m = getProviderMeta(p.name); + const ProvIcon = m.icon; + const isActive = p.id === effectiveProviderId; + return ( + + ); + })} +
+ )} +
+ ) : ( + + + {meta.label} + + )} + + {/* Model selector */} + {models.length > 1 ? ( + <> + · +
+ + + {openMenu === 'model' && ( +
+ {models.length > 8 && ( +
+ setModelSearch(e.target.value)} + autoFocus + /> +
+ )} +
8 ? 'ai-config-menu-scroll' : ''}> + {models + .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) + .map(m => { + const isActive = m.id === effectiveModel; + return ( + + ); + })} +
+
+ )} +
+ + ) : currentModelLabel ? ( + <> + · + {currentModelLabel} + + ) : null} + + {/* Reasoning effort — Codex only */} + {currentProvider.name === 'codex-sdk' && ( + <> + · +
+ + + {openMenu === 'effort' && ( +
+ {REASONING_EFFORTS.map(e => { + const isActive = e.id === (selectedReasoningEffort ?? 'high'); + return ( + + ); + })} +
+ )} +
+ + )} + + {/* Spacer */} +
+ + {/* Session reset note */} + {showSessionNote && ( + New chat session + )} +
+ ); +}; diff --git a/packages/plannotator-code-review/components/AITab.tsx b/packages/plannotator-code-review/components/AITab.tsx new file mode 100644 index 000000000..4d395e9cd --- /dev/null +++ b/packages/plannotator-code-review/components/AITab.tsx @@ -0,0 +1,398 @@ +import React, { useRef, useEffect, useState, useMemo, useCallback, memo } from 'react'; +import type { AIChatEntry, PendingPermission } from '../hooks/useAIChat'; +import { renderChatMarkdown } from '../utils/renderChatMarkdown'; +import { formatLineRange } from '../utils/formatLineRange'; +import { formatRelativeTime } from '../utils/formatRelativeTime'; +import { SparklesIcon } from './SparklesIcon'; +import { CountBadge } from './CountBadge'; +import { CopyButton } from './CopyButton'; +import { PermissionCard } from './PermissionCard'; +import { AIConfigBar } from './AIConfigBar'; +import { submitHint } from '@plannotator/ui/utils/platform'; +import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; + +interface AIProviderInfo { + id: string; + name: string; + models?: Array<{ id: string; label: string; default?: boolean }>; +} + +interface AITabProps { + messages: AIChatEntry[]; + isCreatingSession: boolean; + isStreaming: boolean; + activeFilePath?: string; + scrollToQuestionId?: string | null; + onScrollToLines: (filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => void; + onAskGeneral?: (question: string) => void; + permissionRequests?: PendingPermission[]; + onRespondToPermission?: (requestId: string, allow: boolean) => void; + aiProviders?: AIProviderInfo[]; + aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; + onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; + hasAISession?: boolean; +} + +interface FileGroup { + filePath: string; + messages: AIChatEntry[]; +} + +function getQuestionScope(q: AIChatEntry['question']): 'general' | 'file' | 'line' { + if (!q.filePath) return 'general'; + if (q.lineStart == null) return 'file'; + return 'line'; +} + +export const AITab: React.FC = ({ + messages, + isCreatingSession, + isStreaming, + activeFilePath, + scrollToQuestionId, + onScrollToLines, + onAskGeneral, + permissionRequests = [], + onRespondToPermission, + aiProviders = [], + aiConfig, + onAIConfigChange, + hasAISession = false, +}) => { + const scrollRef = useRef(null); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [generalInput, setGeneralInput] = useState(''); + const [highlightFilePath, setHighlightFilePath] = useState(null); + + // Group messages by file + const { fileGroups, generalMessages } = useMemo(() => { + const grouped = new Map(); + const general: AIChatEntry[] = []; + + for (const msg of messages) { + if (!msg.question.filePath) { + general.push(msg); + } else { + const existing = grouped.get(msg.question.filePath) || []; + existing.push(msg); + grouped.set(msg.question.filePath, existing); + } + } + + const fileGroups: FileGroup[] = []; + for (const [filePath, msgs] of grouped) { + msgs.sort((a, b) => { + const aScope = getQuestionScope(a.question); + const bScope = getQuestionScope(b.question); + if (aScope !== bScope) return aScope === 'file' ? -1 : 1; + return (a.question.lineStart ?? 0) - (b.question.lineStart ?? 0); + }); + fileGroups.push({ filePath, messages: msgs }); + } + + return { fileGroups, generalMessages: general }; + }, [messages]); + + // Auto-expand active file's group + useEffect(() => { + if (activeFilePath) { + setExpandedFiles(prev => { + if (prev.has(activeFilePath)) return prev; + const next = new Set(prev); + next.add(activeFilePath); + return next; + }); + } + }, [activeFilePath]); + + // Scroll to specific question and flash-highlight its file group header + useEffect(() => { + if (!scrollToQuestionId || !scrollRef.current) return; + + const msg = messages.find(m => m.question.id === scrollToQuestionId); + const filePath = msg?.question.filePath; + + if (filePath) { + const header = scrollRef.current.querySelector(`[data-file-group="${CSS.escape(filePath)}"]`); + if (header) { + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + setHighlightFilePath(filePath); + setTimeout(() => setHighlightFilePath(null), 1200); + } + + if (filePath && expandedFiles.has(filePath)) { + setTimeout(() => { + const el = scrollRef.current?.querySelector(`[data-question-id="${scrollToQuestionId}"]`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + }, [scrollToQuestionId]); + + // Auto-scroll when new messages arrive (not on every streaming token) + const prevMsgCount = useRef(messages.length); + useEffect(() => { + if (!scrollRef.current) return; + const isNewMessage = messages.length > prevMsgCount.current; + prevMsgCount.current = messages.length; + + if (isNewMessage) { + const allQAs = scrollRef.current.querySelectorAll('[data-question-id]'); + const lastQA = allQAs[allQAs.length - 1]; + if (lastQA) { + lastQA.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + } + }, [messages.length]); + + const toggleFile = (filePath: string) => { + setExpandedFiles(prev => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }; + + const handleGeneralSubmit = () => { + if (!generalInput.trim() || !onAskGeneral) return; + onAskGeneral(generalInput.trim()); + setGeneralInput(''); + }; + + // Empty state + if (messages.length === 0 && !isCreatingSession) { + return ( +
+
+
+ +
+

+ Select lines and click Ask AI, or ask a general question below. +

+
+ onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + {onAskGeneral && } +
+ ); + } + + return ( +
+ +
+ {isCreatingSession && messages.length === 0 && ( +
+ Starting AI session... +
+ )} + + {/* File-grouped questions */} + {fileGroups.map(({ filePath, messages: fileMessages }) => { + const isExpanded = expandedFiles.has(filePath); + const basename = filePath.split('/').pop() || filePath; + + return ( +
+ + + {isExpanded && ( +
+ {fileMessages.map(({ question, response }) => ( + + ))} +
+ )} +
+ ); + })} + + {/* Pending permission requests */} + {permissionRequests.filter(p => !p.decided).map(perm => ( +
+ {})} + /> +
+ ))} + + {/* General questions */} + {generalMessages.length > 0 && ( +
+ {fileGroups.length > 0 && ( +
+
+ General +
+
+ )} +
+ {generalMessages.map(({ question, response }) => ( + + ))} +
+
+ )} +
+ + + {/* Config bar */} + onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + + {/* General question input */} + {onAskGeneral && } +
+ ); +}; + +/** General question input pinned at bottom — textarea grows upward on multi-line */ +const GeneralInput: React.FC<{ + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + disabled?: boolean; +}> = ({ value, onChange, onSubmit, disabled }) => { + const textareaRef = useRef(null); + + const autoResize = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + // Cap at ~6 lines (6 * 16px line-height + padding) + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, []); + + useEffect(() => { autoResize(); }, [value, autoResize]); + + return ( +
+
+