/**
* 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}
{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 (
);
}
// ============================================
// 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' : ''}
{errors.map((err, i) => (
-
@{err.comment.author}
{err.comment.path && on {err.comment.path}}: {err.error}
))}
);
}
// ============================================
// 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 (
);
}