/** * 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, RefreshCw, } 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 { cn, generateUUID, normalizeModelEntry } 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 { normalizeThinkingLevelForModel } from '@automaker/types'; import { resolveModelString } from '@automaker/model-resolver'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults'; // ============================================ // 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; /** The URL of the PR, used to set prUrl on created features */ url?: string; } interface PRCommentResolutionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; pr: PRCommentResolutionPRInfo; } // ============================================ // Utility Functions // ============================================ /** Generate a feature ID */ function generateFeatureId(): string { return generateUUID(); } /** 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 (
setIsExpanded((prev) => !prev) : undefined} > onToggle()} className="mt-0.5 shrink-0" onClick={(e) => e.stopPropagation()} />
{/* Header: disclosure triangle + author + file location + tags */}
{comment.avatarUrl ? ( {comment.author} ) : (
)} @{comment.author}
{fileLocation && (
{fileLocation}
)} {comment.isBot && ( Bot )} {comment.isOutdated && ( Outdated )} {comment.isReviewComment && ( Review )} {comment.isResolved && ( Resolved )} {/* Resolve / Unresolve button - only for review comments with a threadId */} {comment.isReviewComment && comment.threadId && onResolve && ( )}
{/* Disclosure triangle - toggles expand/collapse */} {needsExpansion ? ( ) : ( )} {/* 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.isBot && ( Bot )} {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); // Per-thread resolving state - tracks which threads are currently being resolved/unresolved const [resolvingThreads, setResolvingThreads] = useState>(new Set()); // Model selection state const [modelEntry, setModelEntry] = useState({ model: 'claude-sonnet' }); // Track previous open state to detect when dialog opens const wasOpenRef = useRef(false); const handleModelChange = useCallback((entry: PhaseModelEntry) => { const modelId = typeof entry.model === 'string' ? entry.model : ''; const normalizedThinkingLevel = normalizeThinkingLevelForModel(modelId, entry.thinkingLevel); setModelEntry({ ...entry, thinkingLevel: normalizedThinkingLevel }); }, []); // Fetch PR review comments const { data, isLoading: loading, isFetching: refreshing, error, refetch, } = useGitHubPRReviewComments(currentProject?.path, open ? pr.number : undefined); // Sync model defaults and refresh comments when dialog opens (transitions from closed to open) useEffect(() => { const justOpened = open && !wasOpenRef.current; wasOpenRef.current = open; if (justOpened) { setModelEntry(effectiveDefaultFeatureModel); // Force refresh PR comments from GitHub when dialog opens refetch(); } }, [open, effectiveDefaultFeatureModel, refetch]); 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 && comments.every((c) => selectedIds.has(c.id)); const someSelected = selectedIds.size > 0 && !allSelected; 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; const threadId = comment.threadId; setResolvingThreads((prev) => { const next = new Set(prev); next.add(threadId); return next; }); resolveThread.mutate( { threadId, resolve }, { onSettled: () => { setResolvingThreads((prev) => { const next = new Set(prev); next.delete(threadId); return next; }); }, } ); }, [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 and normalize model settings const normalizedEntry = normalizeModelEntry(modelEntry); const selectedModel = resolveModelString(normalizedEntry.model); 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: normalizedEntry.thinkingLevel, reasoningEffort: normalizedEntry.reasoningEffort, providerId: normalizedEntry.providerId, planningMode: 'skip', requirePlanApproval: false, dependencies: [], ...(pr.url ? { prUrl: pr.url } : {}), // 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: normalizedEntry.thinkingLevel, reasoningEffort: normalizedEntry.reasoningEffort, providerId: normalizedEntry.providerId, planningMode: 'skip', requirePlanApproval: false, dependencies: [], ...(pr.url ? { prUrl: pr.url } : {}), // 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); setResolvingThreads(new Set()); 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={ !!comment.threadId && resolvingThreads.has(comment.threadId) } /> ))}
)} {/* 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); }} />
); }