/** * PR Comment Resolution Dialog * * A dialog that displays PR review comments with multi-selection support, * allowing users to create feature tasks to address comments individually * or as a group. */ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { MessageSquare, FileCode, User, AlertCircle, CheckCircle2, Loader2, ChevronDown, ArrowUpDown, EyeOff, Eye, Maximize2, Check, Undo2, } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { Markdown } from '@/components/ui/markdown'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn, modelSupportsThinking } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { useGitHubPRReviewComments } from '@/hooks/queries'; import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations'; import { toast } from 'sonner'; import type { PRReviewComment } from '@/lib/electron'; import type { Feature } from '@/store/app-store'; import type { PhaseModelEntry } from '@automaker/types'; import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; // ============================================ // Types // ============================================ type AddressMode = 'together' | 'individually'; type SortOrder = 'newest' | 'oldest'; /** Minimal PR info needed by the dialog - works with both GitHubPR and WorktreePRInfo */ export interface PRCommentResolutionPRInfo { number: number; title: string; /** The branch name (headRefName) associated with this PR, used to assign features to the correct worktree */ headRefName?: string; } interface PRCommentResolutionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; pr: PRCommentResolutionPRInfo; } // ============================================ // Utility Functions // ============================================ /** Generate a feature ID */ function generateFeatureId(): string { return `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`; } /** Format a date string for display */ function formatDate(dateString: string): string { const date = new Date(dateString); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); } /** Format a time string for display */ function formatTime(dateString: string): string { const date = new Date(dateString); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }); } /** Format the file location string */ function formatFileLocation(comment: PRReviewComment): string | null { if (!comment.path) return null; if (comment.line) return `${comment.path}:${comment.line}`; return comment.path; } // ============================================ // Prompt Generation // ============================================ /** Generate a feature title for a single comment */ function generateSingleCommentTitle( pr: PRCommentResolutionPRInfo, comment: PRReviewComment ): string { const location = comment.path ? ` on ${comment.path}${comment.line ? `:${comment.line}` : ''}` : ''; return `Address PR #${pr.number} comment by @${comment.author}${location}`; } /** Generate a feature title for multiple comments addressed together */ function generateGroupTitle(pr: PRCommentResolutionPRInfo, comments: PRReviewComment[]): string { return `Address ${comments.length} review comment${comments.length > 1 ? 's' : ''} on PR #${pr.number}`; } /** Generate a feature description for a single comment */ function generateSingleCommentDescription( pr: PRCommentResolutionPRInfo, comment: PRReviewComment ): string { const fileContext = comment.path ? `**File:** \`${comment.path}\`${comment.line ? ` (line ${comment.line})` : ''}\n` : ''; return `## PR Review Comment Resolution **Pull Request:** #${pr.number} - ${pr.title} **Comment Author:** @${comment.author} ${fileContext} ### Review Comment > ${comment.body.split('\n').join('\n> ')} ### Instructions Please address the review comment above. The comment was left ${comment.isReviewComment ? 'as an inline code review' : 'as a general PR'} comment${comment.path ? ` on file \`${comment.path}\`` : ''}${comment.line ? ` at line ${comment.line}` : ''}. Review the code in context and make the necessary changes to resolve this feedback. Ensure the changes: 1. Directly address the reviewer's concern 2. Follow the existing code patterns and conventions 3. Do not introduce regressions `; } /** Generate a feature description for multiple comments addressed together */ function generateGroupDescription( pr: PRCommentResolutionPRInfo, comments: PRReviewComment[] ): string { const commentSections = comments .map((comment, index) => { const fileContext = comment.path ? `**File:** \`${comment.path}\`${comment.line ? ` (line ${comment.line})` : ''}\n` : ''; return `### Comment ${index + 1} - by @${comment.author} ${fileContext} > ${comment.body.split('\n').join('\n> ')} `; }) .join('\n---\n\n'); return `## PR Review Comments Resolution **Pull Request:** #${pr.number} - ${pr.title} **Number of comments:** ${comments.length} Please address all of the following review comments from this pull request. --- ${commentSections} ### Instructions Please address all the review comments listed above. For each comment: 1. Review the code in context at the specified file and line 2. Make the necessary changes to resolve the reviewer's feedback 3. Ensure changes follow existing code patterns and conventions 4. Do not introduce regressions `; } // ============================================ // Comment Row Component // ============================================ interface CommentRowProps { comment: PRReviewComment; isSelected: boolean; onToggle: () => void; onExpandDetail: () => void; onResolve?: (comment: PRReviewComment, resolve: boolean) => void; isResolvingThread?: boolean; } function CommentRow({ comment, isSelected, onToggle, onExpandDetail, onResolve, isResolvingThread, }: CommentRowProps) { const fileLocation = formatFileLocation(comment); const [isExpanded, setIsExpanded] = useState(false); // Determine if the comment body is long enough to need expansion const PREVIEW_CHAR_LIMIT = 200; const needsExpansion = comment.body.length > PREVIEW_CHAR_LIMIT || comment.body.includes('\n'); const handleExpandToggle = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setIsExpanded((prev) => !prev); }, []); const handleExpandDetail = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onExpandDetail(); }, [onExpandDetail] ); const handleResolveClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (onResolve) { onResolve(comment, !comment.isResolved); } }, [comment, onResolve] ); return (
onToggle()} className="mt-0.5" onClick={(e) => e.stopPropagation()} />
{/* Header: disclosure triangle + author + file location + tags */}
{/* Disclosure triangle - always shown, toggles expand/collapse */} {needsExpansion ? ( ) : ( )}
{comment.avatarUrl ? ( {comment.author} ) : (
)} @{comment.author}
{fileLocation && (
{fileLocation}
)} {comment.isOutdated && ( Outdated )} {comment.isReviewComment && ( Review )} {comment.isResolved && ( Resolved )} {/* Resolve / Unresolve button - only for review comments with a threadId */} {comment.isReviewComment && comment.threadId && onResolve && ( )} {/* Expand detail button */}
{/* Comment body - collapsible, rendered as markdown */} {isExpanded ? (
e.stopPropagation()}> {comment.body}
) : (
{comment.body}
)} {/* Date row */}
{formatDate(comment.createdAt)}
{formatTime(comment.createdAt)}
); } // ============================================ // Comment Detail Dialog Component // ============================================ interface CommentDetailDialogProps { comment: PRReviewComment | null; open: boolean; onOpenChange: (open: boolean) => void; } function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialogProps) { if (!comment) return null; const fileLocation = formatFileLocation(comment); return ( Comment Details Full view of the review comment with rendered content.
{/* Author & metadata section */}
{comment.avatarUrl ? ( {comment.author} ) : (
)}
@{comment.author}
{formatDate(comment.createdAt)} at {formatTime(comment.createdAt)}
{/* Badges */}
{comment.isOutdated && ( Outdated )} {comment.isReviewComment && ( Review )} {comment.isResolved && ( Resolved )}
{/* File location */} {fileLocation && (
{fileLocation}
)} {/* Diff hunk */} {comment.diffHunk && (
Code Context
                  {comment.diffHunk}
                
)} {/* Comment body - rendered as markdown */}
{comment.body}
{/* Additional metadata */} {(comment.updatedAt || comment.commitId || comment.side) && (
{comment.updatedAt && comment.updatedAt !== comment.createdAt && ( Updated: {formatDate(comment.updatedAt)} )} {comment.commitId && ( Commit: {comment.commitId.slice(0, 7)} )} {comment.side && Side: {comment.side}}
)}
); } // ============================================ // Error State Component // ============================================ interface CreationErrorStateProps { errors: Array<{ comment: PRReviewComment; error: string }>; onDismiss: () => void; } function CreationErrorState({ errors, onDismiss }: CreationErrorStateProps) { return (
Failed to create {errors.length} feature{errors.length > 1 ? 's' : ''}
); } // ============================================ // Main Dialog Component // ============================================ export function PRCommentResolutionDialog({ open, onOpenChange, pr, }: PRCommentResolutionDialogProps) { const { currentProject, defaultFeatureModel } = useAppStore(); // Use project-level default feature model if set, otherwise fall back to global const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel; // State const [selectedIds, setSelectedIds] = useState>(new Set()); const [addressMode, setAddressMode] = useState('together'); const [sortOrder, setSortOrder] = useState('newest'); const [showResolved, setShowResolved] = useState(false); const [isCreating, setIsCreating] = useState(false); const [creationErrors, setCreationErrors] = useState< Array<{ comment: PRReviewComment; error: string }> >([]); const [detailComment, setDetailComment] = useState(null); // Model selection state const [modelEntry, setModelEntry] = useState({ model: 'claude-sonnet' }); // Track previous open state to detect when dialog opens const wasOpenRef = useRef(false); // Sync model defaults only when dialog opens (transitions from closed to open) useEffect(() => { const justOpened = open && !wasOpenRef.current; wasOpenRef.current = open; if (justOpened) { setModelEntry(effectiveDefaultFeatureModel); } }, [open, effectiveDefaultFeatureModel]); const handleModelChange = useCallback((entry: PhaseModelEntry) => { // Normalize thinking level when switching between adaptive and non-adaptive models const isNewModelAdaptive = typeof entry.model === 'string' && isAdaptiveThinkingModel(entry.model); const currentLevel = entry.thinkingLevel || 'none'; if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') { // Switching TO an adaptive model with a manual level -> auto-switch to 'adaptive' setModelEntry({ ...entry, thinkingLevel: 'adaptive' }); } else if (!isNewModelAdaptive && currentLevel === 'adaptive') { // Switching FROM an adaptive model with adaptive -> auto-switch to 'high' setModelEntry({ ...entry, thinkingLevel: 'high' }); } else { setModelEntry(entry); } }, []); // Fetch PR review comments const { data, isLoading: loading, error, refetch, } = useGitHubPRReviewComments(currentProject?.path, open ? pr.number : undefined); const allComments = useMemo(() => { const raw = data?.comments ?? []; // Sort based on current sort order return [...raw].sort((a, b) => { const dateA = new Date(a.createdAt).getTime(); const dateB = new Date(b.createdAt).getTime(); return sortOrder === 'newest' ? dateB - dateA : dateA - dateB; }); }, [data, sortOrder]); // Count resolved and unresolved comments for filter display const resolvedCount = useMemo( () => allComments.filter((c) => c.isResolved).length, [allComments] ); const hasResolvedComments = resolvedCount > 0; const comments = useMemo(() => { if (showResolved) return allComments; return allComments.filter((c) => !c.isResolved); }, [allComments, showResolved]); // Feature creation mutation const createFeature = useCreateFeature(currentProject?.path ?? ''); // Resolve/unresolve thread mutation const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number); // Derived state const allSelected = comments.length > 0 && selectedIds.size === comments.length; const someSelected = selectedIds.size > 0 && selectedIds.size < comments.length; const noneSelected = selectedIds.size === 0; // ============================================ // Handlers // ============================================ const handleToggleComment = useCallback((commentId: string) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(commentId)) { next.delete(commentId); } else { next.add(commentId); } return next; }); }, []); const handleResolveComment = useCallback( (comment: PRReviewComment, resolve: boolean) => { if (!comment.threadId) return; resolveThread.mutate({ threadId: comment.threadId, resolve }); }, [resolveThread] ); const handleSelectAll = useCallback(() => { if (allSelected) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(comments.map((c) => c.id))); } }, [allSelected, comments]); const handleModeChange = useCallback((checked: boolean) => { setAddressMode(checked ? 'individually' : 'together'); }, []); const handleSortToggle = useCallback(() => { setSortOrder((prev) => (prev === 'newest' ? 'oldest' : 'newest')); }, []); const handleShowResolvedToggle = useCallback(() => { setShowResolved((prev) => { const nextShowResolved = !prev; // When hiding resolved comments, remove any selected resolved comment IDs if (!nextShowResolved) { setSelectedIds((prevIds) => { const resolvedIds = new Set(allComments.filter((c) => c.isResolved).map((c) => c.id)); const next = new Set(prevIds); for (const id of resolvedIds) { next.delete(id); } return next.size !== prevIds.size ? next : prevIds; }); } return nextShowResolved; }); }, [allComments]); const handleSubmit = useCallback(async () => { if (noneSelected || !currentProject?.path) return; const selectedComments = comments.filter((c) => selectedIds.has(c.id)); // Resolve model settings from the current model entry const selectedModel = modelEntry.model; const normalizedThinking = modelSupportsThinking(selectedModel) ? modelEntry.thinkingLevel || 'none' : 'none'; const normalizedReasoning = supportsReasoningEffort(selectedModel) ? modelEntry.reasoningEffort || 'none' : 'none'; setIsCreating(true); setCreationErrors([]); try { if (addressMode === 'together') { // Create a single feature for all selected comments const feature: Feature = { id: generateFeatureId(), title: generateGroupTitle(pr, selectedComments), category: 'bug-fix', description: generateGroupDescription(pr, selectedComments), steps: [], status: 'backlog', model: selectedModel, thinkingLevel: normalizedThinking, reasoningEffort: normalizedReasoning, // Associate feature with the PR's branch so it appears on the correct worktree ...(pr.headRefName ? { branchName: pr.headRefName } : {}), }; await createFeature.mutateAsync(feature); toast.success('Feature created', { description: `Created feature to address ${selectedComments.length} PR comment${selectedComments.length > 1 ? 's' : ''}`, }); onOpenChange(false); } else { // Create one feature per selected comment const errors: Array<{ comment: PRReviewComment; error: string }> = []; let successCount = 0; for (const comment of selectedComments) { try { const feature: Feature = { id: generateFeatureId(), title: generateSingleCommentTitle(pr, comment), category: 'bug-fix', description: generateSingleCommentDescription(pr, comment), steps: [], status: 'backlog', model: selectedModel, thinkingLevel: normalizedThinking, reasoningEffort: normalizedReasoning, // Associate feature with the PR's branch so it appears on the correct worktree ...(pr.headRefName ? { branchName: pr.headRefName } : {}), }; await createFeature.mutateAsync(feature); successCount++; } catch (err) { errors.push({ comment, error: err instanceof Error ? err.message : 'Unknown error', }); } } if (errors.length > 0) { setCreationErrors(errors); if (successCount > 0) { toast.warning(`Created ${successCount} feature${successCount > 1 ? 's' : ''}`, { description: `${errors.length} failed to create`, }); } } else { toast.success(`Created ${successCount} feature${successCount > 1 ? 's' : ''}`, { description: `Each PR comment will be addressed individually`, }); onOpenChange(false); } } } catch (err) { toast.error('Failed to create feature', { description: err instanceof Error ? err.message : 'Unknown error', }); } finally { setIsCreating(false); } }, [ noneSelected, currentProject?.path, comments, selectedIds, addressMode, pr, createFeature, onOpenChange, modelEntry, ]); const handleOpenChange = useCallback( (newOpen: boolean) => { if (!newOpen) { // Reset state when closing setSelectedIds(new Set()); setAddressMode('together'); setSortOrder('newest'); setShowResolved(false); setCreationErrors([]); setDetailComment(null); setModelEntry(effectiveDefaultFeatureModel); } onOpenChange(newOpen); }, [onOpenChange, effectiveDefaultFeatureModel] ); // ============================================ // Render // ============================================ return ( Manage PR Review Comments Select comments from PR #{pr.number} to create feature tasks that address them. {/* Content Area */}
{/* Loading State */} {loading && (
)} {/* Error State */} {error && !loading && (

Failed to Load Comments

{error instanceof Error ? error.message : 'Unknown error'}

)} {/* Comments List (controls + items) - shown whenever there are any comments */} {!loading && !error && allComments.length > 0 && ( <> {/* Controls Bar */}
{/* Select All - only interactive when there are visible comments */}
{/* Show/Hide Resolved Filter Toggle - always visible */} {/* Sort Toggle Button */} {/* Mode Toggle */}
{/* Empty State - all comments filtered out (all resolved, filter hiding them) */} {comments.length === 0 && (

All Comments Resolved

All {resolvedCount} comment{resolvedCount !== 1 ? 's' : ''} on this pull request{' '} {resolvedCount !== 1 ? 'have' : 'has'} been resolved.

)} {/* Selection Info */} {!noneSelected && comments.length > 0 && (
{selectedIds.size} comment{selectedIds.size > 1 ? 's' : ''} selected {addressMode === 'together' ? ' - will create 1 feature' : ` - will create ${selectedIds.size} feature${selectedIds.size > 1 ? 's' : ''}`}
)} {/* Scrollable Comments */} {comments.length > 0 && (
{comments.map((comment) => ( handleToggleComment(comment.id)} onExpandDetail={() => setDetailComment(comment)} onResolve={handleResolveComment} isResolvingThread={resolveThread.isPending} /> ))}
)} {/* Creation Errors */} {creationErrors.length > 0 && ( setCreationErrors([])} /> )} )} {/* Empty State - no comments at all */} {!loading && !error && allComments.length === 0 && (

No Open Comments

This pull request has no comments to address.

)}
{/* Footer */}
{/* Cancel button - left side */} {/* Model selector + Create button - right side */}
{!loading && !error && allComments.length > 0 && ( )}
{/* Comment Detail Dialog - opened when user clicks expand on a comment */} { if (!open) setDetailComment(null); }} />
); }