mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge pull request #250 from AutoMaker-Org/feat/convert-issues-to-task
feat: abbility to analyze github issues with ai with confidence / task creation
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
useSetupDialog,
|
||||
useTrashDialog,
|
||||
useProjectTheme,
|
||||
useUnviewedValidations,
|
||||
} from './sidebar/hooks';
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -127,6 +128,9 @@ export function Sidebar() {
|
||||
// Running agents count
|
||||
const { runningAgentsCount } = useRunningAgents();
|
||||
|
||||
// Unviewed validations count
|
||||
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
||||
|
||||
// Trash dialog and operations
|
||||
const {
|
||||
showTrashDialog,
|
||||
@@ -235,6 +239,7 @@ export function Sidebar() {
|
||||
setIsProjectPickerOpen,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
});
|
||||
|
||||
// Register keyboard shortcuts
|
||||
|
||||
@@ -78,14 +78,29 @@ export function SidebarNavigation({
|
||||
title={!sidebarOpen ? item.label : undefined}
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
<div className="relative">
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
{/* Count badge for collapsed state */}
|
||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'bg-primary text-primary-foreground shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
>
|
||||
{item.count > 99 ? '99' : item.count}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
@@ -94,7 +109,21 @@ export function SidebarNavigation({
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.shortcut && sidebarOpen && (
|
||||
{/* Count badge */}
|
||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center',
|
||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||
'bg-primary text-primary-foreground shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
data-testid={`count-${item.id}`}
|
||||
>
|
||||
{item.count > 99 ? '99+' : item.count}
|
||||
</span>
|
||||
)}
|
||||
{item.shortcut && sidebarOpen && !item.count && (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
|
||||
@@ -10,3 +10,4 @@ export { useProjectCreation } from './use-project-creation';
|
||||
export { useSetupDialog } from './use-setup-dialog';
|
||||
export { useTrashDialog } from './use-trash-dialog';
|
||||
export { useProjectTheme } from './use-project-theme';
|
||||
export { useUnviewedValidations } from './use-unviewed-validations';
|
||||
|
||||
@@ -44,6 +44,8 @@ interface UseNavigationProps {
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
cyclePrevProject: () => void;
|
||||
cycleNextProject: () => void;
|
||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||
unviewedValidationsCount?: number;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
@@ -61,6 +63,7 @@ export function useNavigation({
|
||||
setIsProjectPickerOpen,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||
@@ -169,6 +172,7 @@ export function useNavigation({
|
||||
id: 'github-issues',
|
||||
label: 'Issues',
|
||||
icon: CircleDot,
|
||||
count: unviewedValidationsCount,
|
||||
},
|
||||
{
|
||||
id: 'github-prs',
|
||||
@@ -180,7 +184,15 @@ export function useNavigation({
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
|
||||
}, [
|
||||
shortcuts,
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
hasGitHubRemote,
|
||||
unviewedValidationsCount,
|
||||
]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { Project, StoredValidation } from '@/lib/electron';
|
||||
|
||||
/**
|
||||
* Hook to track the count of unviewed (fresh) issue validations for a project.
|
||||
* Also provides a function to decrement the count when a validation is viewed.
|
||||
*/
|
||||
export function useUnviewedValidations(currentProject: Project | null) {
|
||||
const [count, setCount] = useState(0);
|
||||
const projectPathRef = useRef<string | null>(null);
|
||||
|
||||
// Keep project path in ref for use in async functions
|
||||
useEffect(() => {
|
||||
projectPathRef.current = currentProject?.path ?? null;
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Fetch and update count from server
|
||||
const fetchUnviewedCount = useCallback(async () => {
|
||||
const projectPath = projectPathRef.current;
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.getValidations) {
|
||||
const result = await api.github.getValidations(projectPath);
|
||||
if (result.success && result.validations) {
|
||||
const unviewed = result.validations.filter((v: StoredValidation) => {
|
||||
if (v.viewedAt) return false;
|
||||
// Check if not stale (< 24 hours)
|
||||
const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60);
|
||||
return hoursSince <= 24;
|
||||
});
|
||||
// Only update count if we're still on the same project (guard against race condition)
|
||||
if (projectPathRef.current === projectPath) {
|
||||
setCount(unviewed.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useUnviewedValidations] Failed to load count:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load initial count and subscribe to events
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) {
|
||||
setCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load initial count
|
||||
fetchUnviewedCount();
|
||||
|
||||
// Subscribe to validation events to update count
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.onValidationEvent) {
|
||||
const unsubscribe = api.github.onValidationEvent((event) => {
|
||||
if (event.projectPath === currentProject.path) {
|
||||
if (event.type === 'issue_validation_complete') {
|
||||
// New validation completed - refresh count from server for consistency
|
||||
fetchUnviewedCount();
|
||||
} else if (event.type === 'issue_validation_viewed') {
|
||||
// Validation was viewed - refresh count from server for consistency
|
||||
fetchUnviewedCount();
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}
|
||||
}, [currentProject?.path, fetchUnviewedCount]);
|
||||
|
||||
// Function to decrement count when a validation is viewed
|
||||
const decrementCount = useCallback(() => {
|
||||
setCount((prev) => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
// Expose refreshCount as an alias to fetchUnviewedCount for external use
|
||||
const refreshCount = fetchUnviewedCount;
|
||||
|
||||
return { count, decrementCount, refreshCount };
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export interface NavItem {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
shortcut?: string;
|
||||
/** Optional count badge to display next to the nav item */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
|
||||
83
apps/ui/src/components/ui/confirm-dialog.tsx
Normal file
83
apps/ui/src/components/ui/confirm-dialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional icon to show in the title */
|
||||
icon?: LucideIcon;
|
||||
/** Icon color class. Defaults to "text-primary" */
|
||||
iconClassName?: string;
|
||||
/** Optional content to show between description and buttons */
|
||||
children?: ReactNode;
|
||||
/** Text for the confirm button. Defaults to "Confirm" */
|
||||
confirmText?: string;
|
||||
/** Text for the cancel button. Defaults to "Cancel" */
|
||||
cancelText?: string;
|
||||
/** Variant for the confirm button. Defaults to "default" */
|
||||
confirmVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
iconClassName = 'text-primary',
|
||||
children,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmVariant = 'default',
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{Icon && <Icon className={`w-5 h-5 ${iconClassName}`} />}
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{children}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
|
||||
{cancelText}
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
className="px-4"
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{confirmText}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
36
apps/ui/src/components/ui/error-state.tsx
Normal file
36
apps/ui/src/components/ui/error-state.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
|
||||
interface ErrorStateProps {
|
||||
/** Error message to display */
|
||||
error: string;
|
||||
/** Title for the error state (default: "Failed to Load") */
|
||||
title?: string;
|
||||
/** Callback when retry button is clicked */
|
||||
onRetry?: () => void;
|
||||
/** Text for the retry button (default: "Try Again") */
|
||||
retryText?: string;
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
error,
|
||||
title = 'Failed to Load',
|
||||
onRetry,
|
||||
retryText = 'Try Again',
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
||||
<CircleDot className="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">{title}</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{retryText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/ui/src/components/ui/loading-state.tsx
Normal file
17
apps/ui/src/components/ui/loading-state.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingStateProps {
|
||||
/** Optional custom message to display below the spinner */
|
||||
message?: string;
|
||||
/** Optional custom size class for the spinner (default: h-8 w-8) */
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
|
||||
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +1,126 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { ErrorState } from '@/components/ui/error-state';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||
|
||||
export function GitHubIssuesView() {
|
||||
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
|
||||
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const { currentProject } = useAppStore();
|
||||
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
||||
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
||||
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
|
||||
|
||||
const fetchIssues = useCallback(async () => {
|
||||
if (!currentProject?.path) {
|
||||
setError('No project selected');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
|
||||
useAppStore();
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.listIssues(currentProject.path);
|
||||
if (result.success) {
|
||||
setOpenIssues(result.openIssues || []);
|
||||
setClosedIssues(result.closedIssues || []);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch issues');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GitHubIssuesView] Error fetching issues:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
||||
|
||||
useEffect(() => {
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
||||
useIssueValidation({
|
||||
selectedIssue,
|
||||
showValidationDialog,
|
||||
onValidationResultChange: setValidationResult,
|
||||
onShowValidationDialogChange: setShowValidationDialog,
|
||||
});
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
// Get default AI profile for task creation
|
||||
const defaultProfile = useMemo(() => {
|
||||
if (!defaultAIProfileId) return null;
|
||||
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null;
|
||||
}, [defaultAIProfileId, aiProfiles]);
|
||||
|
||||
// Get current branch from selected worktree
|
||||
const currentBranch = useMemo(() => {
|
||||
if (!currentProject?.path) return '';
|
||||
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
|
||||
const worktrees = worktreesByProject[currentProject.path] ?? [];
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
|
||||
const selectedWorktree =
|
||||
currentWorktreePath === null
|
||||
? worktrees.find((w) => w.isMain)
|
||||
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||
|
||||
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
|
||||
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
|
||||
|
||||
const handleOpenInGitHub = useCallback((url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
const handleConvertToTask = useCallback(
|
||||
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.features?.create) {
|
||||
// Build description from issue body + validation info
|
||||
const description = [
|
||||
`**From GitHub Issue #${issue.number}**`,
|
||||
'',
|
||||
issue.body || 'No description provided.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'**AI Validation Analysis:**',
|
||||
validation.reasoning,
|
||||
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
|
||||
validation.relatedFiles?.length
|
||||
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const feature = {
|
||||
id: `issue-${issue.number}-${crypto.randomUUID()}`,
|
||||
title: issue.title,
|
||||
description,
|
||||
category: 'From GitHub',
|
||||
status: 'backlog' as const,
|
||||
passes: false,
|
||||
priority: getFeaturePriority(validation.estimatedComplexity),
|
||||
model: defaultProfile?.model ?? 'opus',
|
||||
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
|
||||
branchName: currentBranch,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await api.features.create(currentProject.path, feature);
|
||||
if (result.success) {
|
||||
toast.success(`Created task: ${issue.title}`);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to create task');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GitHubIssuesView] Convert to task error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create task');
|
||||
}
|
||||
},
|
||||
[currentProject?.path, defaultProfile, currentBranch]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
||||
<CircleDot className="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Failed to Load Issues</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
||||
}
|
||||
|
||||
const totalIssues = openIssues.length + closedIssues.length;
|
||||
@@ -102,24 +135,12 @@ export function GitHubIssuesView() {
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalIssues === 0
|
||||
? 'No issues found'
|
||||
: `${openIssues.length} open, ${closedIssues.length} closed`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<IssuesListHeader
|
||||
openCount={openIssues.length}
|
||||
closedCount={closedIssues.length}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
|
||||
{/* Issues List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -142,6 +163,8 @@ export function GitHubIssuesView() {
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||
formatDate={formatDate}
|
||||
cachedValidation={cachedValidations.get(issue.number)}
|
||||
isValidating={validatingIssues.has(issue.number)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -159,6 +182,8 @@ export function GitHubIssuesView() {
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||
formatDate={formatDate}
|
||||
cachedValidation={cachedValidations.get(issue.number)}
|
||||
isValidating={validatingIssues.has(issue.number)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -170,164 +195,43 @@ export function GitHubIssuesView() {
|
||||
|
||||
{/* Issue Detail Panel */}
|
||||
{selectedIssue && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedIssue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
#{selectedIssue.number} {selectedIssue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenInGitHub(selectedIssue.url)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Open in GitHub
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
selectedIssue.state === 'OPEN'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-purple-500/10 text-purple-500'
|
||||
)}
|
||||
>
|
||||
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
<span>
|
||||
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
|
||||
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{selectedIssue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
{selectedIssue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{selectedIssue.body ? (
|
||||
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
View comments, add reactions, and more on GitHub.
|
||||
</p>
|
||||
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Full Issue on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onOpenExternal: () => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{issue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<IssueDetailPanel
|
||||
issue={selectedIssue}
|
||||
validatingIssues={validatingIssues}
|
||||
cachedValidations={cachedValidations}
|
||||
onValidateIssue={handleValidateIssue}
|
||||
onViewCachedValidation={handleViewCachedValidation}
|
||||
onOpenInGitHub={handleOpenInGitHub}
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{issue.title}</span>
|
||||
</div>
|
||||
{/* Validation Dialog */}
|
||||
<ValidationDialog
|
||||
open={showValidationDialog}
|
||||
onOpenChange={setShowValidationDialog}
|
||||
issue={selectedIssue}
|
||||
validationResult={validationResult}
|
||||
onConvertToTask={handleConvertToTask}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{issue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenExternal();
|
||||
{/* Revalidate Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showRevalidateConfirm}
|
||||
onOpenChange={setShowRevalidateConfirm}
|
||||
title="Re-validate Issue"
|
||||
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
|
||||
icon={RefreshCw}
|
||||
iconClassName="text-primary"
|
||||
confirmText="Re-validate"
|
||||
onConfirm={() => {
|
||||
if (selectedIssue) {
|
||||
handleValidateIssue(selectedIssue, { forceRevalidate: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { IssueRow } from './issue-row';
|
||||
export { IssueDetailPanel } from './issue-detail-panel';
|
||||
export { IssuesListHeader } from './issues-list-header';
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Wand2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
GitPullRequest,
|
||||
User,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueDetailPanelProps } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
|
||||
export function IssueDetailPanel({
|
||||
issue,
|
||||
validatingIssues,
|
||||
cachedValidations,
|
||||
onValidateIssue,
|
||||
onViewCachedValidation,
|
||||
onOpenInGitHub,
|
||||
onClose,
|
||||
onShowRevalidateConfirm,
|
||||
formatDate,
|
||||
}: IssueDetailPanelProps) {
|
||||
const isValidating = validatingIssues.has(issue.number);
|
||||
const cached = cachedValidations.get(issue.number);
|
||||
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{issue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
#{issue.number} {issue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{(() => {
|
||||
if (isValidating) {
|
||||
return (
|
||||
<Button variant="default" size="sm" disabled>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
Validating...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (cached && !isStale) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
||||
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
||||
View Result
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onShowRevalidateConfirm}
|
||||
title="Re-validate"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (cached && isStale) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
||||
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
||||
View (stale)
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
Re-validate
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
Validate with AI
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Open in GitHub
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
issue.state === 'OPEN'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-purple-500/10 text-purple-500'
|
||||
)}
|
||||
>
|
||||
{issue.state === 'OPEN' ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
<span>
|
||||
#{issue.number} opened {formatDate(issue.createdAt)} by{' '}
|
||||
<span className="font-medium text-foreground">{issue.author.login}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{issue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
{issue.assignees && issue.assignees.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Assigned to:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{issue.assignees.map((assignee) => (
|
||||
<span
|
||||
key={assignee.login}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20"
|
||||
>
|
||||
{assignee.avatarUrl && (
|
||||
<img
|
||||
src={assignee.avatarUrl}
|
||||
alt={assignee.login}
|
||||
className="h-4 w-4 rounded-full"
|
||||
/>
|
||||
)}
|
||||
{assignee.login}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Pull Requests */}
|
||||
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitPullRequest className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Linked Pull Requests</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{issue.linkedPRs.map((pr) => (
|
||||
<div key={pr.number} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 text-xs font-medium rounded',
|
||||
pr.state === 'open'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: pr.state === 'merged'
|
||||
? 'bg-purple-500/10 text-purple-500'
|
||||
: 'bg-red-500/10 text-red-500'
|
||||
)}
|
||||
>
|
||||
{pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">#{pr.number}</span>
|
||||
<span className="truncate">{pr.title}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 shrink-0"
|
||||
onClick={() => onOpenInGitHub(pr.url)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{issue.body ? (
|
||||
<Markdown className="text-sm">{issue.body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
View comments, add reactions, and more on GitHub.
|
||||
</p>
|
||||
<Button onClick={() => onOpenInGitHub(issue.url)}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Full Issue on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Sparkles,
|
||||
GitPullRequest,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueRowProps } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
|
||||
export function IssueRow({
|
||||
issue,
|
||||
isSelected,
|
||||
onClick,
|
||||
onOpenExternal,
|
||||
formatDate,
|
||||
cachedValidation,
|
||||
isValidating,
|
||||
}: IssueRowProps) {
|
||||
// Check if validation exists and calculate staleness
|
||||
const validationHoursSince = cachedValidation
|
||||
? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60)
|
||||
: null;
|
||||
const isValidationStaleValue =
|
||||
validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt);
|
||||
|
||||
// Check if validation is unviewed (exists, not stale, not viewed)
|
||||
const hasUnviewedValidation =
|
||||
cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue;
|
||||
|
||||
// Check if validation has been viewed (exists and was viewed)
|
||||
const hasViewedValidation =
|
||||
cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{issue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{issue.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{/* Labels */}
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Linked PR indicator */}
|
||||
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20">
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Assignee indicator */}
|
||||
{issue.assignees && issue.assignees.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20">
|
||||
<User className="h-3 w-3" />
|
||||
{issue.assignees.map((a) => a.login).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Validating indicator */}
|
||||
{isValidating && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Analyzing...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Unviewed validation indicator */}
|
||||
{!isValidating && hasUnviewedValidation && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 animate-in fade-in duration-200">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Analysis Ready
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Viewed validation indicator */}
|
||||
{!isValidating && hasViewedValidation && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-green-500/10 text-green-500 border border-green-500/20">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Validated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenExternal();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface IssuesListHeaderProps {
|
||||
openCount: number;
|
||||
closedCount: number;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function IssuesListHeader({
|
||||
openCount,
|
||||
closedCount,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
}: IssuesListHeaderProps) {
|
||||
const totalIssues = openCount + closedCount;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10">
|
||||
<CircleDot className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Issues</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const VALIDATION_STALENESS_HOURS = 24;
|
||||
@@ -0,0 +1 @@
|
||||
export { ValidationDialog } from './validation-dialog';
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
FileCode,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
IssueValidationResult,
|
||||
IssueValidationVerdict,
|
||||
IssueValidationConfidence,
|
||||
IssueComplexity,
|
||||
GitHubIssue,
|
||||
} from '@/lib/electron';
|
||||
|
||||
interface ValidationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
issue: GitHubIssue | null;
|
||||
validationResult: IssueValidationResult | null;
|
||||
onConvertToTask?: (issue: GitHubIssue, validation: IssueValidationResult) => void;
|
||||
}
|
||||
|
||||
const verdictConfig: Record<
|
||||
IssueValidationVerdict,
|
||||
{ label: string; color: string; bgColor: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
valid: {
|
||||
label: 'Valid',
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
invalid: {
|
||||
label: 'Invalid',
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10',
|
||||
icon: XCircle,
|
||||
},
|
||||
needs_clarification: {
|
||||
label: 'Needs Clarification',
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-500/10',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const confidenceConfig: Record<IssueValidationConfidence, { label: string; color: string }> = {
|
||||
high: { label: 'High Confidence', color: 'text-green-500' },
|
||||
medium: { label: 'Medium Confidence', color: 'text-yellow-500' },
|
||||
low: { label: 'Low Confidence', color: 'text-orange-500' },
|
||||
};
|
||||
|
||||
const complexityConfig: Record<IssueComplexity, { label: string; color: string }> = {
|
||||
trivial: { label: 'Trivial', color: 'text-green-500' },
|
||||
simple: { label: 'Simple', color: 'text-blue-500' },
|
||||
moderate: { label: 'Moderate', color: 'text-yellow-500' },
|
||||
complex: { label: 'Complex', color: 'text-orange-500' },
|
||||
very_complex: { label: 'Very Complex', color: 'text-red-500' },
|
||||
};
|
||||
|
||||
export function ValidationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
issue,
|
||||
validationResult,
|
||||
onConvertToTask,
|
||||
}: ValidationDialogProps) {
|
||||
if (!issue) return null;
|
||||
|
||||
const handleConvertToTask = () => {
|
||||
if (validationResult && onConvertToTask) {
|
||||
onConvertToTask(issue, validationResult);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Issue Validation Result</DialogTitle>
|
||||
<DialogDescription>
|
||||
#{issue.number}: {issue.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{validationResult ? (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Verdict Badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{(() => {
|
||||
const config = verdictConfig[validationResult.verdict];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<>
|
||||
<div className={cn('p-2 rounded-lg', config.bgColor)}>
|
||||
<Icon className={cn('h-6 w-6', config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('text-lg font-semibold', config.color)}>{config.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
confidenceConfig[validationResult.confidence].color
|
||||
)}
|
||||
>
|
||||
{confidenceConfig[validationResult.confidence].label}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{validationResult.estimatedComplexity && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Estimated Complexity</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
complexityConfig[validationResult.estimatedComplexity].color
|
||||
)}
|
||||
>
|
||||
{complexityConfig[validationResult.estimatedComplexity].label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bug Confirmed Badge */}
|
||||
{validationResult.bugConfirmed && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-red-500">Bug Confirmed in Codebase</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
Analysis
|
||||
</h4>
|
||||
<div className="bg-muted/30 p-3 rounded-lg border border-border">
|
||||
<Markdown>{validationResult.reasoning}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Files */}
|
||||
{validationResult.relatedFiles && validationResult.relatedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
Related Files
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{validationResult.relatedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm font-mono bg-muted/50 px-2 py-1 rounded text-muted-foreground"
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Fix */}
|
||||
{validationResult.suggestedFix && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Suggested Approach</h4>
|
||||
<div className="bg-muted/30 p-3 rounded-lg border border-border">
|
||||
<Markdown>{validationResult.suggestedFix}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing Info (for needs_clarification) */}
|
||||
{validationResult.missingInfo && validationResult.missingInfo.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
Missing Information
|
||||
</h4>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
{validationResult.missingInfo.map((info, index) => (
|
||||
<li key={index} className="text-sm text-muted-foreground">
|
||||
{info}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">No validation result available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{validationResult?.verdict === 'valid' && onConvertToTask && (
|
||||
<Button onClick={handleConvertToTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Convert to Task
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useGithubIssues } from './use-github-issues';
|
||||
export { useIssueValidation } from './use-issue-validation';
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
export function useGithubIssues() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
|
||||
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchIssues = useCallback(async () => {
|
||||
if (!currentProject?.path) {
|
||||
if (isMountedRef.current) {
|
||||
setError('No project selected');
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isMountedRef.current) {
|
||||
setError(null);
|
||||
}
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.listIssues(currentProject.path);
|
||||
if (isMountedRef.current) {
|
||||
if (result.success) {
|
||||
setOpenIssues(result.openIssues || []);
|
||||
setClosedIssues(result.closedIssues || []);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch issues');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
console.error('[GitHubIssuesView] Error fetching issues:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
fetchIssues();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [fetchIssues]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (isMountedRef.current) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
|
||||
return {
|
||||
openIssues,
|
||||
closedIssues,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
getElectronAPI,
|
||||
GitHubIssue,
|
||||
IssueValidationResult,
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
} from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { isValidationStale } from '../utils';
|
||||
|
||||
interface UseIssueValidationOptions {
|
||||
selectedIssue: GitHubIssue | null;
|
||||
showValidationDialog: boolean;
|
||||
onValidationResultChange: (result: IssueValidationResult | null) => void;
|
||||
onShowValidationDialogChange: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function useIssueValidation({
|
||||
selectedIssue,
|
||||
showValidationDialog,
|
||||
onValidationResultChange,
|
||||
onShowValidationDialogChange,
|
||||
}: UseIssueValidationOptions) {
|
||||
const { currentProject, validationModel, muteDoneSound } = useAppStore();
|
||||
const [validatingIssues, setValidatingIssues] = useState<Set<number>>(new Set());
|
||||
const [cachedValidations, setCachedValidations] = useState<Map<number, StoredValidation>>(
|
||||
new Map()
|
||||
);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
// Refs for stable event handler (avoids re-subscribing on state changes)
|
||||
const selectedIssueRef = useRef<GitHubIssue | null>(null);
|
||||
const showValidationDialogRef = useRef(false);
|
||||
|
||||
// Keep refs in sync with state for stable event handler
|
||||
useEffect(() => {
|
||||
selectedIssueRef.current = selectedIssue;
|
||||
}, [selectedIssue]);
|
||||
|
||||
useEffect(() => {
|
||||
showValidationDialogRef.current = showValidationDialog;
|
||||
}, [showValidationDialog]);
|
||||
|
||||
// Load cached validations on mount
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadCachedValidations = async () => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.getValidations) {
|
||||
const result = await api.github.getValidations(currentProject.path);
|
||||
if (isMounted && result.success && result.validations) {
|
||||
const map = new Map<number, StoredValidation>();
|
||||
for (const v of result.validations) {
|
||||
map.set(v.issueNumber, v);
|
||||
}
|
||||
setCachedValidations(map);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
console.error('[GitHubIssuesView] Failed to load cached validations:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCachedValidations();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Load running validations on mount (restore validatingIssues state)
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadRunningValidations = async () => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.getValidationStatus) {
|
||||
const result = await api.github.getValidationStatus(currentProject.path);
|
||||
if (isMounted && result.success && result.runningIssues) {
|
||||
setValidatingIssues(new Set(result.runningIssues));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
console.error('[GitHubIssuesView] Failed to load running validations:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadRunningValidations();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Subscribe to validation events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.github?.onValidationEvent) return;
|
||||
|
||||
const handleValidationEvent = (event: IssueValidationEvent) => {
|
||||
// Only handle events for current project
|
||||
if (event.projectPath !== currentProject?.path) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'issue_validation_start':
|
||||
setValidatingIssues((prev) => new Set([...prev, event.issueNumber]));
|
||||
break;
|
||||
|
||||
case 'issue_validation_complete':
|
||||
setValidatingIssues((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(event.issueNumber);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Update cached validations (use event.model to avoid stale closure race condition)
|
||||
setCachedValidations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(event.issueNumber, {
|
||||
issueNumber: event.issueNumber,
|
||||
issueTitle: event.issueTitle,
|
||||
validatedAt: new Date().toISOString(),
|
||||
model: event.model,
|
||||
result: event.result,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
// Show toast notification
|
||||
toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, {
|
||||
description:
|
||||
event.result.verdict === 'valid'
|
||||
? 'Issue is ready to be converted to a task'
|
||||
: event.result.verdict === 'invalid'
|
||||
? 'Issue may have problems'
|
||||
: 'Issue needs clarification',
|
||||
});
|
||||
|
||||
// Play audio notification (if not muted)
|
||||
if (!muteDoneSound) {
|
||||
try {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio('/sounds/ding.mp3');
|
||||
}
|
||||
audioRef.current.play().catch(() => {
|
||||
// Audio play might fail due to browser restrictions
|
||||
});
|
||||
} catch {
|
||||
// Ignore audio errors
|
||||
}
|
||||
}
|
||||
|
||||
// If validation dialog is open for this issue, update the result
|
||||
if (
|
||||
selectedIssueRef.current?.number === event.issueNumber &&
|
||||
showValidationDialogRef.current
|
||||
) {
|
||||
onValidationResultChange(event.result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'issue_validation_error':
|
||||
setValidatingIssues((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(event.issueNumber);
|
||||
return next;
|
||||
});
|
||||
toast.error(`Validation failed for issue #${event.issueNumber}`, {
|
||||
description: event.error,
|
||||
});
|
||||
if (
|
||||
selectedIssueRef.current?.number === event.issueNumber &&
|
||||
showValidationDialogRef.current
|
||||
) {
|
||||
onShowValidationDialogChange(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = api.github.onValidationEvent(handleValidationEvent);
|
||||
return () => unsubscribe();
|
||||
}, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]);
|
||||
|
||||
// Cleanup audio element on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleValidateIssue = useCallback(
|
||||
async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => {
|
||||
const { forceRevalidate = false } = options;
|
||||
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already validating this issue
|
||||
if (validatingIssues.has(issue.number)) {
|
||||
toast.info(`Validation already in progress for issue #${issue.number}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for cached result - if fresh, show it directly (unless force revalidate)
|
||||
const cached = cachedValidations.get(issue.number);
|
||||
if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) {
|
||||
// Show cached result directly
|
||||
onValidationResultChange(cached.result);
|
||||
onShowValidationDialogChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start async validation in background (no dialog - user will see badge when done)
|
||||
toast.info(`Starting validation for issue #${issue.number}`, {
|
||||
description: 'You will be notified when the analysis is complete',
|
||||
});
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.validateIssue) {
|
||||
const result = await api.github.validateIssue(
|
||||
currentProject.path,
|
||||
{
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
issueBody: issue.body || '',
|
||||
issueLabels: issue.labels.map((l) => l.name),
|
||||
},
|
||||
validationModel
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to start validation');
|
||||
}
|
||||
// On success, the result will come through the event stream
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GitHubIssuesView] Validation error:', err);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
|
||||
}
|
||||
},
|
||||
[
|
||||
currentProject?.path,
|
||||
validatingIssues,
|
||||
cachedValidations,
|
||||
validationModel,
|
||||
onValidationResultChange,
|
||||
onShowValidationDialogChange,
|
||||
]
|
||||
);
|
||||
|
||||
// View cached validation result
|
||||
const handleViewCachedValidation = useCallback(
|
||||
async (issue: GitHubIssue) => {
|
||||
const cached = cachedValidations.get(issue.number);
|
||||
if (cached) {
|
||||
onValidationResultChange(cached.result);
|
||||
onShowValidationDialogChange(true);
|
||||
|
||||
// Mark as viewed if not already viewed
|
||||
if (!cached.viewedAt && currentProject?.path) {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.markValidationViewed) {
|
||||
await api.github.markValidationViewed(currentProject.path, issue.number);
|
||||
// Update local state
|
||||
setCachedValidations((prev) => {
|
||||
const next = new Map(prev);
|
||||
const updated = prev.get(issue.number);
|
||||
if (updated) {
|
||||
next.set(issue.number, {
|
||||
...updated,
|
||||
viewedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
cachedValidations,
|
||||
currentProject?.path,
|
||||
onValidationResultChange,
|
||||
onShowValidationDialogChange,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
validatingIssues,
|
||||
cachedValidations,
|
||||
handleValidateIssue,
|
||||
handleViewCachedValidation,
|
||||
};
|
||||
}
|
||||
28
apps/ui/src/components/views/github-issues-view/types.ts
Normal file
28
apps/ui/src/components/views/github-issues-view/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||
|
||||
export interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onOpenExternal: () => void;
|
||||
formatDate: (date: string) => string;
|
||||
/** Cached validation for this issue (if any) */
|
||||
cachedValidation?: StoredValidation | null;
|
||||
/** Whether validation is currently running for this issue */
|
||||
isValidating?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueDetailPanelProps {
|
||||
issue: GitHubIssue;
|
||||
validatingIssues: Set<number>;
|
||||
cachedValidations: Map<number, StoredValidation>;
|
||||
onValidateIssue: (
|
||||
issue: GitHubIssue,
|
||||
options?: { showDialog?: boolean; forceRevalidate?: boolean }
|
||||
) => Promise<void>;
|
||||
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
|
||||
onOpenInGitHub: (url: string) => void;
|
||||
onClose: () => void;
|
||||
onShowRevalidateConfirm: () => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
33
apps/ui/src/components/views/github-issues-view/utils.ts
Normal file
33
apps/ui/src/components/views/github-issues-view/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { IssueComplexity } from '@/lib/electron';
|
||||
import { VALIDATION_STALENESS_HOURS } from './constants';
|
||||
|
||||
/**
|
||||
* Map issue complexity to feature priority.
|
||||
* Lower complexity issues get higher priority (1 = high, 2 = medium).
|
||||
*/
|
||||
export function getFeaturePriority(complexity: IssueComplexity | undefined): number {
|
||||
switch (complexity) {
|
||||
case 'trivial':
|
||||
case 'simple':
|
||||
return 1; // High priority for easy wins
|
||||
case 'moderate':
|
||||
case 'complex':
|
||||
case 'very_complex':
|
||||
default:
|
||||
return 2; // Medium priority for larger efforts
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidationStale(validatedAt: string): boolean {
|
||||
const hoursSinceValidation = (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60);
|
||||
return hoursSinceValidation > VALIDATION_STALENESS_HOURS;
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export function SettingsView() {
|
||||
setDefaultAIProfileId,
|
||||
aiProfiles,
|
||||
apiKeys,
|
||||
validationModel,
|
||||
setValidationModel,
|
||||
} = useAppStore();
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
@@ -134,6 +136,7 @@ export function SettingsView() {
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
defaultAIProfileId={defaultAIProfileId}
|
||||
aiProfiles={aiProfiles}
|
||||
validationModel={validationModel}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
@@ -141,6 +144,7 @@ export function SettingsView() {
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
onDefaultAIProfileIdChange={setDefaultAIProfileId}
|
||||
onValidationModelChange={setValidationModel}
|
||||
/>
|
||||
);
|
||||
case 'danger':
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
User,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { AIProfile } from '@/store/app-store';
|
||||
import type { AgentModel } from '@automaker/types';
|
||||
|
||||
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
@@ -34,6 +36,7 @@ interface FeatureDefaultsSectionProps {
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultAIProfileId: string | null;
|
||||
aiProfiles: AIProfile[];
|
||||
validationModel: AgentModel;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
@@ -41,6 +44,7 @@ interface FeatureDefaultsSectionProps {
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
onDefaultAIProfileIdChange: (value: string | null) => void;
|
||||
onValidationModelChange: (value: AgentModel) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
@@ -52,6 +56,7 @@ export function FeatureDefaultsSection({
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
validationModel,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
@@ -59,6 +64,7 @@ export function FeatureDefaultsSection({
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
onDefaultAIProfileIdChange,
|
||||
onValidationModelChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
// Find the selected profile name for display
|
||||
const selectedProfile = defaultAIProfileId
|
||||
@@ -227,6 +233,45 @@ export function FeatureDefaultsSection({
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Issue Validation Model */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-purple-500/10">
|
||||
<Sparkles className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Issue Validation Model</Label>
|
||||
<Select
|
||||
value={validationModel}
|
||||
onValueChange={(v: string) => onValidationModelChange(v as AgentModel)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-8" data-testid="validation-model-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="opus">
|
||||
<span>Opus</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-1">(Default)</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="sonnet">
|
||||
<span>Sonnet</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="haiku">
|
||||
<span>Haiku</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Model used for validating GitHub issues. Opus provides the most thorough analysis,
|
||||
while Haiku is faster and more cost-effective.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Profiles Only Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
|
||||
Reference in New Issue
Block a user