mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user