Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -1,11 +1,11 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Bot } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
interface BoardHeaderProps {
projectName: string;
@@ -30,6 +30,14 @@ export function BoardHeader({
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
const apiKeys = useAppStore((state) => state.apiKeys);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div>
@@ -37,6 +45,9 @@ export function BoardHeader({
<p className="text-sm text-muted-foreground">{projectName}</p>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - only show for CLI users (not API key users) */}
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{isMounted && (
<div
@@ -66,10 +77,7 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label
htmlFor="auto-mode-toggle"
className="text-sm font-medium cursor-pointer"
>
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch

View File

@@ -1,2 +1,2 @@
export { KanbanCard } from "./kanban-card";
export { KanbanColumn } from "./kanban-column";
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';

View File

@@ -0,0 +1,259 @@
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import {
AgentTaskInfo,
parseAgentContext,
formatModelName,
DEFAULT_MODEL,
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
ListTodo,
Sparkles,
Expand,
CheckCircle2,
Circle,
Loader2,
Wrench,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === 'none') return '';
const labels: Record<ThinkingLevel, string> = {
none: '',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
return labels[level];
}
interface AgentInfoPanelProps {
feature: Feature;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
}
export function AgentInfoPanel({
feature,
contextContent,
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const { kanbanCardDetailLevel } = useAppStore();
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
useEffect(() => {
const loadContext = async () => {
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
if (feature.status === 'backlog') {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
if (isCurrentAutoTask) {
const interval = setInterval(loadContext, 3000);
return () => {
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === 'backlog') {
return (
<div className="mb-3 space-y-2 overflow-hidden">
<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>
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
</span>
</div>
) : null}
</div>
</div>
);
}
// 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>
)}
</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>
</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>
);
}
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (
<>
{showAgentInfo && (
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
)}
</>
);
}

View File

@@ -0,0 +1,337 @@
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
PlayCircle,
RotateCcw,
StopCircle,
CheckCircle2,
FileText,
Eye,
Wand2,
Archive,
} from 'lucide-react';
interface CardActionsProps {
feature: Feature;
isCurrentAutoTask: boolean;
hasContext?: boolean;
shortcutKey?: string;
onEdit: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
}
export function CardActions({
feature,
isCurrentAutoTask,
hasContext,
shortcutKey,
onEdit,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onFollowUp,
onImplement,
onComplete,
onViewPlan,
onApprovePlan,
}: CardActionsProps) {
return (
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-running-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Approve Plan</span>
</Button>
)}
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-[11px] px-2 shrink-0"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Approve Plan
</Button>
)}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : hasContext && onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Resume
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{/* Logs button */}
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
</Button>
)}
{/* Complete button */}
{onComplete && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
onClick={(e) => {
e.stopPropagation();
onComplete();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`complete-${feature.id}`}
>
<Archive className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Complete</span>
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{/* Refine prompt button */}
{onFollowUp && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px] min-w-0"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Refine</span>
</Button>
)}
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
{feature.prUrl && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`mark-as-verified-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Mark as Verified
</Button>
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-backlog-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{feature.planSpec?.content && onViewPlan && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
onViewPlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-plan-${feature.id}`}
title="View Plan"
>
<Eye className="w-3 h-3" />
</Button>
)}
{onImplement && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
</Button>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
'data-testid'?: string;
title?: string;
}
/**
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({ children, className, 'data-testid': dataTestId, title }: CardBadgeProps) {
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
title={title}
>
{children}
</div>
);
}
interface CardBadgesProps {
feature: Feature;
}
export function CardBadges({ feature }: CardBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
// Status badges row (error, blocked)
const showStatusBadges =
feature.error ||
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === 'backlog');
if (!showStatusBadges) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
{/* Error badge */}
{feature.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Blocked badge */}
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold',
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
interface PriorityBadgesProps {
feature: Feature;
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
const isJustFinished = useMemo(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000;
return currentTime - finishedTime < twoMinutes;
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval') {
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000;
const timeRemaining = twoMinutes - (currentTime - finishedTime);
if (timeRemaining <= 0) {
return;
}
// eslint-disable-next-line no-undef
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}, [feature.justFinishedAt, feature.status, currentTime]);
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
if (!showPriorityBadges) {
return null;
}
return (
<div className="absolute top-2 left-2 flex items-center gap-1.5">
{/* Priority badge */}
{feature.priority && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
className={cn(
'bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5', // badge style from example
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">H</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">L</span>
)}
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? 'High Priority'
: feature.priority === 2
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished badge */}
{isJustFinished && (
<CardBadge
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
</CardBadge>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
useWorktrees: boolean;
showSteps: boolean;
}
export function CardContentSections({
feature,
useWorktrees,
showSteps,
}: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
{useWorktrees && feature.branchName && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<GitBranch className="w-3 h-3 shrink-0" />
<span className="font-mono truncate" title={feature.branchName}>
{feature.branchName}
</span>
</div>
)}
{/* PR URL Display */}
{typeof feature.prUrl === 'string' &&
/^https?:\/\//i.test(feature.prUrl) &&
(() => {
const prNumber = feature.prUrl.split('/').pop();
return (
<div className="mb-2">
<a
href={feature.prUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors"
title={feature.prUrl}
data-testid={`pr-url-${feature.id}`}
>
<GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a>
</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

@@ -0,0 +1,312 @@
import { useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
GripVertical,
Edit,
Loader2,
Trash2,
FileText,
MoreVertical,
ChevronDown,
ChevronUp,
Cpu,
} from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
interface CardHeaderProps {
feature: Feature;
isDraggable: boolean;
isCurrentAutoTask: boolean;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
}
export function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
onEdit,
onDelete,
onViewOutput,
}: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
onDelete();
};
return (
<CardHeader className="p-3 pb-2 block">
{/* Running task header */}
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
className="text-[var(--status-in-progress)] text-[10px]"
/>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-running-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-running-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</>
)}
{/* In progress header */}
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-feature-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-${feature.id}`}
className="text-xs"
>
<FileText className="w-3 h-3 mr-2" />
View Logs
</DropdownMenuItem>
)}
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
{/* Title and description */}
<div className="flex items-start gap-2">
{isDraggable && (
<div
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
{feature.title}
</CardTitle>
) : null}
<CardDescription
className={cn(
'text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground',
!isDescriptionExpanded && 'line-clamp-3'
)}
>
{feature.description || feature.summary || feature.id}
</CardDescription>
{(feature.description || feature.summary || '').length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1.5 transition-colors"
data-testid={`toggle-description-${feature.id}`}
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
<span>Less</span>
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
<span>More</span>
</>
)}
</button>
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="Delete Feature"
description="Are you sure you want to delete this feature? This action cannot be undone."
testId="delete-confirmation-dialog"
confirmTestId="confirm-delete-button"
/>
</CardHeader>
);
}

View File

@@ -0,0 +1,7 @@
export { AgentInfoPanel } from './agent-info-panel';
export { CardActions } from './card-actions';
export { CardBadges, PriorityBadges } from './card-badges';
export { CardContentSections } from './card-content-sections';
export { CardHeaderSection } from './card-header';
export { KanbanCard } from './kanban-card';
export { SummaryDialog } from './summary-dialog';

View File

@@ -0,0 +1,193 @@
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
contextContent?: string;
summary?: string;
opacity?: number;
glassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
}
export const KanbanCard = memo(function KanbanCard({
feature,
onEdit,
onDelete,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onMoveBackToInProgress: _onMoveBackToInProgress,
onFollowUp,
onImplement,
onComplete,
onViewPlan,
onApprovePlan,
hasContext,
isCurrentAutoTask,
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
const isDraggable =
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardElement = (
<Card
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Status Badges Row */}
<CardBadges feature={feature} />
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
{/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} />
{/* Card Header */}
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
/>
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
/>
{/* Actions */}
<CardActions
feature={feature}
isCurrentAutoTask={!!isCurrentAutoTask}
hasContext={hasContext}
shortcutKey={shortcutKey}
onEdit={onEdit}
onViewOutput={onViewOutput}
onVerify={onVerify}
onResume={onResume}
onForceStop={onForceStop}
onManualVerify={onManualVerify}
onFollowUp={onFollowUp}
onImplement={onImplement}
onComplete={onComplete}
onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan}
/>
</CardContent>
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
});

View File

@@ -0,0 +1,68 @@
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { Sparkles } from 'lucide-react';
interface SummaryDialogProps {
feature: Feature;
agentInfo: AgentTaskInfo | null;
summary?: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function SummaryDialog({
feature,
agentInfo,
summary,
isOpen,
onOpenChange,
}: SummaryDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
data-testid={`summary-dialog-${feature.id}`}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
Implementation Summary
</DialogTitle>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ''}
>
{(() => {
const displayText = feature.description || feature.summary || 'No description';
return displayText.length > 100 ? `${displayText.slice(0, 100)}...` : displayText;
})()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
<Markdown>
{feature.summary || summary || agentInfo?.summary || 'No summary available'}
</Markdown>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="close-summary-button"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,19 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { useAppStore } from '@/store/app-store';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
open: boolean;
@@ -26,7 +25,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw" | "changes";
type ViewMode = 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -36,13 +35,13 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [projectPath, setProjectPath] = useState<string>('');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -75,22 +74,19 @@ export function AgentOutputModal({
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
const result = await api.features.getAgentOutput(currentProject.path, featureId);
if (result.success) {
setOutput(result.content || "");
setOutput(result.content || '');
} else {
setOutput("");
setOutput('');
}
} else {
setOutput("");
setOutput('');
}
} catch (error) {
console.error("Failed to load output:", error);
setOutput("");
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
}
@@ -108,38 +104,32 @@ export function AgentOutputModal({
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
if ('featureId' in event && event.featureId !== featureId) {
return;
}
let newContent = "";
let newContent = '';
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
case 'auto_mode_progress':
newContent = event.content || '';
break;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}\n` : ""
}`;
case 'auto_mode_tool': {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
break;
case "auto_mode_phase":
}
case 'auto_mode_phase': {
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
}
case 'auto_mode_error':
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
case 'auto_mode_ultrathink_preparation': {
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
@@ -169,66 +159,74 @@ export function AgentOutputModal({
newContent = prepContent;
break;
case "planning_started":
}
case 'planning_started': {
// Show when planning mode begins
if ("mode" in event && "message" in event) {
if ('mode' in event && 'message' in event) {
const modeLabel =
event.mode === "lite"
? "Lite"
: event.mode === "spec"
? "Spec"
: "Full";
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
case "plan_approval_required":
}
case 'plan_approval_required':
// Show when plan requires approval
if ("planningMode" in event) {
if ('planningMode' in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case "plan_approved":
case 'plan_approved':
// Show when plan is manually approved
if ("hasEdits" in event) {
if ('hasEdits' in event) {
newContent = event.hasEdits
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
: `\n✅ Plan approved - continuing to implementation...\n`;
}
break;
case "plan_auto_approved":
case 'plan_auto_approved':
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case "plan_revision_requested":
case 'plan_revision_requested': {
// Show when user requests plan revision
if ("planVersion" in event) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
if ('planVersion' in event) {
const revisionEvent = event as Extract<
AutoModeEvent,
{ type: 'plan_revision_requested' }
>;
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
}
break;
case "auto_mode_task_started":
}
case 'auto_mode_task_started': {
// Show when a task starts
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
if ('taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
}
break;
case "auto_mode_task_complete":
}
case 'auto_mode_task_complete': {
// Show task completion progress
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
if ('taskId' in event && 'tasksCompleted' in event && 'tasksTotal' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
}
break;
case "auto_mode_phase_complete":
}
case 'auto_mode_phase_complete': {
// Show phase completion for full mode
if ("phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
if ('phaseNumber' in event) {
const phaseEvent = event as Extract<
AutoModeEvent,
{ type: 'auto_mode_phase_complete' }
>;
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
}
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
}
case 'auto_mode_feature_complete': {
const emoji = event.passes ? '✅' : '⚠️';
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
@@ -239,6 +237,7 @@ export function AgentOutputModal({
}, 1500);
}
break;
}
}
if (newContent) {
@@ -267,20 +266,15 @@ export function AgentOutputModal({
const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers
if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
/^[0-9]$/.test(event.key)
) {
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onNumberKeyPress]);
@@ -293,19 +287,18 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "parsed"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
@@ -313,11 +306,11 @@ export function AgentOutputModal({
Logs
</button>
<button
onClick={() => setViewMode("changes")}
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-changes"
>
@@ -325,11 +318,11 @@ export function AgentOutputModal({
Changes
</button>
<button
onClick={() => setViewMode("raw")}
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "raw"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
@@ -353,7 +346,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
{viewMode === "changes" ? (
{viewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -386,19 +379,17 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
) : viewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}
</div>
</>
)}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,30 +6,29 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
Feature,
AgentModel,
@@ -38,7 +36,7 @@ import {
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -47,14 +45,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
} from '@/components/ui/dropdown-menu';
import { DependencyTreeDialog } from './dependency-tree-dialog';
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -104,16 +102,19 @@ export function EditFeatureDialog({
// If feature has no branchName, default to using current branch
return !feature?.branchName;
});
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
@@ -135,33 +136,31 @@ export function EditFeatureDialog({
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
const isBranchSelectorEnabled = editingFeature.status === "backlog";
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
: 'none';
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? (currentBranch || "")
: editingFeature.branchName || "";
? currentBranch || ''
: editingFeature.branchName || '';
const updates = {
title: editingFeature.title ?? "",
title: editingFeature.title ?? '',
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
@@ -192,16 +191,11 @@ export function EditFeatureDialog({
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -224,16 +218,14 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
toast.success('Description enhanced!');
} else {
toast.error(result?.error || "Failed to enhance description");
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
console.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
@@ -267,10 +259,7 @@ export function EditFeatureDialog({
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="prompt"
className="py-4 flex-1 min-h-0 flex flex-col"
>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
@@ -287,10 +276,7 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -318,7 +304,7 @@ export function EditFeatureDialog({
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
id="edit-title"
value={editingFeature.title ?? ""}
value={editingFeature.title ?? ''}
onChange={(e) =>
setEditingFeature({
...editingFeature,
@@ -332,38 +318,25 @@ export function EditFeatureDialog({
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<Button variant="outline" size="sm" className="w-[180px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -400,7 +373,7 @@ export function EditFeatureDialog({
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
@@ -410,7 +383,7 @@ export function EditFeatureDialog({
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
)}
@@ -429,17 +402,12 @@ export function EditFeatureDialog({
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Simple Mode Active
</p>
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
@@ -447,13 +415,11 @@ export function EditFeatureDialog({
<Button
variant="outline"
size="sm"
onClick={() =>
setShowEditAdvancedOptions(!showEditAdvancedOptions)
}
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
data-testid="edit-show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -461,29 +427,28 @@ export function EditFeatureDialog({
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={editingFeature.model ?? "opus"}
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
selectedModel={editingFeature.model ?? 'opus'}
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>
{editModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={editingFeature.thinkingLevel ?? "none"}
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
onLevelSelect={(level) =>
setEditingFeature({
...editingFeature,
@@ -515,13 +480,9 @@ export function EditFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
setEditingFeature({ ...editingFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
steps={editingFeature.steps}
onStepsChange={(steps) =>
setEditingFeature({ ...editingFeature, steps })
}
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
testIdPrefix="edit"
/>
</TabsContent>
@@ -541,12 +502,12 @@ export function EditFeatureDialog({
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
editingFeature.status === 'backlog' &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback } from 'react';
import {
Feature,
FeatureImage,
@@ -6,13 +6,13 @@ import {
ThinkingLevel,
PlanningMode,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { truncateDescription } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
} from '@/store/app-store';
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -20,10 +20,7 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -41,6 +38,7 @@ interface UseBoardActionsProps {
outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
}
@@ -68,6 +66,7 @@ export function useBoardActions({
outputFeature,
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const {
@@ -111,33 +110,34 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success) {
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Auto-select the worktree when creating a feature for it
onWorktreeAutoSelect?.({
path: result.worktree.path,
branch: result.worktree.branch,
});
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
} else if (!result.success) {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
toast.error('Failed to create worktree', {
description: result.error || 'An error occurred',
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
}
@@ -149,7 +149,7 @@ export function useBoardActions({
...featureData,
title: featureData.title,
titleGenerating: needsTitleGeneration,
status: "backlog" as const,
status: 'backlog' as const,
branchName: finalBranchName,
};
const createdFeature = addFeature(newFeatureData);
@@ -161,10 +161,14 @@ export function useBoardActions({
if (needsTitleGeneration) {
const api = getElectronAPI();
if (api?.features?.generateTitle) {
api.features.generateTitle(featureData.description)
api.features
.generateTitle(featureData.description)
.then((result) => {
if (result.success && result.title) {
const titleUpdates = { title: result.title, titleGenerating: false };
const titleUpdates = {
title: result.title,
titleGenerating: false,
};
updateFeature(createdFeature.id, titleUpdates);
persistFeatureUpdate(createdFeature.id, titleUpdates);
} else {
@@ -175,7 +179,7 @@ export function useBoardActions({
}
})
.catch((error) => {
console.error("[Board] Error generating title:", error);
console.error('[Board] Error generating title:', error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
@@ -184,7 +188,17 @@ export function useBoardActions({
}
}
},
[addFeature, persistFeatureCreate, persistFeatureUpdate, updateFeature, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
[
addFeature,
persistFeatureCreate,
persistFeatureUpdate,
updateFeature,
saveCategory,
useWorktrees,
currentProject,
onWorktreeCreated,
onWorktreeAutoSelect,
]
);
const handleUpdateFeature = useCallback(
@@ -213,14 +227,11 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(
currentProject.path,
finalBranchName
);
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Refresh worktree list in UI
@@ -230,16 +241,15 @@ export function useBoardActions({
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "An error occurred",
toast.error('Failed to create worktree', {
description: result.error || 'An error occurred',
});
}
}
} catch (error) {
console.error("[Board] Error creating worktree:", error);
toast.error("Failed to create worktree", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
}
@@ -257,7 +267,15 @@ export function useBoardActions({
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
[
updateFeature,
persistFeatureUpdate,
saveCategory,
setEditingFeature,
useWorktrees,
currentProject,
onWorktreeCreated,
]
);
const handleDeleteFeature = useCallback(
@@ -270,15 +288,13 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
toast.success('Agent stopped', {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
toast.error("Failed to stop agent", {
description: "The feature will still be deleted.",
console.error('[Board] Error stopping feature before delete:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be deleted.',
});
}
}
@@ -291,17 +307,11 @@ export function useBoardActions({
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
}
}
} catch (error) {
console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
}
}
@@ -318,7 +328,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
@@ -332,15 +342,15 @@ export function useBoardActions({
if (result.success) {
console.log(
"[Board] Feature run started successfully, branch:",
feature.branchName || "default"
'[Board] Feature run started successfully, branch:',
feature.branchName || 'default'
);
} else {
console.error("[Board] Failed to run feature:", result.error);
console.error('[Board] Failed to run feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error running feature:", error);
console.error('[Board] Error running feature:', error);
await loadFeatures();
}
},
@@ -350,9 +360,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
toast.error("Concurrency limit reached", {
toast.error('Concurrency limit reached', {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
autoMode.maxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
@@ -367,22 +377,22 @@ export function useBoardActions({
const dep = features.find((f) => f.id === depId);
return dep ? truncateDescription(dep.description, 40) : depId;
})
.join(", ");
.join(', ');
toast.warning("Starting feature with incomplete dependencies", {
toast.warning('Starting feature with incomplete dependencies', {
description: `This feature depends on: ${depDescriptions}`,
});
}
}
const updates = {
status: "in_progress" as const,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
console.log('[Board] Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
},
@@ -403,23 +413,20 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
console.log("[Board] Feature verification started successfully");
console.log('[Board] Feature verification started successfully');
} else {
console.error("[Board] Failed to verify feature:", result.error);
console.error('[Board] Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error verifying feature:", error);
console.error('[Board] Error verifying feature:', error);
await loadFeatures();
}
},
@@ -433,7 +440,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
console.error('Auto mode API not available');
return;
}
@@ -444,13 +451,13 @@ export function useBoardActions({
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
console.log('[Board] Feature resume started successfully');
} else {
console.error("[Board] Failed to resume feature:", result.error);
console.error('[Board] Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error resuming feature:", error);
console.error('[Board] Error resuming feature:', error);
await loadFeatures();
}
},
@@ -459,15 +466,13 @@ export function useBoardActions({
const handleManualVerify = useCallback(
(feature: Feature) => {
moveFeature(feature.id, "verified");
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: "verified",
status: 'verified',
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
toast.success('Feature verified', {
description: `Marked as verified: ${truncateDescription(feature.description)}`,
});
},
[moveFeature, persistFeatureUpdate]
@@ -476,15 +481,13 @@ export function useBoardActions({
const handleMoveBackToInProgress = useCallback(
(feature: Feature) => {
const updates = {
status: "in_progress" as const,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
toast.info('Feature moved back', {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -493,16 +496,11 @@ export function useBoardActions({
const handleOpenFollowUp = useCallback(
(feature: Feature) => {
setFollowUpFeature(feature);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
);
const handleSendFollowUp = useCallback(async () => {
@@ -513,15 +511,15 @@ export function useBoardActions({
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
console.error("Follow-up feature API not available");
toast.error("Follow-up not available", {
description: "This feature is not available in the current version.",
console.error('Follow-up feature API not available');
toast.error('Follow-up not available', {
description: 'This feature is not available in the current version.',
});
return;
}
const updates = {
status: "in_progress" as const,
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
justFinishedAt: undefined,
};
@@ -530,14 +528,12 @@ export function useBoardActions({
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(
featureDescription
)}`,
toast.success('Follow-up started', {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
@@ -551,10 +547,9 @@ export function useBoardActions({
// No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error sending follow-up:', error);
toast.error('Failed to send follow-up', {
description: error instanceof Error ? error.message : 'An error occurred',
});
loadFeatures();
});
@@ -580,10 +575,9 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode?.commitFeature) {
console.error("Commit feature API not available");
toast.error("Commit not available", {
description:
"This feature is not available in the current version.",
console.error('Commit feature API not available');
toast.error('Commit not available', {
description: 'This feature is not available in the current version.',
});
return;
}
@@ -596,38 +590,29 @@ export function useBoardActions({
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { status: 'verified' });
toast.success('Feature committed', {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error("[Board] Failed to commit feature:", result.error);
toast.error("Failed to commit feature", {
description: result.error || "An error occurred",
console.error('[Board] Failed to commit feature:', result.error);
toast.error('Failed to commit feature', {
description: result.error || 'An error occurred',
});
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error committing feature:', error);
toast.error('Failed to commit feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
await loadFeatures();
}
},
[
currentProject,
moveFeature,
persistFeatureUpdate,
loadFeatures,
onWorktreeCreated,
]
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
);
const handleMergeFeature = useCallback(
@@ -637,37 +622,32 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
console.error("Worktree API not available");
toast.error("Merge not available", {
description:
"This feature is not available in the current version.",
console.error('Worktree API not available');
toast.error('Merge not available', {
description: 'This feature is not available in the current version.',
});
return;
}
const result = await api.worktree.mergeFeature(
currentProject.path,
feature.id
);
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
if (result.success) {
await loadFeatures();
toast.success("Feature merged", {
toast.success('Feature merged', {
description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to merge feature:", result.error);
toast.error("Failed to merge feature", {
description: result.error || "An error occurred",
console.error('[Board] Failed to merge feature:', result.error);
toast.error('Failed to merge feature', {
description: result.error || 'An error occurred',
});
}
} catch (error) {
console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error merging feature:', error);
toast.error('Failed to merge feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
@@ -677,12 +657,12 @@ export function useBoardActions({
const handleCompleteFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
toast.success('Feature completed', {
description: `Archived: ${truncateDescription(feature.description)}`,
});
},
@@ -692,15 +672,13 @@ export function useBoardActions({
const handleUnarchiveFeature = useCallback(
(feature: Feature) => {
const updates = {
status: "verified" as const,
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
toast.success('Feature restored', {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -716,7 +694,7 @@ export function useBoardActions({
const handleOutputModalNumberKeyPress = useCallback(
(key: string) => {
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
const index = key === '0' ? 9 : parseInt(key, 10) - 1;
const targetFeature = inProgressFeaturesForShortcuts[index];
if (!targetFeature) {
@@ -729,12 +707,7 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
);
const handleForceStopFeature = useCallback(
@@ -743,9 +716,9 @@ export function useBoardActions({
await autoMode.stopFeature(feature.id);
const targetStatus =
feature.skipTests && feature.status === "waiting_approval"
? "waiting_approval"
: "backlog";
feature.skipTests && feature.status === 'waiting_approval'
? 'waiting_approval'
: 'backlog';
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
@@ -753,21 +726,18 @@ export function useBoardActions({
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
toast.success('Agent stopped', {
description:
targetStatus === "waiting_approval"
targetStatus === 'waiting_approval'
? `Stopped commit - returned to waiting approval: ${truncateDescription(
feature.description
)}`
: `Stopped working on: ${truncateDescription(
feature.description
)}`,
: `Stopped working on: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature:", error);
toast.error("Failed to stop agent", {
description:
error instanceof Error ? error.message : "An error occurred",
console.error('[Board] Error stopping feature:', error);
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
@@ -777,25 +747,21 @@ export function useBoardActions({
const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
const primaryBranch = projectPath
? getPrimaryWorktreeBranch(projectPath)
: null;
const primaryBranch = projectPath ? getPrimaryWorktreeBranch(projectPath) : null;
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
if (f.status !== 'backlog') return false;
// Determine the feature's branch (default to primary branch if not set)
const featureBranch = f.branchName || primaryBranch || "main";
const featureBranch = f.branchName || primaryBranch || 'main';
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
// show features with no branch or primary branch
if (
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
!f.branchName || (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
@@ -803,13 +769,11 @@ export function useBoardActions({
return featureBranch === currentWorktreeBranch;
});
const availableSlots =
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
const availableSlots = useAppStore.getState().maxConcurrency - runningAutoTasks.length;
if (availableSlots <= 0) {
toast.error("Concurrency limit reached", {
description:
"Wait for a task to complete or increase the concurrency limit.",
toast.error('Concurrency limit reached', {
description: 'Wait for a task to complete or increase the concurrency limit.',
});
return;
}
@@ -817,12 +781,11 @@ export function useBoardActions({
if (backlogFeatures.length === 0) {
const isOnPrimaryBranch =
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
(projectPath && isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info('Backlog empty', {
description: !isOnPrimaryBranch
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
: 'No features in backlog to start.',
});
return;
}
@@ -852,9 +815,9 @@ export function useBoardActions({
});
if (!featureToStart) {
toast.info("No eligible features", {
toast.info('No eligible features', {
description:
"All backlog features have unmet dependencies. Complete their dependencies first.",
'All backlog features have unmet dependencies. Complete their dependencies first.',
});
return;
}
@@ -874,7 +837,7 @@ export function useBoardActions({
]);
const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
const verifiedFeatures = features.filter((f) => f.status === 'verified');
for (const feature of verifiedFeatures) {
const isRunning = runningAutoTasks.includes(feature.id);
@@ -882,30 +845,21 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before archive:",
error
);
console.error('[Board] Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
toast.success("All verified features archived", {
toast.success('All verified features archived', {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
updateFeature,
persistFeatureUpdate,
]);
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
return {
handleAddFeature,

View File

@@ -1,8 +1,8 @@
import { useMemo, useCallback } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
type ColumnId = Feature["status"];
type ColumnId = Feature['status'];
interface UseBoardColumnFeaturesProps {
features: Feature[];
@@ -87,7 +87,7 @@ export function useBoardColumnFeatures({
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === "backlog") {
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
@@ -136,7 +136,14 @@ export function useBoardColumnFeatures({
}
return map;
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
}, [
features,
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
@@ -147,7 +154,7 @@ export function useBoardColumnFeatures({
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
return features.filter((f) => f.status === 'completed');
}, [features]);
return {

View File

@@ -1,22 +1,14 @@
import {
DndContext,
DragOverlay,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
import { COLUMNS, ColumnId } from "./constants";
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
interface KanbanBoardProps {
sensors: any;
@@ -93,10 +85,7 @@ export function KanbanBoard({
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -118,8 +107,7 @@ export function KanbanBoard({
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
column.id === 'verified' && columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
@@ -130,7 +118,7 @@ export function KanbanBoard({
<Archive className="w-3 h-3 mr-1" />
Archive All
</Button>
) : column.id === "backlog" ? (
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -175,9 +163,8 @@ export function KanbanBoard({
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
@@ -190,30 +177,19 @@ export function KanbanBoard({
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() =>
onMoveBackToInProgress(feature)
}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
);
})}
@@ -226,7 +202,7 @@ export function KanbanBoard({
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (

View File

@@ -1,66 +1,50 @@
import { AgentModel, ThinkingLevel } from "@/store/app-store";
import {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
} from "lucide-react";
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
export type ModelOption = {
id: AgentModel;
label: string;
description: string;
badge?: string;
provider: "claude";
provider: 'claude';
};
export const CLAUDE_MODELS: ModelOption[] = [
{
id: "haiku",
label: "Claude Haiku",
description: "Fast and efficient for simple tasks.",
badge: "Speed",
provider: "claude",
id: 'haiku',
label: 'Claude Haiku',
description: 'Fast and efficient for simple tasks.',
badge: 'Speed',
provider: 'claude',
},
{
id: "sonnet",
label: "Claude Sonnet",
description: "Balanced performance with strong reasoning.",
badge: "Balanced",
provider: "claude",
id: 'sonnet',
label: 'Claude Sonnet',
description: 'Balanced performance with strong reasoning.',
badge: 'Balanced',
provider: 'claude',
},
{
id: "opus",
label: "Claude Opus",
description: "Most capable model for complex work.",
badge: "Premium",
provider: "claude",
id: 'opus',
label: 'Claude Opus',
description: 'Most capable model for complex work.',
badge: 'Premium',
provider: 'claude',
},
];
export const THINKING_LEVELS: ThinkingLevel[] = [
"none",
"low",
"medium",
"high",
"ultrathink",
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
none: "None",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: 'None',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
// Profile icon mapping
export const PROFILE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,

View File

@@ -1,16 +1,10 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
interface WorktreeTabProps {
worktree: WorktreeInfo;
@@ -91,93 +85,88 @@ export function WorktreeTab({
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? "open";
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
const prStateClasses = (() => {
// When selected (active tab), use high contrast solid background (paper-like)
if (isSelected) {
return "bg-background text-foreground border-transparent shadow-sm";
return 'bg-background text-foreground border-transparent shadow-sm';
}
// When not selected, use the colored variants
switch (prState) {
case "open":
case "reopened":
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
case "draft":
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
case "merged":
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
case "closed":
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
case 'open':
case 'reopened':
return 'bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25';
case 'draft':
return 'bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25';
case 'merged':
return 'bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25';
case 'closed':
return 'bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25';
default:
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
return 'bg-muted text-muted-foreground border-border/60 hover:bg-muted/80';
}
})();
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ''}`;
// Helper to get status icon color for the selected state
const getStatusColorClass = () => {
if (!isSelected) return "";
if (!isSelected) return '';
switch (prState) {
case "open":
case "reopened":
return "text-emerald-600 dark:text-emerald-500";
case "draft":
return "text-amber-600 dark:text-amber-500";
case "merged":
return "text-purple-600 dark:text-purple-500";
case "closed":
return "text-rose-600 dark:text-rose-500";
case 'open':
case 'reopened':
return 'text-emerald-600 dark:text-emerald-500';
case 'draft':
return 'text-amber-600 dark:text-amber-500';
case 'merged':
return 'text-purple-600 dark:text-purple-500';
case 'closed':
return 'text-rose-600 dark:text-rose-500';
default:
return "text-muted-foreground";
return 'text-muted-foreground';
}
};
prBadge = (
<button
type="button"
<span
role="button"
tabIndex={0}
className={cn(
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
"appearance-none cursor-pointer hover:opacity-80 active:opacity-70", // Reset button appearance but keep cursor, add hover/active states
'ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background',
'cursor-pointer hover:opacity-80 active:opacity-70',
prStateClasses
)}
style={{
// Override any inherited button styles
backgroundImage: "none",
boxShadow: "none",
}}
title={`${prLabel} - Click to open`}
aria-label={`${prLabel} - Click to open pull request`}
onClick={(e) => {
e.stopPropagation(); // Prevent triggering worktree selection
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
}
}}
onKeyDown={(e) => {
// Prevent event from bubbling to parent button
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
window.open(worktree.pr.url, '_blank', 'noopener,noreferrer');
}
}
}}
>
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
<GitPullRequest className={cn('w-3 h-3', getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? 'text-foreground font-semibold' : ''}>
PR #{worktree.pr.number}
</span>
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
<span className={cn('capitalize', getStatusColorClass())} aria-hidden="true">
{prState}
</span>
</button>
</span>
);
}
@@ -186,21 +175,21 @@ export function WorktreeTab({
{worktree.isMain ? (
<>
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
'h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
title={`Click to preview ${worktree.branch}`}
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -211,18 +200,23 @@ export function WorktreeTab({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -245,26 +239,24 @@ export function WorktreeTab({
</>
) : (
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70"
'h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
!worktree.hasWorktree && !isSelected && 'opacity-70'
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
: 'Click to switch to this branch'
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -275,18 +267,23 @@ export function WorktreeTab({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -297,13 +294,13 @@ export function WorktreeTab({
{isDevServerRunning && (
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo } from "../types";
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
interface UseWorktreesOptions {
projectPath: string;
@@ -11,7 +10,11 @@ interface UseWorktreesOptions {
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
}
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
export function useWorktrees({
projectPath,
refreshTrigger = 0,
onRemovedWorktrees,
}: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
@@ -20,29 +23,37 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
const fetchWorktrees = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn('Worktree API not available');
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error('Failed to fetch worktrees:', error);
return undefined;
} finally {
if (!silent) {
setIsLoading(false);
}
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error("Failed to fetch worktrees:", error);
return undefined;
} finally {
setIsLoading(false);
}
}, [projectPath, setWorktreesInStore]);
},
[projectPath, setWorktreesInStore]
);
useEffect(() => {
fetchWorktrees();
@@ -58,30 +69,38 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
}
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
// Use a ref to track the current worktree to avoid running validation
// when selection changes (which could cause a race condition with stale worktrees list)
const currentWorktreeRef = useRef(currentWorktree);
useEffect(() => {
currentWorktreeRef.current = currentWorktree;
}, [currentWorktree]);
// Validation effect: only runs when worktrees list changes (not on selection change)
// This prevents a race condition where the selection is reset because the
// local worktrees state hasn't been updated yet from the async fetch
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
const current = currentWorktreeRef.current;
const currentPath = current?.path;
const currentWorktreeExists =
currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
if (current == null || (currentPath !== null && !currentWorktreeExists)) {
// Find the primary worktree and get its branch name
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(projectPath, null, mainBranch);
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
}, [worktrees, projectPath, setCurrentWorktree]);
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
},
[projectPath, setCurrentWorktree]
);

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, RefreshCw, ChevronDown, ChevronUp } from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { getItem, setItem } from '@/lib/storage';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
useDevServers,
@@ -10,10 +11,10 @@ import {
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from "./hooks";
import { WorktreeTab } from "./components";
} from './hooks';
import { WorktreeTab } from './components';
const WORKTREE_PANEL_COLLAPSED_KEY = "worktree-panel-collapsed";
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
export function WorktreePanel({
projectPath,
@@ -85,20 +86,38 @@ export function WorktreePanel({
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === "true";
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === 'true';
});
useEffect(() => {
localStorage.setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
}, [isCollapsed]);
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 5000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchWorktrees]);
// Get the currently selected worktree for collapsed view
const selectedWorktree = worktrees.find((w) => {
if (currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null) {
if (
currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
) {
return w.isMain;
}
return pathsEqual(w.path, currentWorktreePath);
@@ -106,9 +125,7 @@ export function WorktreePanel({
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
};
@@ -125,9 +142,8 @@ export function WorktreePanel({
}
};
if (!useWorktreesEnabled) {
return null;
}
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Collapsed view - just show current branch and toggle
if (isCollapsed) {
@@ -140,16 +156,14 @@ export function WorktreePanel({
onClick={toggleCollapsed}
title="Expand worktree panel"
>
<ChevronDown className="w-4 h-4" />
<PanelLeftOpen className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">
{selectedWorktree?.branch ?? "main"}
</span>
<span className="text-sm font-mono font-medium">{selectedWorktree?.branch ?? 'main'}</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? "!"}
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
</div>
@@ -166,86 +180,139 @@ export function WorktreePanel({
onClick={toggleCollapsed}
title="Collapse worktree panel"
>
<ChevronUp className="w-4 h-4" />
<PanelLeftClose className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-2 flex-wrap">
{worktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</Button>
)}
</div>
{/* Worktrees section - only show if enabled */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</>
)}
</div>
);
}