/**
* 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}
{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 (
);
}
// ============================================
// 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);
// 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 (
);
}