Merge branch 'AutoMaker-Org:main' into claude/task-dependency-graph-iPz1k

This commit is contained in:
James Botwina
2025-12-22 14:23:18 -05:00
committed by GitHub
44 changed files with 2928 additions and 446 deletions

View File

@@ -1,5 +1,5 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
@@ -20,7 +20,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center'
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
// Add left padding on macOS to avoid overlapping with traffic light buttons
isMac && 'pt-4 pl-20'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />

View File

@@ -51,7 +51,9 @@ export function SidebarNavigation({
return (
<button
key={item.id}
onClick={() => navigate({ to: `/${item.id}` as const })}
onClick={() => {
navigate({ to: `/${item.id}` as const });
}}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',

View File

@@ -1,9 +1,20 @@
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react';
import {
FileText,
LayoutGrid,
Bot,
BookOpen,
UserCircle,
Terminal,
CircleDot,
GitPullRequest,
Zap,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
interface UseNavigationProps {
shortcuts: {
@@ -51,6 +62,30 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
useEffect(() => {
async function checkGitHubRemote() {
if (!currentProject?.path) {
setHasGitHubRemote(false);
return;
}
try {
const api = getElectronAPI();
if (api.github) {
const result = await api.github.checkRemote(currentProject.path);
setHasGitHubRemote(result.success && result.hasGitHubRemote === true);
}
} catch {
setHasGitHubRemote(false);
}
}
checkGitHubRemote();
}, [currentProject?.path]);
// Build navigation sections
const navSections: NavSection[] = useMemo(() => {
const allToolsItems: NavItem[] = [
@@ -114,7 +149,7 @@ export function useNavigation({
});
}
return [
const sections: NavSection[] = [
{
label: 'Project',
items: projectItems,
@@ -124,7 +159,28 @@ export function useNavigation({
items: visibleToolsItems,
},
];
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
// Add GitHub section if project has a GitHub remote
if (hasGitHubRemote) {
sections.push({
label: 'GitHub',
items: [
{
id: 'github-issues',
label: 'Issues',
icon: CircleDot,
},
{
id: 'github-prs',
label: 'Pull Requests',
icon: GitPullRequest,
},
],
});
}
return sections;
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {

View File

@@ -40,7 +40,7 @@ export function useProjectCreation({
// Write initial app_spec.txt with proper XML structure
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
const api = getElectronAPI();
await api.fs.writeFile(
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
@@ -103,7 +103,7 @@ export function useProjectCreation({
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.fs.createFolder(projectPath);
await api.mkdir(projectPath);
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
@@ -127,16 +127,19 @@ export function useProjectCreation({
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone template repository
await api.git.clone(template.githubUrl, projectPath);
const cloneResult = await api.templates.clone(template.repoUrl, projectName, parentDir);
if (!cloneResult.success) {
throw new Error(cloneResult.error || 'Failed to clone template');
}
const projectPath = cloneResult.projectPath!;
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write app_spec.txt with template-specific info
await api.fs.writeFile(
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
@@ -196,16 +199,19 @@ export function useProjectCreation({
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone custom repository
await api.git.clone(repoUrl, projectPath);
const cloneResult = await api.templates.clone(repoUrl, projectName, parentDir);
if (!cloneResult.success) {
throw new Error(cloneResult.error || 'Failed to clone repository');
}
const projectPath = cloneResult.projectPath!;
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write app_spec.txt with custom URL info
await api.fs.writeFile(
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>

View File

@@ -426,7 +426,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
interface DetectedFeature {
category: string;
description: string;
steps: string[];
passes: boolean;
}
@@ -453,11 +452,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Testing',
description: 'Automated test suite',
steps: [
'Step 1: Tests directory exists',
'Step 2: Test files are present',
'Step 3: Run test suite',
],
passes: true,
});
}
@@ -471,11 +465,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'UI/Design',
description: 'Component-based UI architecture',
steps: [
'Step 1: Components directory exists',
'Step 2: UI components are defined',
'Step 3: Components are reusable',
],
passes: true,
});
}
@@ -485,11 +474,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Project Structure',
description: 'Organized source code structure',
steps: [
'Step 1: Source directory exists',
'Step 2: Code is properly organized',
'Step 3: Follows best practices',
],
passes: true,
});
}
@@ -504,11 +488,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Frontend',
description: 'React-based user interface',
steps: [
'Step 1: React is installed',
'Step 2: Components render correctly',
'Step 3: State management works',
],
passes: true,
});
}
@@ -517,11 +496,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Framework',
description: 'Next.js framework integration',
steps: [
'Step 1: Next.js is configured',
'Step 2: Pages/routes are defined',
'Step 3: Server-side rendering works',
],
passes: true,
});
}
@@ -536,11 +510,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Developer Experience',
description: 'TypeScript type safety',
steps: [
'Step 1: TypeScript is configured',
'Step 2: Type definitions exist',
'Step 3: Code compiles without errors',
],
passes: true,
});
}
@@ -550,11 +519,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'UI/Design',
description: 'Tailwind CSS styling',
steps: [
'Step 1: Tailwind is configured',
'Step 2: Styles are applied',
'Step 3: Responsive design works',
],
passes: true,
});
}
@@ -564,11 +528,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Developer Experience',
description: 'Code quality tools',
steps: [
'Step 1: Linter is configured',
'Step 2: Code passes lint checks',
'Step 3: Formatting is consistent',
],
passes: true,
});
}
@@ -578,11 +537,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Platform',
description: 'Electron desktop application',
steps: [
'Step 1: Electron is configured',
'Step 2: Main process runs',
'Step 3: Renderer process loads',
],
passes: true,
});
}
@@ -592,11 +546,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Testing',
description: 'Playwright end-to-end testing',
steps: [
'Step 1: Playwright is configured',
'Step 2: E2E tests are defined',
'Step 3: Tests pass successfully',
],
passes: true,
});
}
@@ -610,11 +559,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Documentation',
description: 'Project documentation',
steps: [
'Step 1: README exists',
'Step 2: Documentation is comprehensive',
'Step 3: Setup instructions are clear',
],
passes: true,
});
}
@@ -629,11 +573,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'DevOps',
description: 'CI/CD pipeline configuration',
steps: [
'Step 1: CI config exists',
'Step 2: Pipeline runs on push',
'Step 3: Automated checks pass',
],
passes: true,
});
}
@@ -647,11 +586,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Backend',
description: 'API endpoints',
steps: [
'Step 1: API routes are defined',
'Step 2: Endpoints respond correctly',
'Step 3: Error handling is implemented',
],
passes: true,
});
}
@@ -669,11 +603,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Architecture',
description: 'State management system',
steps: [
'Step 1: Store is configured',
'Step 2: State updates correctly',
'Step 3: Components access state',
],
passes: true,
});
}
@@ -683,11 +612,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Configuration',
description: 'Project configuration files',
steps: [
'Step 1: Config files exist',
'Step 2: Configuration is valid',
'Step 3: Build process works',
],
passes: true,
});
}
@@ -700,11 +624,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
detectedFeatures.push({
category: 'Core',
description: 'Basic project structure',
steps: [
'Step 1: Project directory exists',
'Step 2: Files are present',
'Step 3: Project can be loaded',
],
passes: true,
});
}
@@ -719,7 +638,6 @@ ${Object.entries(projectAnalysis.filesByExtension)
id: crypto.randomUUID(),
category: detectedFeature.category,
description: detectedFeature.description,
steps: detectedFeature.steps,
status: 'backlog',
});
}

View File

@@ -436,9 +436,9 @@ export function BoardView() {
// Create the feature
const featureData = {
title: `Address PR #${prNumber} Review Comments`,
category: 'PR Review',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
@@ -478,9 +478,9 @@ export function BoardView() {
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts`,
category: 'Maintenance',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,

View File

@@ -128,116 +128,130 @@ export function AgentInfoPanel({
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
return (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
<>
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
)}
</div>
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate font-medium">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
{agentInfo.currentPhase && (
<div
className={cn(
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
</div>
)}
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</div>
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
)}
</>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate font-medium">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
)}
</div>
)}
</>
)}
</div>
{/* SummaryDialog must be rendered alongside the expand button */}
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
</>
);
}

View File

@@ -1,17 +1,12 @@
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
useWorktrees: boolean;
showSteps: boolean;
}
export function CardContentSections({
feature,
useWorktrees,
showSteps,
}: CardContentSectionsProps) {
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
@@ -50,30 +45,6 @@ export function CardContentSections({
</div>
);
})()}
{/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1.5">
{feature.steps.slice(0, 3).map((step, index) => (
<div
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === 'verified' ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-5">
+{feature.steps.length - 3} more
</p>
)}
</div>
)}
</>
);
}

View File

@@ -61,9 +61,7 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
const { useWorktrees } = useAppStore();
const isDraggable =
feature.status === 'backlog' ||
@@ -152,7 +150,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
{/* Agent Info Panel */}
<AgentInfoPanel

View File

@@ -62,7 +62,6 @@ interface AddFeatureDialogProps {
title: string;
category: string;
description: string;
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
@@ -105,7 +104,6 @@ export function AddFeatureDialog({
title: '',
category: '',
description: '',
steps: [''],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
@@ -193,7 +191,6 @@ export function AddFeatureDialog({
title: newFeature.title,
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
@@ -211,7 +208,6 @@ export function AddFeatureDialog({
title: '',
category: '',
description: '',
steps: [''],
images: [],
imagePaths: [],
textFilePaths: [],
@@ -502,8 +498,6 @@ export function AddFeatureDialog({
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
steps={newFeature.steps}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
</TabsContent>
</Tabs>

View File

@@ -64,7 +64,6 @@ interface EditFeatureDialogProps {
title: string;
category: string;
description: string;
steps: string[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
@@ -165,7 +164,6 @@ export function EditFeatureDialog({
title: editingFeature.title ?? '',
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests ?? false,
model: selectedModel,
thinkingLevel: normalizedThinking,
@@ -491,8 +489,6 @@ export function EditFeatureDialog({
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
steps={editingFeature.steps}
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
testIdPrefix="edit"
/>
</TabsContent>

View File

@@ -245,7 +245,6 @@ export function FeatureSuggestionsDialog({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: s.steps,
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
@@ -453,23 +452,9 @@ export function FeatureSuggestionsDialog({
{suggestion.description}
</Label>
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
)}
{suggestion.steps.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Implementation Steps:
</p>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
{suggestion.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
{isExpanded && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
</div>
)}
</div>

View File

@@ -89,7 +89,6 @@ export function useBoardActions({
title: string;
category: string;
description: string;
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
@@ -208,7 +207,6 @@ export function useBoardActions({
title: string;
category: string;
description: string;
steps: string[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;

View File

@@ -1,36 +1,20 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FlaskConical, Plus } from 'lucide-react';
import { FlaskConical } from 'lucide-react';
interface TestingTabContentProps {
skipTests: boolean;
onSkipTestsChange: (skipTests: boolean) => void;
steps: string[];
onStepsChange: (steps: string[]) => void;
testIdPrefix?: string;
}
export function TestingTabContent({
skipTests,
onSkipTestsChange,
steps,
onStepsChange,
testIdPrefix = '',
}: TestingTabContentProps) {
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : 'skip-tests';
const handleStepChange = (index: number, value: string) => {
const newSteps = [...steps];
newSteps[index] = value;
onStepsChange(newSteps);
};
const handleAddStep = () => {
onStepsChange([...steps, '']);
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
@@ -48,37 +32,9 @@ export function TestingTabContent({
</div>
</div>
<p className="text-xs text-muted-foreground">
When enabled, this feature will use automated TDD. When disabled, it will require manual
verification.
When enabled, the agent will use Playwright to verify the feature works correctly before
marking it as verified. When disabled, manual verification will be required.
</p>
{/* Verification Steps - Only shown when skipTests is enabled */}
{skipTests && (
<div className="space-y-2 pt-2 border-t border-border">
<Label>Verification Steps</Label>
<p className="text-xs text-muted-foreground mb-2">
Add manual steps to verify this feature works correctly.
</p>
{steps.map((step, index) => (
<Input
key={index}
value={step}
placeholder={`Verification step ${index + 1}`}
onChange={(e) => handleStepChange(index, e.target.value)}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddStep}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}add-step-button`}
>
<Plus className="w-4 h-4 mr-2" />
Add Verification Step
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useState, useEffect, useCallback } from 'react';
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/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 fetchIssues = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
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]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchIssues();
}, [fetchIssues]);
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',
});
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
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>
);
}
const totalIssues = openIssues.length + closedIssues.length;
return (
<div className="flex-1 flex overflow-hidden">
{/* Issues List */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedIssue ? 'w-80' : 'flex-1'
)}
>
{/* 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>
{/* Issues List */}
<div className="flex-1 overflow-auto">
{totalIssues === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<CircleDot className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-medium mb-2">No Issues</h2>
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
</div>
) : (
<div className="divide-y divide-border">
{/* Open Issues */}
{openIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}
isSelected={selectedIssue?.number === issue.number}
onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate}
/>
))}
{/* Closed Issues Section */}
{closedIssues.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Closed Issues ({closedIssues.length})
</div>
{closedIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}
isSelected={selectedIssue?.number === issue.number}
onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate}
/>
))}
</>
)}
</div>
)}
</div>
</div>
{/* 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 ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="whitespace-pre-wrap text-sm">{selectedIssue.body}</div>
</div>
) : (
<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" />
)}
<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>
{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();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,421 @@
import { useState, useEffect, useCallback } from 'react';
import {
GitPullRequest,
Loader2,
RefreshCw,
ExternalLink,
GitMerge,
Circle,
X,
AlertCircle,
} from 'lucide-react';
import { getElectronAPI, GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function GitHubPRsView() {
const [openPRs, setOpenPRs] = useState<GitHubPR[]>([]);
const [mergedPRs, setMergedPRs] = useState<GitHubPR[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
const { currentProject } = useAppStore();
const fetchPRs = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listPRs(currentProject.path);
if (result.success) {
setOpenPRs(result.openPRs || []);
setMergedPRs(result.mergedPRs || []);
} else {
setError(result.error || 'Failed to fetch pull requests');
}
}
} catch (err) {
console.error('[GitHubPRsView] Error fetching PRs:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
useEffect(() => {
fetchPRs();
}, [fetchPRs]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchPRs();
}, [fetchPRs]);
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 getReviewStatus = (pr: GitHubPR) => {
if (pr.isDraft) return { label: 'Draft', color: 'text-muted-foreground', bg: 'bg-muted' };
switch (pr.reviewDecision) {
case 'APPROVED':
return { label: 'Approved', color: 'text-green-500', bg: 'bg-green-500/10' };
case 'CHANGES_REQUESTED':
return { label: 'Changes requested', color: 'text-orange-500', bg: 'bg-orange-500/10' };
case 'REVIEW_REQUIRED':
return { label: 'Review required', color: 'text-yellow-500', bg: 'bg-yellow-500/10' };
default:
return null;
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
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">
<GitPullRequest className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</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>
);
}
const totalPRs = openPRs.length + mergedPRs.length;
return (
<div className="flex-1 flex overflow-hidden">
{/* PR List */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedPR ? 'w-80' : 'flex-1'
)}
>
{/* 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-blue-500/10">
<GitPullRequest className="h-5 w-5 text-blue-500" />
</div>
<div>
<h1 className="text-lg font-bold">Pull Requests</h1>
<p className="text-xs text-muted-foreground">
{totalPRs === 0
? 'No pull requests found'
: `${openPRs.length} open, ${mergedPRs.length} merged`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
{/* PR List */}
<div className="flex-1 overflow-auto">
{totalPRs === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<GitPullRequest className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-medium mb-2">No Pull Requests</h2>
<p className="text-sm text-muted-foreground">
This repository has no pull requests yet.
</p>
</div>
) : (
<div className="divide-y divide-border">
{/* Open PRs */}
{openPRs.map((pr) => (
<PRRow
key={pr.number}
pr={pr}
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
))}
{/* Merged PRs Section */}
{mergedPRs.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Merged ({mergedPRs.length})
</div>
{mergedPRs.map((pr) => (
<PRRow
key={pr.number}
pr={pr}
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
))}
</>
)}
</div>
)}
</div>
</div>
{/* PR Detail Panel */}
{selectedPR && (
<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">
{selectedPR.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 flex-shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{selectedPR.number} {selectedPR.title}
</span>
{selectedPR.isDraft && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
Draft
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedPR.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* PR Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
selectedPR.state === 'MERGED'
? 'bg-purple-500/10 text-purple-500'
: selectedPR.isDraft
? 'bg-muted text-muted-foreground'
: 'bg-green-500/10 text-green-500'
)}
>
{selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
</span>
{getReviewStatus(selectedPR) && (
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
getReviewStatus(selectedPR)!.bg,
getReviewStatus(selectedPR)!.color
)}
>
{getReviewStatus(selectedPR)!.label}
</span>
)}
<span>
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
</span>
</div>
{/* Branch info */}
{selectedPR.headRefName && (
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-muted-foreground">Branch:</span>
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{selectedPR.headRefName}
</span>
</div>
)}
{/* Labels */}
{selectedPR.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedPR.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 */}
{selectedPR.body ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<div className="whitespace-pre-wrap text-sm">{selectedPR.body}</div>
</div>
) : (
<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 code changes, comments, and reviews on GitHub.
</p>
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full PR on GitHub
</Button>
</div>
</div>
</div>
)}
</div>
);
}
interface PRRowProps {
pr: GitHubPR;
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
formatDate: (date: string) => string;
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
}
function PRRow({
pr,
isSelected,
onClick,
onOpenExternal,
formatDate,
getReviewStatus,
}: PRRowProps) {
const reviewStatus = getReviewStatus(pr);
return (
<div
className={cn(
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{pr.state === 'MERGED' ? (
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-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">{pr.title}</span>
{pr.isDraft && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground flex-shrink-0">
Draft
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{pr.number} opened {formatDate(pr.createdAt)} by {pr.author.login}
</span>
{pr.headRefName && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-1 rounded">
{pr.headRefName}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Review Status */}
{reviewStatus && (
<span
className={cn(
'px-1.5 py-0.5 text-[10px] font-medium rounded',
reviewStatus.bg,
reviewStatus.color
)}
>
{reviewStatus.label}
</span>
)}
{/* Labels */}
{pr.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();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -345,11 +345,6 @@ export function InterviewView() {
category: 'Core',
description: 'Initial project setup',
status: 'backlog' as const,
steps: [
'Step 1: Review app_spec.txt',
'Step 2: Set up development environment',
'Step 3: Start implementing features',
],
skipTests: true,
};

View File

@@ -92,12 +92,77 @@ export interface RunningAgentsAPI {
getAll: () => Promise<RunningAgentsResult>;
}
// GitHub types
export interface GitHubLabel {
name: string;
color: string;
}
export interface GitHubAuthor {
login: string;
}
export interface GitHubIssue {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
body: string;
}
export interface GitHubPR {
number: number;
title: string;
state: string;
author: GitHubAuthor;
createdAt: string;
labels: GitHubLabel[];
url: string;
isDraft: boolean;
headRefName: string;
reviewDecision: string | null;
mergeable: string;
body: string;
}
export interface GitHubRemoteStatus {
hasGitHubRemote: boolean;
remoteUrl: string | null;
owner: string | null;
repo: string | null;
}
export interface GitHubAPI {
checkRemote: (projectPath: string) => Promise<{
success: boolean;
hasGitHubRemote?: boolean;
remoteUrl?: string | null;
owner?: string | null;
repo?: string | null;
error?: string;
}>;
listIssues: (projectPath: string) => Promise<{
success: boolean;
openIssues?: GitHubIssue[];
closedIssues?: GitHubIssue[];
error?: string;
}>;
listPRs: (projectPath: string) => Promise<{
success: boolean;
openPRs?: GitHubPR[];
mergedPRs?: GitHubPR[];
error?: string;
}>;
}
// Feature Suggestions types
export interface FeatureSuggestion {
id: string;
category: string;
description: string;
steps: string[];
priority: number;
reasoning: string;
}
@@ -326,6 +391,7 @@ export interface ElectronAPI {
autoMode?: AutoModeAPI;
features?: FeaturesAPI;
runningAgents?: RunningAgentsAPI;
github?: GitHubAPI;
enhancePrompt?: {
enhance: (
originalText: string,
@@ -873,6 +939,9 @@ const getMockElectronAPI = (): ElectronAPI => {
// Mock Running Agents API
runningAgents: createMockRunningAgentsAPI(),
// Mock GitHub API
github: createMockGitHubAPI(),
// Mock Claude API
claude: {
getUsage: async () => {
@@ -1975,12 +2044,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'Code Smell',
description: 'Extract duplicate validation logic into reusable utility',
steps: [
'Identify all files with similar validation patterns',
'Create a validation utilities module',
'Replace duplicate code with utility calls',
'Add unit tests for the new utilities',
],
priority: 1,
reasoning: 'Reduces code duplication and improves maintainability',
},
@@ -1988,12 +2051,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Complexity',
description: 'Break down large handleSubmit function into smaller functions',
steps: [
'Identify the handleSubmit function in form components',
'Extract validation logic into separate function',
'Extract API call logic into separate function',
'Extract success/error handling into separate functions',
],
priority: 2,
reasoning: 'Function is too long and handles multiple responsibilities',
},
@@ -2001,12 +2058,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Architecture',
description: 'Move business logic out of React components into hooks',
steps: [
'Identify business logic in component files',
'Create custom hooks for reusable logic',
'Update components to use the new hooks',
'Add tests for the extracted hooks',
],
priority: 3,
reasoning: 'Improves separation of concerns and testability',
},
@@ -2019,12 +2070,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'High',
description: 'Sanitize user input before rendering to prevent XSS',
steps: [
'Audit all places where user input is rendered',
'Implement input sanitization using DOMPurify',
'Add Content-Security-Policy headers',
'Test with common XSS payloads',
],
priority: 1,
reasoning: 'User input is rendered without proper sanitization',
},
@@ -2032,12 +2077,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Medium',
description: 'Add rate limiting to authentication endpoints',
steps: [
'Implement rate limiting middleware',
'Configure limits for login attempts',
'Add account lockout after failed attempts',
'Log suspicious activity',
],
priority: 2,
reasoning: 'Prevents brute force attacks on authentication',
},
@@ -2045,12 +2084,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Low',
description: 'Remove sensitive information from error messages',
steps: [
'Audit error handling in API routes',
'Create generic error messages for production',
'Log detailed errors server-side only',
'Implement proper error boundaries',
],
priority: 3,
reasoning: 'Error messages may leak implementation details',
},
@@ -2063,12 +2096,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'Rendering',
description: 'Add React.memo to prevent unnecessary re-renders',
steps: [
'Profile component renders with React DevTools',
'Identify components that re-render unnecessarily',
'Wrap pure components with React.memo',
'Use useCallback for event handlers passed as props',
],
priority: 1,
reasoning: "Components re-render even when props haven't changed",
},
@@ -2076,12 +2103,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Bundle Size',
description: 'Implement code splitting for route components',
steps: [
'Use React.lazy for route components',
'Add Suspense boundaries with loading states',
'Analyze bundle with webpack-bundle-analyzer',
'Consider dynamic imports for heavy libraries',
],
priority: 2,
reasoning: 'Initial bundle is larger than necessary',
},
@@ -2089,12 +2110,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Caching',
description: 'Add memoization for expensive computations',
steps: [
'Identify expensive calculations in render',
'Use useMemo for derived data',
'Consider using react-query for server state',
'Add caching headers for static assets',
],
priority: 3,
reasoning: 'Expensive computations run on every render',
},
@@ -2107,12 +2122,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-0`,
category: 'User Experience',
description: 'Add dark mode toggle with system preference detection',
steps: [
'Create a ThemeProvider context to manage theme state',
'Add a toggle component in the settings or header',
'Implement CSS variables for theme colors',
'Add localStorage persistence for user preference',
],
priority: 1,
reasoning: 'Dark mode is a standard feature that improves accessibility and user comfort',
},
@@ -2120,11 +2129,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-1`,
category: 'Performance',
description: 'Implement lazy loading for heavy components',
steps: [
'Identify components that are heavy or rarely used',
'Use React.lazy() and Suspense for code splitting',
'Add loading states for lazy-loaded components',
],
priority: 2,
reasoning: 'Improves initial load time and reduces bundle size',
},
@@ -2132,12 +2136,6 @@ async function simulateSuggestionsGeneration(suggestionType: SuggestionType = 'f
id: `suggestion-${Date.now()}-2`,
category: 'Accessibility',
description: 'Add keyboard navigation support throughout the app',
steps: [
'Implement focus management for modals and dialogs',
'Add keyboard shortcuts for common actions',
'Ensure all interactive elements are focusable',
'Add ARIA labels and roles where needed',
],
priority: 3,
reasoning: 'Improves accessibility for users who rely on keyboard navigation',
},
@@ -2604,6 +2602,38 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
};
}
// Mock GitHub API implementation
function createMockGitHubAPI(): GitHubAPI {
return {
checkRemote: async (projectPath: string) => {
console.log('[Mock] Checking GitHub remote for:', projectPath);
return {
success: true,
hasGitHubRemote: false,
remoteUrl: null,
owner: null,
repo: null,
};
},
listIssues: async (projectPath: string) => {
console.log('[Mock] Listing GitHub issues for:', projectPath);
return {
success: true,
openIssues: [],
closedIssues: [],
};
},
listPRs: async (projectPath: string) => {
console.log('[Mock] Listing GitHub PRs for:', projectPath);
return {
success: true,
openPRs: [],
mergedPRs: [],
};
},
};
}
// Utility functions for project management
export interface Project {

View File

@@ -21,6 +21,9 @@ import type {
SuggestionsEvent,
SpecRegenerationEvent,
SuggestionType,
GitHubAPI,
GitHubIssue,
GitHubPR,
} from './electron';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
@@ -743,6 +746,13 @@ export class HttpApiClient implements ElectronAPI {
}> => this.get('/api/running-agents'),
};
// GitHub API
github: GitHubAPI = {
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
};
// Workspace API
workspace = {
getConfig: (): Promise<{

View File

@@ -52,3 +52,14 @@ export function pathsEqual(p1: string | undefined | null, p2: string | undefined
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}
/**
* Detect if running on macOS.
* Checks Electron process.platform first, then falls back to navigator APIs.
*/
export const isMac =
typeof process !== 'undefined' && process.platform === 'darwin'
? true
: typeof navigator !== 'undefined' &&
(/Mac/.test(navigator.userAgent) ||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));

View File

@@ -10,6 +10,7 @@ import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import http, { Server } from 'http';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
// Development environment
const isDev = !app.isPackaged;
@@ -274,12 +275,22 @@ async function startStaticServer(): Promise<void> {
* Start the backend server
*/
async function startServer(): Promise<void> {
let command: string;
// Find Node.js executable (handles desktop launcher scenarios)
const nodeResult = findNodeExecutable({
skipSearch: isDev,
logger: (msg: string) => console.log(`[Electron] ${msg}`),
});
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
if (command !== 'node' && !fs.existsSync(command)) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
let args: string[];
let serverPath: string;
if (isDev) {
command = 'node';
serverPath = path.join(__dirname, '../../server/src/index.ts');
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
@@ -302,7 +313,6 @@ async function startServer(): Promise<void> {
args = [tsxCliPath, 'watch', serverPath];
} else {
command = 'node';
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath];
@@ -315,8 +325,15 @@ async function startServer(): Promise<void> {
? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, '../../server/node_modules');
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`);
}
const env = {
...process.env,
PATH: enhancedPath,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules,
@@ -511,6 +528,16 @@ app.whenReady().then(async () => {
createWindow();
} catch (error) {
console.error('[Electron] Failed to start:', error);
const errorMessage = (error as Error).message;
const isNodeError = errorMessage.includes('Node.js');
dialog.showErrorBox(
'Automaker Failed to Start',
`The application failed to start.\n\n${errorMessage}\n\n${
isNodeError
? 'Please install Node.js from https://nodejs.org or via a package manager (Homebrew, nvm, fnm).'
: 'Please check the application logs for more details.'
}`
);
app.quit();
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { GitHubIssuesView } from '@/components/views/github-issues-view';
export const Route = createFileRoute('/github-issues')({
component: GitHubIssuesView,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { GitHubPRsView } from '@/components/views/github-prs-view';
export const Route = createFileRoute('/github-prs')({
component: GitHubPRsView,
});

View File

@@ -4,13 +4,9 @@ import type { Project, TrashedProject } from '@/lib/electron';
import type {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
AgentModel,
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
ThemeMode,
} from '@automaker/types';
// Re-export ThemeMode for convenience
@@ -245,17 +241,6 @@ export interface ChatSession {
archived: boolean;
}
// Re-export for backward compatibility
export type {
FeatureImagePath,
FeatureTextFilePath,
AgentModel,
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
};
// UI-specific: base64-encoded images (not in shared types)
export interface FeatureImage {
id: string;
@@ -265,18 +250,18 @@ export interface FeatureImage {
size: number;
}
export interface FeatureImagePath {
id: string;
path: string; // Path to the temp file
filename: string;
mimeType: string;
}
// Available models for feature execution
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
// UI-specific Feature extension with UI-only fields and stricter types
export interface Feature extends Omit<
BaseFeature,
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
> {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
images?: FeatureImage[]; // UI-specific base64 images