Merge main into kanban-scaling

Resolves merge conflicts while preserving:
- Kanban scaling improvements (window sizing, bounce prevention, debouncing)
- Main's sidebar refactoring into hooks
- Main's openInEditor functionality for VS Code integration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
trueheads
2025-12-22 01:49:45 -06:00
599 changed files with 26666 additions and 24168 deletions

View File

@@ -1,16 +1,15 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
}
export function BoardControls({
@@ -57,7 +56,7 @@ export function BoardControls({
<Archive className="w-4 h-4" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedCount > 99 ? "99+" : completedCount}
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
@@ -75,12 +74,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("minimal")}
onClick={() => onDetailLevelChange('minimal')}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
'p-2 rounded-l-lg transition-colors',
kanbanCardDetailLevel === 'minimal'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-minimal"
>
@@ -94,12 +93,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("standard")}
onClick={() => onDetailLevelChange('standard')}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
'p-2 transition-colors',
kanbanCardDetailLevel === 'standard'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-standard"
>
@@ -113,12 +112,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("detailed")}
onClick={() => onDetailLevelChange('detailed')}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
'p-2 rounded-r-lg transition-colors',
kanbanCardDetailLevel === 'detailed'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-detailed"
>

View File

@@ -1,13 +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 { ClaudeUsagePopover } from "@/components/claude-usage-popover";
import { useAppStore } from "@/store/app-store";
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;
@@ -36,7 +34,8 @@ export function BoardHeader({
// 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 isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
return (
@@ -78,10 +77,7 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label
htmlFor="auto-mode-toggle"
className="text-sm font-medium cursor-pointer"
>
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch

View File

@@ -1,7 +1,6 @@
import { useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Loader2 } from "lucide-react";
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
interface BoardSearchBarProps {
searchQuery: string;
@@ -25,7 +24,7 @@ export function BoardSearchBar({
const handleKeyDown = (e: KeyboardEvent) => {
// Only focus if not typing in an input/textarea
if (
e.key === "/" &&
e.key === '/' &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
@@ -34,8 +33,8 @@ export function BoardSearchBar({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
@@ -53,7 +52,7 @@ export function BoardSearchBar({
/>
{searchQuery ? (
<button
onClick={() => onSearchChange("")}
onClick={() => onSearchChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
@@ -70,19 +69,18 @@ export function BoardSearchBar({
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
{isCreatingSpec && currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
);
}

View File

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

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
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";
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
@@ -17,21 +17,21 @@ import {
Circle,
Loader2,
Wrench,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { SummaryDialog } from "./summary-dialog";
} 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 "";
if (!level || level === 'none') return '';
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: '',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
return labels[level];
}
@@ -53,7 +53,7 @@ export function AgentInfoPanel({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
useEffect(() => {
const loadContext = async () => {
@@ -63,22 +63,18 @@ export function AgentInfoPanel({
return;
}
if (feature.status === "backlog") {
if (feature.status === 'backlog') {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
@@ -94,68 +90,61 @@ export function AgentInfoPanel({
}
}
} catch {
// eslint-disable-next-line no-undef
console.debug("[KanbanCard] No context file for feature:", feature.id);
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
if (isCurrentAutoTask) {
// eslint-disable-next-line no-undef
const interval = setInterval(loadContext, 3000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === "backlog") {
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>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
{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)}
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
</span>
</div>
)}
) : null}
</div>
</div>
);
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
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>
<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)]"
'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}
@@ -169,31 +158,26 @@ export function AgentInfoPanel({
<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
{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" ? (
<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" ? (
) : 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"
'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}
@@ -210,8 +194,7 @@ export function AgentInfoPanel({
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === "waiting_approval" ||
feature.status === "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">
@@ -238,27 +221,20 @@ export function AgentInfoPanel({
</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">
{!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">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</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>
)}
</>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
PlayCircle,
@@ -10,7 +10,7 @@ import {
Eye,
Wand2,
Archive,
} from "lucide-react";
} from 'lucide-react';
interface CardActionsProps {
feature: Feature;
@@ -52,7 +52,7 @@ export function CardActions({
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -109,10 +109,10 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -191,7 +191,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{/* Logs button */}
{onViewOutput && (
@@ -229,7 +229,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{/* Refine prompt button */}
{onFollowUp && (
@@ -282,7 +282,7 @@ export function CardActions({
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<Button
variant="secondary"

View File

@@ -1,19 +1,14 @@
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";
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;
'data-testid'?: string;
title?: string;
}
@@ -21,16 +16,11 @@ interface CardBadgeProps {
* 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) {
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",
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
@@ -50,7 +40,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
@@ -62,7 +52,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog");
feature.status === 'backlog');
if (!showStatusBadges) {
return null;
@@ -77,8 +67,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
<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)]"
'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}`}
>
@@ -96,14 +86,14 @@ export function CardBadges({ feature }: CardBadgesProps) {
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog" && (
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"
'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}`}
>
@@ -112,10 +102,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
</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"}
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
@@ -123,7 +111,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(", ")}
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
@@ -141,11 +129,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
feature.status !== "waiting_approval" ||
feature.error
) {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
@@ -154,7 +138,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval') {
return;
}
@@ -179,7 +163,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === "backlog") ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
if (!showPriorityBadges) {
@@ -195,45 +179,39 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
<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
'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)]",
'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)]",
'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)]"
'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>
<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">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">
L
</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"
? 'High Priority'
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === "backlog" && (
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react";
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
@@ -25,10 +25,10 @@ export function CardContentSections({
)}
{/* PR URL Display */}
{typeof feature.prUrl === "string" &&
{typeof feature.prUrl === 'string' &&
/^https?:\/\//i.test(feature.prUrl) &&
(() => {
const prNumber = feature.prUrl.split("/").pop();
const prNumber = feature.prUrl.split('/').pop();
return (
<div className="mb-2">
<a
@@ -43,7 +43,7 @@ export function CardContentSections({
>
<GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a>
@@ -59,14 +59,12 @@ export function CardContentSections({
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === "verified" ? (
{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>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -79,4 +77,3 @@ export function CardContentSections({
</>
);
}

View File

@@ -1,18 +1,14 @@
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 { 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";
} from '@/components/ui/dropdown-menu';
import {
GripVertical,
Edit,
@@ -23,10 +19,10 @@ import {
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";
} 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;
@@ -100,9 +96,7 @@ export function CardHeaderSection({
<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>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -111,7 +105,7 @@ export function CardHeaderSection({
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
@@ -128,8 +122,7 @@ export function CardHeaderSection({
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -142,9 +135,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Edit"
>
@@ -161,9 +152,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Logs"
>
@@ -177,9 +166,7 @@ export function CardHeaderSection({
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Delete"
>
@@ -190,7 +177,7 @@ export function CardHeaderSection({
)}
{/* In progress header */}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -246,9 +233,7 @@ export function CardHeaderSection({
<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>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -271,9 +256,7 @@ export function CardHeaderSection({
{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>
<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">
@@ -282,13 +265,13 @@ export function CardHeaderSection({
) : null}
<CardDescription
className={cn(
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
!isDescriptionExpanded && "line-clamp-3"
'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 && (
{(feature.description || feature.summary || '').length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -327,4 +310,3 @@ export function CardHeaderSection({
</CardHeader>
);
}

View File

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

View File

@@ -1,14 +1,14 @@
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";
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;
@@ -63,23 +63,14 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
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({
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,
});
@@ -92,10 +83,10 @@ export const KanbanCard = memo(function KanbanCard({
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
(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>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
@@ -105,28 +96,22 @@ export const KanbanCard = memo(function KanbanCard({
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",
'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",
'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",
'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"
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
@@ -137,8 +122,8 @@ export const KanbanCard = memo(function KanbanCard({
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
@@ -149,9 +134,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">
{feature.category}
</span>
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
{/* Priority and Manual Verification badges */}
@@ -169,11 +152,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections
feature={feature}
useWorktrees={useWorktrees}
showSteps={showSteps}
/>
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
{/* Agent Info Panel */}
<AgentInfoPanel

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { AgentTaskInfo } from "@/lib/agent-context-parser";
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {
Dialog,
DialogContent,
@@ -7,10 +7,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";
import { Sparkles } from "lucide-react";
} 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;
@@ -40,23 +40,17 @@ export function SummaryDialog({
</DialogTitle>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
title={feature.description || feature.summary || ''}
>
{(() => {
const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
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"}
{feature.summary || summary || agentInfo?.summary || 'No summary available'}
</Markdown>
</div>
<DialogFooter>
@@ -72,4 +66,3 @@ export function SummaryDialog({
</Dialog>
);
}

View File

@@ -1,22 +1,22 @@
import { Feature } from "@/store/app-store";
import { Feature } from '@/store/app-store';
export type ColumnId = Feature["status"];
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: "in_progress",
title: "In Progress",
colorClass: "bg-[var(--status-in-progress)]",
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
{
id: "waiting_approval",
title: "Waiting Approval",
colorClass: "bg-[var(--status-waiting)]",
id: 'waiting_approval',
title: 'Waiting Approval',
colorClass: 'bg-[var(--status-waiting)]',
},
{
id: "verified",
title: "Verified",
colorClass: "bg-[var(--status-success)]",
id: 'verified',
title: 'Verified',
colorClass: 'bg-[var(--status-success)]',
},
];

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,18 +6,19 @@ 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,
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
@@ -26,10 +26,10 @@ import {
FlaskConical,
Sparkles,
ChevronDown,
} 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 {
useAppStore,
AgentModel,
@@ -37,7 +37,7 @@ import {
FeatureImage,
AIProfile,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -46,14 +46,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "@tanstack/react-router";
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
interface AddFeatureDialogProps {
open: boolean;
@@ -65,6 +65,7 @@ interface AddFeatureDialogProps {
steps: string[];
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
@@ -92,7 +93,7 @@ export function AddFeatureDialog({
branchSuggestions,
branchCardCounts,
defaultSkipTests,
defaultBranch = "main",
defaultBranch = 'main',
currentBranch,
isMaximized,
showProfilesOnly,
@@ -101,27 +102,29 @@ export function AddFeatureDialog({
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
title: "",
category: "",
description: "",
steps: [""],
title: '',
category: '',
description: '',
steps: [''],
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "",
model: 'opus' as AgentModel,
thinkingLevel: 'none' as ThinkingLevel,
branchName: '',
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Get enhancement model, planning mode defaults, and worktrees setting from store
@@ -144,10 +147,10 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch || "",
branchName: defaultBranch || '',
// Use default profile's model/thinkingLevel if set, else fallback to defaults
model: defaultProfile?.model ?? "opus",
thinkingLevel: defaultProfile?.thinkingLevel ?? "none",
model: defaultProfile?.model ?? 'opus',
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -171,22 +174,20 @@ export function AddFeatureDialog({
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const category = newFeature.category || "Uncategorized";
const category = newFeature.category || 'Uncategorized';
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.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 || ""
: newFeature.branchName || "";
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
onAdd({
title: newFeature.title,
@@ -195,6 +196,7 @@ export function AddFeatureDialog({
steps: newFeature.steps.filter((s) => s.trim()),
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
@@ -206,17 +208,18 @@ export function AddFeatureDialog({
// Reset form
setNewFeature({
title: "",
category: "",
description: "",
steps: [""],
title: '',
category: '',
description: '',
steps: [''],
images: [],
imagePaths: [],
textFilePaths: [],
skipTests: defaultSkipTests,
model: "opus",
model: 'opus',
priority: 2,
thinkingLevel: "none",
branchName: "",
thinkingLevel: 'none',
branchName: '',
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -251,13 +254,13 @@ export function AddFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
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,16 +270,11 @@ export function AddFeatureDialog({
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? newFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
setNewFeature({
...newFeature,
model,
@@ -306,14 +304,9 @@ export function AddFeatureDialog({
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>
Create a new feature card for the Kanban board.
</DialogDescription>
<DialogDescription>Create a new feature card for the Kanban board.</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="tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
@@ -330,10 +323,7 @@ export function AddFeatureDialog({
</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="description">Description</Label>
<DescriptionImageDropZone
@@ -345,8 +335,10 @@ export function AddFeatureDialog({
}
}}
images={newFeature.imagePaths}
onImagesChange={(images) =>
setNewFeature({ ...newFeature, imagePaths: images })
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
textFiles={newFeature.textFilePaths}
onTextFilesChange={(textFiles) =>
setNewFeature({ ...newFeature, textFilePaths: textFiles })
}
placeholder="Describe the feature..."
previewMap={newFeaturePreviewMap}
@@ -360,47 +352,32 @@ export function AddFeatureDialog({
<Input
id="title"
value={newFeature.title}
onChange={(e) =>
setNewFeature({ ...newFeature, title: e.target.value })
}
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
placeholder="Leave blank to auto-generate"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[200px] 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-[200px] 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>
@@ -422,9 +399,7 @@ export function AddFeatureDialog({
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
value={newFeature.category}
onChange={(value) =>
setNewFeature({ ...newFeature, category: value })
}
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
@@ -435,9 +410,7 @@ export function AddFeatureDialog({
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
@@ -448,25 +421,18 @@ export function AddFeatureDialog({
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) =>
setNewFeature({ ...newFeature, priority })
}
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
testIdPrefix="priority"
/>
</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>
@@ -478,7 +444,7 @@ export function AddFeatureDialog({
data-testid="show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showAdvancedOptions ? "Hide" : "Show"} Advanced
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -492,23 +458,19 @@ export function AddFeatureDialog({
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: "/profiles" });
navigate({ to: '/profiles' });
}}
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showAdvancedOptions) && (
<>
<ModelSelector
selectedModel={newFeature.model}
onModelSelect={handleModelSelect}
/>
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
{newModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={newFeature.thinkingLevel}
@@ -522,10 +484,7 @@ export function AddFeatureDialog({
</TabsContent>
{/* Options Tab */}
<TabsContent
value="options"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
@@ -542,9 +501,7 @@ export function AddFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
steps={newFeature.steps}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
@@ -556,12 +513,10 @@ export function AddFeatureDialog({
</Button>
<HotkeyButton
onClick={handleAdd}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
}
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
Add Feature
</HotkeyButton>

View File

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

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Dialog,
@@ -7,9 +7,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Archive } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Archive } from 'lucide-react';
interface ArchiveAllVerifiedDialogProps {
open: boolean;
@@ -30,8 +30,8 @@ export function ArchiveAllVerifiedDialog({
<DialogHeader>
<DialogTitle>Archive All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to archive all verified features? They will be
moved to the archive box.
Are you sure you want to archive all verified features? They will be moved to the
archive box.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be archived.
@@ -52,5 +52,3 @@ export function ArchiveAllVerifiedDialog({
</Dialog>
);
}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { GitCommit, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
@@ -36,7 +35,7 @@ export function CommitWorktreeDialog({
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState("");
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -49,36 +48,36 @@ export function CommitWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.commit(worktree.path, message);
if (result.success && result.result) {
if (result.result.committed) {
toast.success("Changes committed", {
toast.success('Changes committed', {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage("");
setMessage('');
} else {
toast.info("No changes to commit", {
toast.info('No changes to commit', {
description: result.result.message,
});
}
} else {
setError(result.error || "Failed to commit changes");
setError(result.error || 'Failed to commit changes');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to commit");
setError(err instanceof Error ? err.message : 'Failed to commit');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
handleCommit();
}
};
@@ -94,15 +93,12 @@ export function CommitWorktreeDialog({
Commit Changes
</DialogTitle>
<DialogDescription>
Commit changes in the{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>{" "}
worktree.
Commit changes in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
{worktree.changedFilesCount && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
@@ -132,17 +128,10 @@ export function CommitWorktreeDialog({
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || !message.trim()}
>
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,11 +5,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ArchiveRestore, Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { ArchiveRestore, Trash2 } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface CompletedFeaturesModalProps {
open: boolean;
@@ -37,9 +36,9 @@ export function CompletedFeaturesModal({
<DialogTitle>Completed Features</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet."
? 'No completed features yet.'
: `${completedFeatures.length} completed feature${
completedFeatures.length > 1 ? "s" : ""
completedFeatures.length > 1 ? 's' : ''
}`}
</DialogDescription>
</DialogHeader>
@@ -62,7 +61,7 @@ export function CompletedFeaturesModal({
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
{feature.category || 'Uncategorized'}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { GitBranchPlus, Loader2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { GitBranchPlus, Loader2 } from 'lucide-react';
interface WorktreeInfo {
path: string;
@@ -36,14 +35,14 @@ export function CreateBranchDialog({
worktree,
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState("");
const [branchName, setBranchName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setBranchName("");
setBranchName('');
setError(null);
}
}, [open]);
@@ -54,7 +53,7 @@ export function CreateBranchDialog({
// Basic validation
const invalidChars = /[\s~^:?*[\]\\]/;
if (invalidChars.test(branchName)) {
setError("Branch name contains invalid characters");
setError('Branch name contains invalid characters');
return;
}
@@ -64,7 +63,7 @@ export function CreateBranchDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error("Branch API not available");
toast.error('Branch API not available');
return;
}
@@ -75,11 +74,11 @@ export function CreateBranchDialog({
onCreated();
onOpenChange(false);
} else {
setError(result.error || "Failed to create branch");
setError(result.error || 'Failed to create branch');
}
} catch (err) {
console.error("Create branch failed:", err);
setError("Failed to create branch");
console.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);
}
@@ -94,7 +93,10 @@ export function CreateBranchDialog({
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
Create a new branch from{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
</DialogHeader>
@@ -110,38 +112,29 @@ export function CreateBranchDialog({
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && branchName.trim() && !isCreating) {
if (e.key === 'Enter' && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating}
>
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Branch"
'Create Branch'
)}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -7,15 +6,15 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
@@ -40,10 +39,10 @@ export function CreatePRDialog({
projectPath,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [commitMessage, setCommitMessage] = useState("");
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [baseBranch, setBaseBranch] = useState('main');
const [commitMessage, setCommitMessage] = useState('');
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -57,10 +56,10 @@ export function CreatePRDialog({
useEffect(() => {
if (open) {
// Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
@@ -72,10 +71,10 @@ export function CreatePRDialog({
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch('main');
setIsDraft(false);
setError(null);
setPrUrl(null);
@@ -94,7 +93,7 @@ export function CreatePRDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.createPR) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.createPR(worktree.path, {
@@ -114,19 +113,19 @@ export function CreatePRDialog({
// Show different message based on whether PR already existed
if (result.result.prAlreadyExisted) {
toast.success("Pull request found!", {
toast.success('Pull request found!', {
description: `PR already exists for ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
},
});
} else {
toast.success("Pull request created!", {
toast.success('Pull request created!', {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
},
});
}
@@ -140,12 +139,12 @@ export function CreatePRDialog({
// Check if we should show browser fallback
if (!result.result.prCreated && hasBrowserUrl) {
// If gh CLI is not available, show browser fallback UI
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
if (prError === 'gh_cli_not_available' || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed successfully
operationCompletedRef.current = true;
toast.success("Branch pushed", {
toast.success('Branch pushed', {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
@@ -159,11 +158,12 @@ export function CreatePRDialog({
if (prError) {
// Parse common gh CLI errors for better messages
let errorMessage = prError;
if (prError.includes("No commits between")) {
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
} else if (prError.includes("already exists")) {
errorMessage = "A pull request already exists for this branch.";
} else if (prError.includes("not logged in") || prError.includes("auth")) {
if (prError.includes('No commits between')) {
errorMessage =
'No new commits to create PR. Make sure your branch has changes compared to the base branch.';
} else if (prError.includes('already exists')) {
errorMessage = 'A pull request already exists for this branch.';
} else if (prError.includes('not logged in') || prError.includes('auth')) {
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
}
@@ -172,7 +172,7 @@ export function CreatePRDialog({
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed even though PR creation failed
operationCompletedRef.current = true;
toast.error("PR creation failed", {
toast.error('PR creation failed', {
description: errorMessage,
duration: 8000,
});
@@ -183,7 +183,7 @@ export function CreatePRDialog({
}
// Show success toast for push
toast.success("Branch pushed", {
toast.success('Branch pushed', {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
@@ -192,8 +192,9 @@ export function CreatePRDialog({
// No browser URL available, just close
if (!result.result.prCreated) {
if (!hasBrowserUrl) {
toast.info("PR not created", {
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
toast.info('PR not created', {
description:
'Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.',
duration: 8000,
});
}
@@ -202,10 +203,10 @@ export function CreatePRDialog({
onOpenChange(false);
}
} else {
setError(result.error || "Failed to create pull request");
setError(result.error || 'Failed to create pull request');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create PR");
setError(err instanceof Error ? err.message : 'Failed to create PR');
} finally {
setIsLoading(false);
}
@@ -235,10 +236,8 @@ export function CreatePRDialog({
Create Pull Request
</DialogTitle>
<DialogDescription>
Push changes and create a pull request from{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
Push changes and create a pull request from{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
@@ -249,15 +248,10 @@ export function CreatePRDialog({
</div>
<div>
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your PR is ready for review
</p>
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
</div>
<div className="flex gap-2 justify-center">
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
@@ -283,7 +277,7 @@ export function CreatePRDialog({
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, "_blank");
window.open(browserUrl, '_blank');
}
}}
className="gap-2 w-full"
@@ -292,11 +286,10 @@ export function CreatePRDialog({
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
{browserUrl}
</div>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">{browserUrl}</div>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to
create PRs directly from the app
</p>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleClose}>
@@ -311,8 +304,7 @@ export function CreatePRDialog({
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
Commit Message{" "}
<span className="text-muted-foreground">(optional)</span>
Commit Message <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="commit-message"
@@ -322,8 +314,7 @@ export function CreatePRDialog({
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{worktree.changedFilesCount} uncommitted file(s) will be
committed
{worktree.changedFilesCount} uncommitted file(s) will be committed
</p>
</div>
)}
@@ -374,9 +365,7 @@ export function CreatePRDialog({
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface CreatedWorktreeInfo {
path: string;
@@ -33,13 +32,13 @@ export function CreateWorktreeDialog({
projectPath,
onCreated,
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState("");
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError("Branch name is required");
setError('Branch name is required');
return;
}
@@ -47,7 +46,7 @@ export function CreateWorktreeDialog({
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
);
return;
}
@@ -58,35 +57,30 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError("Worktree API not available");
setError('Worktree API not available');
return;
}
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
toast.success(
`Worktree created for branch "${result.worktree.branch}"`,
{
description: result.worktree.isNew
? "New branch created"
: "Using existing branch",
}
);
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch',
});
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false);
setBranchName("");
setBranchName('');
} else {
setError(result.error || "Failed to create worktree");
setError(result.error || 'Failed to create worktree');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create worktree");
setError(err instanceof Error ? err.message : 'Failed to create worktree');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading && branchName.trim()) {
if (e.key === 'Enter' && !isLoading && branchName.trim()) {
handleCreate();
}
};
@@ -100,8 +94,8 @@ export function CreateWorktreeDialog({
Create New Worktree
</DialogTitle>
<DialogDescription>
Create a new git worktree with its own branch. This allows you to
work on multiple features in parallel.
Create a new git worktree with its own branch. This allows you to work on multiple
features in parallel.
</DialogDescription>
</DialogHeader>
@@ -140,17 +134,10 @@ export function CreateWorktreeDialog({
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isLoading || !branchName.trim()}
>
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,9 +5,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
interface DeleteAllVerifiedDialogProps {
open: boolean;
@@ -29,8 +28,7 @@ export function DeleteAllVerifiedDialog({
<DialogHeader>
<DialogTitle>Delete All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to delete all verified features? This action
cannot be undone.
Are you sure you want to delete all verified features? This action cannot be undone.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be deleted.
@@ -42,7 +40,11 @@ export function DeleteAllVerifiedDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
<Button
variant="destructive"
onClick={onConfirm}
data-testid="confirm-delete-all-verified"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete All
</Button>

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,10 +5,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { Feature } from "@/store/app-store";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface DeleteCompletedFeatureDialogProps {
feature: Feature | null;
@@ -36,7 +35,7 @@ export function DeleteCompletedFeatureDialog({
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{feature.description?.slice(0, 100)}
{(feature.description?.length ?? 0) > 100 ? "..." : ""}&quot;
{(feature.description?.length ?? 0) > 100 ? '...' : ''}&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
@@ -44,11 +43,7 @@ export function DeleteCompletedFeatureDialog({
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={onClose}
data-testid="cancel-delete-completed-button"
>
<Button variant="ghost" onClick={onClose} data-testid="cancel-delete-completed-button">
Cancel
</Button>
<Button

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface WorktreeInfo {
path: string;
@@ -51,14 +50,10 @@ export function DeleteWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.delete) {
toast.error("Worktree API not available");
toast.error('Worktree API not available');
return;
}
const result = await api.worktree.delete(
projectPath,
worktree.path,
deleteBranch
);
const result = await api.worktree.delete(projectPath, worktree.path, deleteBranch);
if (result.success) {
toast.success(`Worktree deleted`, {
@@ -70,13 +65,13 @@ export function DeleteWorktreeDialog({
onOpenChange(false);
setDeleteBranch(false);
} else {
toast.error("Failed to delete worktree", {
toast.error('Failed to delete worktree', {
description: result.error,
});
}
} catch (err) {
toast.error("Failed to delete worktree", {
description: err instanceof Error ? err.message : "Unknown error",
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsLoading(false);
@@ -95,21 +90,18 @@ export function DeleteWorktreeDialog({
</DialogTitle>
<DialogDescription className="space-y-3">
<span>
Are you sure you want to delete the worktree for branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
?
Are you sure you want to delete the worktree for branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>?
</span>
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
unassigned and moved to the main worktree.
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch.{' '}
{affectedFeatureCount !== 1 ? 'They' : 'It'} will be unassigned and moved to the
main worktree.
</span>
</div>
)}
@@ -118,8 +110,8 @@ export function DeleteWorktreeDialog({
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted
change(s). These will be lost if you proceed.
This worktree has {worktree.changedFilesCount} uncommitted change(s). These will
be lost if you proceed.
</span>
</div>
)}
@@ -133,26 +125,16 @@ export function DeleteWorktreeDialog({
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
/>
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
Also delete the branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
Also delete the branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</Label>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -1,14 +1,8 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Feature } from "@/store/app-store";
import { AlertCircle, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Feature } from '@/store/app-store';
import { AlertCircle, CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DependencyTreeDialogProps {
open: boolean;
@@ -37,22 +31,20 @@ export function DependencyTreeDialog({
.filter((f): f is Feature => f !== undefined);
// Find features that depend on this one
const dependents = allFeatures.filter((f) =>
f.dependencies?.includes(feature.id)
);
const dependents = allFeatures.filter((f) => f.dependencies?.includes(feature.id));
setDependencyTree({ dependencies, dependents });
}, [feature, allFeatures]);
if (!feature) return null;
const getStatusIcon = (status: Feature["status"]) => {
const getStatusIcon = (status: Feature['status']) => {
switch (status) {
case "completed":
case "verified":
case 'completed':
case 'verified':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in_progress":
case "waiting_approval":
case 'in_progress':
case 'waiting_approval':
return <Circle className="w-4 h-4 text-blue-500 fill-blue-500/20" />;
default:
return <Circle className="w-4 h-4 text-muted-foreground/50" />;
@@ -64,10 +56,10 @@ export function DependencyTreeDialog({
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-medium",
priority === 1 && "bg-red-500/20 text-red-500",
priority === 2 && "bg-yellow-500/20 text-yellow-500",
priority === 3 && "bg-blue-500/20 text-blue-500"
'text-xs px-1.5 py-0.5 rounded font-medium',
priority === 1 && 'bg-red-500/20 text-red-500',
priority === 2 && 'bg-yellow-500/20 text-yellow-500',
priority === 3 && 'bg-blue-500/20 text-blue-500'
)}
>
P{priority}
@@ -91,9 +83,7 @@ export function DependencyTreeDialog({
{getPriorityBadge(feature.priority)}
</div>
<p className="text-sm text-muted-foreground">{feature.description}</p>
<p className="text-xs text-muted-foreground/70 mt-2">
Category: {feature.category}
</p>
<p className="text-xs text-muted-foreground/70 mt-2">Category: {feature.category}</p>
</div>
{/* Dependencies (what this feature needs) */}
@@ -102,9 +92,7 @@ export function DependencyTreeDialog({
<h3 className="font-semibold text-sm">
Dependencies ({dependencyTree.dependencies.length})
</h3>
<span className="text-xs text-muted-foreground">
This feature requires:
</span>
<span className="text-xs text-muted-foreground">This feature requires:</span>
</div>
{dependencyTree.dependencies.length === 0 ? (
@@ -117,35 +105,33 @@ export function DependencyTreeDialog({
<div
key={dep.id}
className={cn(
"border rounded-lg p-3 transition-colors",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/5 border-green-500/20"
: "bg-muted/30 border-border"
'border rounded-lg p-3 transition-colors',
dep.status === 'completed' || dep.status === 'verified'
? 'bg-green-500/5 border-green-500/20'
: 'bg-muted/30 border-border'
)}
>
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dep.status)}
<span className="text-sm font-medium flex-1">
{dep.description.slice(0, 100)}
{dep.description.length > 100 && "..."}
{dep.description.length > 100 && '...'}
</span>
{getPriorityBadge(dep.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dep.category}
</span>
<span className="text-xs text-muted-foreground">{dep.category}</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/20 text-green-600"
: dep.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
'text-xs px-2 py-0.5 rounded-full',
dep.status === 'completed' || dep.status === 'verified'
? 'bg-green-500/20 text-green-600'
: dep.status === 'in_progress'
? 'bg-blue-500/20 text-blue-600'
: 'bg-muted text-muted-foreground'
)}
>
{dep.status.replace(/_/g, " ")}
{dep.status.replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -160,9 +146,7 @@ export function DependencyTreeDialog({
<h3 className="font-semibold text-sm">
Dependents ({dependencyTree.dependents.length})
</h3>
<span className="text-xs text-muted-foreground">
Features blocked by this:
</span>
<span className="text-xs text-muted-foreground">Features blocked by this:</span>
</div>
{dependencyTree.dependents.length === 0 ? (
@@ -172,34 +156,28 @@ export function DependencyTreeDialog({
) : (
<div className="space-y-2">
{dependencyTree.dependents.map((dependent) => (
<div
key={dependent.id}
className="border rounded-lg p-3 bg-muted/30"
>
<div key={dependent.id} className="border rounded-lg p-3 bg-muted/30">
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dependent.status)}
<span className="text-sm font-medium flex-1">
{dependent.description.slice(0, 100)}
{dependent.description.length > 100 && "..."}
{dependent.description.length > 100 && '...'}
</span>
{getPriorityBadge(dependent.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dependent.category}
</span>
<span className="text-xs text-muted-foreground">{dependent.category}</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dependent.status === "completed" ||
dependent.status === "verified"
? "bg-green-500/20 text-green-600"
: dependent.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
'text-xs px-2 py-0.5 rounded-full',
dependent.status === 'completed' || dependent.status === 'verified'
? 'bg-green-500/20 text-green-600'
: dependent.status === 'in_progress'
? 'bg-blue-500/20 text-blue-600'
: 'bg-muted text-muted-foreground'
)}
>
{dependent.status.replace(/_/g, " ")}
{dependent.status.replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -210,7 +188,7 @@ export function DependencyTreeDialog({
{/* Warning for incomplete dependencies */}
{dependencyTree.dependencies.some(
(d) => d.status !== "completed" && d.status !== "verified"
(d) => d.status !== 'completed' && d.status !== 'verified'
) && (
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
@@ -219,8 +197,8 @@ export function DependencyTreeDialog({
Incomplete Dependencies
</p>
<p className="text-yellow-600 dark:text-yellow-400 mt-1">
This feature has dependencies that aren't completed yet.
Consider completing them first for a smoother implementation.
This feature has dependencies that aren't completed yet. Consider completing them
first for a smoother implementation.
</p>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,30 +6,30 @@ 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,
FeatureTextFilePath as DescriptionTextFilePath,
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 +37,7 @@ import {
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -47,14 +46,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;
@@ -70,6 +69,7 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
@@ -104,16 +104,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 +138,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,
@@ -169,6 +170,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
textFilePaths: editingFeature.textFilePaths ?? [],
branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
@@ -192,16 +194,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 +221,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 +262,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 +279,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
@@ -308,6 +297,13 @@ export function EditFeatureDialog({
imagePaths: images,
})
}
textFiles={editingFeature.textFilePaths ?? []}
onTextFilesChange={(textFiles) =>
setEditingFeature({
...editingFeature,
textFilePaths: textFiles,
})
}
placeholder="Describe the feature..."
previewMap={editFeaturePreviewMap}
onPreviewMapChange={setEditFeaturePreviewMap}
@@ -318,7 +314,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 +328,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 +383,7 @@ export function EditFeatureDialog({
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
@@ -410,7 +393,7 @@ export function EditFeatureDialog({
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
)}
@@ -429,17 +412,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 +425,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 +437,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 +490,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 +512,12 @@ export function EditFeatureDialog({
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
editingFeature.status === 'backlog' &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -7,11 +6,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Loader2,
Lightbulb,
@@ -22,10 +21,15 @@ import {
RefreshCw,
Shield,
Zap,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
} from 'lucide-react';
import {
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
interface FeatureSuggestionsDialogProps {
open: boolean;
@@ -39,35 +43,38 @@ interface FeatureSuggestionsDialogProps {
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
const suggestionTypeConfig: Record<
SuggestionType,
{
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}
> = {
features: {
label: "Feature Suggestions",
label: 'Feature Suggestions',
icon: Lightbulb,
description: "Discover missing features and improvements",
color: "text-yellow-500",
description: 'Discover missing features and improvements',
color: 'text-yellow-500',
},
refactoring: {
label: "Refactoring Suggestions",
label: 'Refactoring Suggestions',
icon: RefreshCw,
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
description: 'Find code smells and refactoring opportunities',
color: 'text-blue-500',
},
security: {
label: "Security Suggestions",
label: 'Security Suggestions',
icon: Shield,
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
description: 'Identify security vulnerabilities and issues',
color: 'text-red-500',
},
performance: {
label: "Performance Suggestions",
label: 'Performance Suggestions',
icon: Zap,
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
description: 'Discover performance bottlenecks and optimizations',
color: 'text-green-500',
},
};
@@ -112,23 +119,25 @@ export function FeatureSuggestionsDialog({
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === "suggestions_tool") {
const toolName = event.tool || "Unknown Tool";
if (event.type === 'suggestions_progress') {
setProgress((prev) => [...prev, event.content || '']);
} else if (event.type === 'suggestions_tool') {
const toolName = event.tool || 'Unknown Tool';
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === "suggestions_complete") {
} else if (event.type === 'suggestions_complete') {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
const typeLabel = currentSuggestionType
? suggestionTypeConfig[currentSuggestionType].label.toLowerCase()
: 'suggestions';
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info("No suggestions generated. Try again.");
toast.info('No suggestions generated. Try again.');
}
} else if (event.type === "suggestions_error") {
} else if (event.type === 'suggestions_error') {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
@@ -140,31 +149,34 @@ export function FeatureSuggestionsDialog({
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
return;
}
const handleGenerate = useCallback(
async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error('Suggestions API not available');
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || 'Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
toast.error('Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath, setIsGenerating, setSuggestions]);
},
[projectPath, setIsGenerating, setSuggestions]
);
// Stop generating
const handleStop = useCallback(async () => {
@@ -174,9 +186,9 @@ export function FeatureSuggestionsDialog({
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info("Generation stopped");
toast.info('Generation stopped');
} catch (error) {
console.error("Failed to stop generation:", error);
console.error('Failed to stop generation:', error);
}
}, [setIsGenerating]);
@@ -218,7 +230,7 @@ export function FeatureSuggestionsDialog({
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning("No suggestions selected");
toast.warning('No suggestions selected');
return;
}
@@ -226,9 +238,7 @@ export function FeatureSuggestionsDialog({
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id));
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
@@ -236,7 +246,7 @@ export function FeatureSuggestionsDialog({
category: s.category,
description: s.description,
steps: s.steps,
status: "backlog" as const,
status: 'backlog' as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
@@ -264,8 +274,8 @@ export function FeatureSuggestionsDialog({
onClose();
} catch (error) {
console.error("Failed to import features:", error);
toast.error("Failed to import features");
console.error('Failed to import features:', error);
toast.error('Failed to import features');
} finally {
setIsImporting(false);
}
@@ -315,7 +325,7 @@ export function FeatureSuggestionsDialog({
<DialogDescription>
{currentConfig
? currentConfig.description
: "Analyze your project to discover improvements. Choose a suggestion type below."}
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
</DialogDescription>
</DialogHeader>
@@ -323,32 +333,35 @@ export function FeatureSuggestionsDialog({
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions.
Choose what type of analysis you want to perform:
Our AI will analyze your project and generate actionable suggestions. Choose what type
of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
<div className="text-xs text-muted-foreground mt-1">
{config.description}
</div>
{(
Object.entries(suggestionTypeConfig) as [
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">
{config.label.replace(' Suggestions', '')}
</div>
</Button>
);
}
)}
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</div>
</div>
) : isGenerating ? (
@@ -370,7 +383,7 @@ export function FeatureSuggestionsDialog({
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join("")}
{progress.join('')}
</div>
</div>
</div>
@@ -383,14 +396,10 @@ export function FeatureSuggestionsDialog({
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div
@@ -406,8 +415,8 @@ export function FeatureSuggestionsDialog({
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
data-testid={`suggestion-${suggestion.id}`}
>
@@ -447,9 +456,7 @@ export function FeatureSuggestionsDialog({
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
)}
{suggestion.steps.length > 0 && (
<div>
@@ -513,7 +520,7 @@ export function FeatureSuggestionsDialog({
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
@@ -522,7 +529,7 @@ export function FeatureSuggestionsDialog({
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
{selectedIds.size !== 1 ? 's' : ''}
</HotkeyButton>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -7,17 +6,17 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { HotkeyButton } from "@/components/ui/hotkey-button";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare } from "lucide-react";
import { Feature } from "@/store/app-store";
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
interface FollowUpDialogProps {
open: boolean;
@@ -58,7 +57,7 @@ export function FollowUpDialog({
compact={!isMaximized}
data-testid="follow-up-dialog"
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && prompt.trim()) {
e.preventDefault();
onSend();
}
@@ -71,7 +70,7 @@ export function FollowUpDialog({
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 100)}
{feature.description.length > 100 ? "..." : ""}
{feature.description.length > 100 ? '...' : ''}
</span>
)}
</DialogDescription>
@@ -90,8 +89,8 @@ export function FollowUpDialog({
/>
</div>
<p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing
context. You can attach screenshots to help explain the issue.
The agent will continue from where it left off, using the existing context. You can
attach screenshots to help explain the issue.
</p>
</div>
<DialogFooter>
@@ -106,7 +105,7 @@ export function FollowUpDialog({
<HotkeyButton
onClick={onSend}
disabled={!prompt.trim()}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-follow-up"
>

View File

@@ -1,9 +1,9 @@
export { AddFeatureDialog } from "./add-feature-dialog";
export { AgentOutputModal } from "./agent-output-modal";
export { CompletedFeaturesModal } from "./completed-features-modal";
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
export { FollowUpDialog } from "./follow-up-dialog";
export { PlanApprovalDialog } from "./plan-approval-dialog";
export { AddFeatureDialog } from './add-feature-dialog';
export { AgentOutputModal } from './agent-output-modal';
export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FeatureSuggestionsDialog } from './feature-suggestions-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -8,13 +8,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Markdown } from "@/components/ui/markdown";
import { Label } from "@/components/ui/label";
import { Feature } from "@/store/app-store";
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
interface PlanApprovalDialogProps {
open: boolean;
@@ -40,7 +40,7 @@ export function PlanApprovalDialog({
const [isEditMode, setIsEditMode] = useState(false);
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState("");
const [rejectFeedback, setRejectFeedback] = useState('');
// Reset state when dialog opens or plan content changes
useEffect(() => {
@@ -48,7 +48,7 @@ export function PlanApprovalDialog({
setEditedPlan(planContent);
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback("");
setRejectFeedback('');
}
}, [open, planContent]);
@@ -68,7 +68,7 @@ export function PlanApprovalDialog({
const handleCancelReject = () => {
setShowRejectFeedback(false);
setRejectFeedback("");
setRejectFeedback('');
};
const handleClose = (open: boolean) => {
@@ -79,20 +79,17 @@ export function PlanApprovalDialog({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl"
data-testid="plan-approval-dialog"
>
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
<DialogHeader>
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
<DialogDescription>
{viewOnly
? "View the generated plan for this feature."
: "Review the generated plan before implementation begins."}
? 'View the generated plan for this feature.'
: 'Review the generated plan before implementation begins.'}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? "..." : ""}
{feature.description.length > 150 ? '...' : ''}
</span>
)}
</DialogDescription>
@@ -103,7 +100,7 @@ export function PlanApprovalDialog({
{!viewOnly && (
<div className="flex items-center justify-between mb-3">
<Label className="text-sm text-muted-foreground">
{isEditMode ? "Edit Mode" : "View Mode"}
{isEditMode ? 'Edit Mode' : 'View Mode'}
</Label>
<Button
variant="outline"
@@ -138,7 +135,7 @@ export function PlanApprovalDialog({
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || "No plan content available."}</Markdown>
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
</div>
)}
</div>
@@ -169,33 +166,21 @@ export function PlanApprovalDialog({
</Button>
) : showRejectFeedback ? (
<>
<Button
variant="ghost"
onClick={handleCancelReject}
disabled={isLoading}
>
<Button variant="ghost" onClick={handleCancelReject} disabled={isLoading}>
Back
</Button>
<Button
variant="secondary"
onClick={handleReject}
disabled={isLoading}
>
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
{rejectFeedback.trim() ? 'Revise Plan' : 'Cancel Feature'}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleReject}
disabled={isLoading}
>
<Button variant="outline" onClick={handleReject} disabled={isLoading}>
<RefreshCw className="w-4 h-4 mr-2" />
Request Changes
</Button>

View File

@@ -1,10 +1,10 @@
export { useBoardFeatures } from "./use-board-features";
export { useBoardDragDrop } from "./use-board-drag-drop";
export { useBoardActions } from "./use-board-actions";
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
export { useBoardColumnFeatures } from "./use-board-column-features";
export { useBoardEffects } from "./use-board-effects";
export { useBoardBackground } from "./use-board-background";
export { useBoardPersistence } from "./use-board-persistence";
export { useFollowUpState } from "./use-follow-up-state";
export { useSuggestionsState } from "./use-suggestions-state";
export { useBoardFeatures } from './use-board-features';
export { useBoardDragDrop } from './use-board-drag-drop';
export { useBoardActions } from './use-board-actions';
export { useBoardKeyboardShortcuts } from './use-board-keyboard-shortcuts';
export { useBoardColumnFeatures } from './use-board-column-features';
export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSuggestionsState } from './use-suggestions-state';

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback } from 'react';
import {
Feature,
FeatureImage,
@@ -6,13 +6,13 @@ import {
ThinkingLevel,
PlanningMode,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { truncateDescription } from "@/lib/utils";
import { getBlockingDependencies } from "@automaker/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;
@@ -113,14 +110,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 && 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
@@ -135,29 +129,27 @@ 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',
});
}
}
// Check if we need to generate a title
const needsTitleGeneration =
!featureData.title.trim() && featureData.description.trim();
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
title: featureData.title,
titleGenerating: needsTitleGeneration,
status: "backlog" as const,
status: 'backlog' as const,
branchName: finalBranchName,
};
const createdFeature = addFeature(newFeatureData);
@@ -187,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);
@@ -235,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
@@ -252,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',
});
}
}
@@ -300,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.',
});
}
}
@@ -321,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);
}
}
@@ -348,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;
}
@@ -362,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();
}
},
@@ -380,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;
@@ -397,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;
},
@@ -433,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();
}
},
@@ -463,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;
}
@@ -474,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();
}
},
@@ -489,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]
@@ -506,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]
@@ -523,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 () => {
@@ -543,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,
};
@@ -560,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);
@@ -581,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();
});
@@ -610,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;
}
@@ -626,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(
@@ -667,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',
});
}
},
@@ -707,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)}`,
});
},
@@ -722,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]
@@ -746,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) {
@@ -759,12 +707,7 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
);
const handleForceStopFeature = useCallback(
@@ -773,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);
@@ -783,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',
});
}
},
@@ -807,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))
);
}
@@ -833,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;
}
@@ -847,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;
}
@@ -882,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;
}
@@ -904,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);
@@ -912,30 +845,21 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before archive:",
error
);
console.error('[Board] Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed
const updates = {
status: "completed" as const,
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
toast.success("All verified features archived", {
toast.success('All verified features archived', {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
updateFeature,
persistFeatureUpdate,
]);
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
return {
handleAddFeature,

View File

@@ -1,20 +1,17 @@
import { useMemo } from "react";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) {
const boardBackgroundByProject = useAppStore(
(state) => state.boardBackgroundByProject
);
const boardBackgroundByProject = useAppStore((state) => state.boardBackgroundByProject);
// Get background settings for current project
const backgroundSettings = useMemo(() => {
return (
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings
);
}, [currentProject, boardBackgroundByProject]);
@@ -26,17 +23,15 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || "http://localhost:3008"
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${
backgroundSettings.imageVersion
? `&v=${backgroundSettings.imageVersion}`
: ""
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
} as React.CSSProperties;
}, [backgroundSettings, currentProject]);

View File

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

View File

@@ -1,18 +1,15 @@
import { useState, useCallback } from "react";
import { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants";
import { useState, useCallback } from 'react';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { Feature } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { COLUMNS, ColumnId } from '../constants';
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
runningAutoTasks: string[];
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
}
@@ -63,12 +60,10 @@ export function useBoardDragDrop({
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - in_progress items can be dragged (but not if they're currently running)
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
if (draggedFeature.status === "in_progress") {
if (draggedFeature.status === 'in_progress') {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
console.log(
"[Board] Cannot drag feature - currently running"
);
console.log('[Board] Cannot drag feature - currently running');
return;
}
}
@@ -94,9 +89,9 @@ export function useBoardDragDrop({
// Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
if (draggedFeature.status === 'backlog') {
// From backlog
if (targetStatus === "in_progress") {
if (targetStatus === 'in_progress') {
// Use helper function to handle concurrency check and start implementation
// Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature);
@@ -104,122 +99,110 @@ export function useBoardDragDrop({
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === "waiting_approval") {
} else if (draggedFeature.status === 'waiting_approval') {
// waiting_approval features can be dragged to verified for manual verification
// NOTE: This check must come BEFORE skipTests check because waiting_approval
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
if (targetStatus === 'verified') {
moveFeature(featureId, 'verified');
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: "verified",
status: 'verified',
justFinishedAt: undefined,
});
toast.success("Feature verified", {
toast.success('Feature verified', {
description: `Manually verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
moveFeature(featureId, 'backlog');
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
status: 'backlog',
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", {
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.status === "in_progress") {
} else if (draggedFeature.status === 'in_progress') {
// Handle in_progress features being moved
if (targetStatus === "backlog") {
if (targetStatus === 'backlog') {
// Allow moving in_progress cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (
targetStatus === "verified" &&
draggedFeature.skipTests
) {
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
// Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
moveFeature(featureId, 'verified');
persistFeatureUpdate(featureId, { status: 'verified' });
toast.success('Feature verified', {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between verified and waiting_approval
if (
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
} else if (draggedFeature.status === "verified") {
} else if (draggedFeature.status === 'verified') {
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === "waiting_approval") {
if (targetStatus === 'waiting_approval') {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
} else if (targetStatus === "backlog") {
} else if (targetStatus === 'backlog') {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
)}${draggedFeature.description.length > 50 ? '...' : ''}`,
});
}
}
},
[
features,
runningAutoTasks,
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
]
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
);
return {

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useEffect } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
@@ -43,11 +43,11 @@ export function useBoardEffects({
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === "suggestions_complete" && event.suggestions) {
if (event.type === 'suggestions_complete' && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === "suggestions_error") {
} else if (event.type === 'suggestions_error') {
setIsGeneratingSuggestions(false);
}
});
@@ -64,9 +64,9 @@ export function useBoardEffects({
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
'[BoardView] Spec regeneration event:',
event.type,
"for project:",
'for project:',
event.projectPath
);
@@ -74,9 +74,9 @@ export function useBoardEffects({
return;
}
if (event.type === "spec_regeneration_complete") {
if (event.type === 'spec_regeneration_complete') {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
}
});
@@ -101,10 +101,7 @@ export function useBoardEffects({
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log(
"[Board] Syncing running tasks from backend:",
status.runningFeatures
);
console.log('[Board] Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
@@ -114,7 +111,7 @@ export function useBoardEffects({
}
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
console.error('[Board] Failed to sync running tasks:', error);
}
};
@@ -126,9 +123,7 @@ export function useBoardEffects({
const checkAllContexts = async () => {
const featuresWithPotentialContext = features.filter(
(f) =>
f.status === "in_progress" ||
f.status === "waiting_approval" ||
f.status === "verified"
f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified'
);
const contextChecks = await Promise.all(
featuresWithPotentialContext.map(async (f) => ({

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
interface UseBoardFeaturesProps {
currentProject: { path: string; id: string } | null;
@@ -24,8 +24,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch =
previousPath !== null && currentPath !== previousPath;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
@@ -33,9 +32,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
console.log(
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
);
console.log(`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
@@ -51,7 +48,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
// Keep cached features if API is unavailable
return;
}
@@ -59,17 +56,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map(
(f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
})
);
const featuresWithIds = result.features.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || 'backlog',
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || 'opus',
thinkingLevel: f.thinkingLevel || 'none',
}));
// Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
@@ -78,7 +73,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} else if (!result.success && result.error) {
console.error("[BoardView] API returned error:", result.error);
console.error('[BoardView] API returned error:', result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
@@ -88,7 +83,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Otherwise keep cached features
}
} catch (error) {
console.error("Failed to load features:", error);
console.error('Failed to load features:', error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
@@ -108,9 +103,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/categories.json`
);
const result = await api.readFile(`${currentProject.path}/.automaker/categories.json`);
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
@@ -122,7 +115,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} catch (error) {
console.error("Failed to load categories:", error);
console.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
@@ -154,7 +147,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(categories);
}
} catch (error) {
console.error("Failed to save category:", error);
console.error('Failed to save category:', error);
}
},
[currentProject, persistedCategories]
@@ -168,13 +161,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === "spec_regeneration_complete" &&
event.type === 'spec_regeneration_complete' &&
currentProject &&
event.projectPath === currentProject.path
) {
console.log(
"[BoardView] Spec regeneration complete, refreshing features"
);
console.log('[BoardView] Spec regeneration complete, refreshing features');
loadFeatures();
}
});
@@ -195,32 +186,26 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId =
("projectId" in event && event.projectId) || projectId;
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
console.log('[Board] Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio("/sounds/ding.mp3");
audio
.play()
.catch((err) => console.warn("Could not play ding sound:", err));
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => console.warn('Could not play ding sound:', err));
}
} else if (event.type === "plan_approval_required") {
} else if (event.type === 'plan_approval_required') {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
console.log("[Board] Plan approval required, reloading features...");
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === "auto_mode_error") {
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(
"[Board] Feature error, reloading features...",
event.error
);
console.log('[Board] Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {
@@ -231,20 +216,20 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Check for authentication errors and show a more helpful message
const isAuthError =
event.errorType === "authentication" ||
event.errorType === 'authentication' ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
(event.error.includes('Authentication failed') ||
event.error.includes('Invalid API key')));
if (isAuthError) {
toast.error("Authentication Failed", {
toast.error('Authentication Failed', {
description:
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
duration: 10000,
});
} else {
toast.error("Agent encountered an error", {
description: event.error || "Check the logs for details",
toast.error('Agent encountered an error', {
description: event.error || 'Check the logs for details',
});
}
}

View File

@@ -1,10 +1,10 @@
import { useMemo, useRef, useEffect } from "react";
import { useMemo, useRef, useEffect } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { Feature } from "@/store/app-store";
} from '@/hooks/use-keyboard-shortcuts';
import { Feature } from '@/store/app-store';
interface UseBoardKeyboardShortcutsProps {
features: Feature[];
@@ -27,7 +27,7 @@ export function useBoardKeyboardShortcuts({
const inProgressFeaturesForShortcuts = useMemo(() => {
return features.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
return isRunning || f.status === 'in_progress';
});
}, [features, runningAutoTasks]);
@@ -45,19 +45,19 @@ export function useBoardKeyboardShortcuts({
{
key: shortcuts.addFeature,
action: onAddFeature,
description: "Add new feature",
description: 'Add new feature',
},
{
key: shortcuts.startNext,
action: () => startNextFeaturesRef.current(),
description: "Start next features from backlog",
description: 'Start next features from backlog',
},
];
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
// Keys 1-9 for first 9 cards, 0 for 10th card
const key = index === 9 ? "0" : String(index + 1);
const key = index === 9 ? '0' : String(index + 1);
shortcutsList.push({
key,
action: () => {

View File

@@ -1,15 +1,13 @@
import { useCallback } from "react";
import { Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useCallback } from 'react';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseBoardPersistenceProps {
currentProject: { path: string; id: string } | null;
}
export function useBoardPersistence({
currentProject,
}: UseBoardPersistenceProps) {
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
// Persist feature update to API (replaces saveFeatures)
@@ -20,20 +18,16 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
const result = await api.features.update(
currentProject.path,
featureId,
updates
);
const result = await api.features.update(currentProject.path, featureId, updates);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature update:", error);
console.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
@@ -47,7 +41,7 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
@@ -56,7 +50,7 @@ export function useBoardPersistence({
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature creation:", error);
console.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
@@ -70,13 +64,13 @@ export function useBoardPersistence({
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
console.error('[BoardView] Features API not available');
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error("Failed to persist feature deletion:", error);
console.error('Failed to persist feature deletion:', error);
}
},
[currentProject]

View File

@@ -1,32 +1,35 @@
import { useState, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { useState, useCallback } from 'react';
import { Feature } from '@/store/app-store';
import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState("");
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt("");
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
}, []);
const handleFollowUpDialogChange = useCallback((open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
}, [resetFollowUpState]);
const handleFollowUpDialogChange = useCallback(
(open: boolean) => {
if (!open) {
resetFollowUpState();
} else {
setShowFollowUpDialog(open);
}
},
[resetFollowUpState]
);
return {
// State

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import type { FeatureSuggestion } from "@/lib/electron";
import { useState, useCallback } from 'react';
import type { FeatureSuggestion } from '@/lib/electron';
export function useSuggestionsState() {
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);

View File

@@ -3,7 +3,6 @@ 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';

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { Label } from "@/components/ui/label";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from '@/components/ui/label';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
interface BranchSelectorProps {
useCurrentBranch: boolean;
@@ -25,7 +25,7 @@ export function BranchSelector({
branchCardCounts,
currentBranch,
disabled = false,
testIdPrefix = "branch",
testIdPrefix = 'branch',
}: BranchSelectorProps) {
// Validate: if "other branch" is selected, branch name is required
const isBranchRequired = !useCurrentBranch;
@@ -35,32 +35,22 @@ export function BranchSelector({
<div className="space-y-2">
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
value={useCurrentBranch ? 'current' : 'other'}
onValueChange={(value: string) => onUseCurrentBranchChange(value === 'current')}
disabled={disabled}
data-testid={`${testIdPrefix}-radio-group`}
aria-labelledby={`${testIdPrefix}-label`}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
<Label
htmlFor={`${testIdPrefix}-current`}
className="font-normal cursor-pointer"
>
<Label htmlFor={`${testIdPrefix}-current`} className="font-normal cursor-pointer">
Use current selected branch
{currentBranch && (
<span className="text-muted-foreground ml-1">
({currentBranch})
</span>
)}
{currentBranch && <span className="text-muted-foreground ml-1">({currentBranch})</span>}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
<Label
htmlFor={`${testIdPrefix}-other`}
className="font-normal cursor-pointer"
>
<Label htmlFor={`${testIdPrefix}-other`} className="font-normal cursor-pointer">
Other branch
</Label>
</div>
@@ -91,11 +81,10 @@ export function BranchSelector({
) : (
<p className="text-xs text-muted-foreground">
{useCurrentBranch
? "Work will be done in the currently selected branch. A worktree will be created if needed."
: "Work will be done in this branch. A worktree will be created if needed."}
? 'Work will be done in the currently selected branch. A worktree will be created if needed.'
: 'Work will be done in this branch. A worktree will be created if needed.'}
</p>
)}
</div>
);
}

View File

@@ -1,8 +1,8 @@
export * from "./model-constants";
export * from "./model-selector";
export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
export * from "./branch-selector";
export * from "./planning-mode-selector";
export * from './model-constants';
export * from './model-selector';
export * from './thinking-level-selector';
export * from './profile-quick-select';
export * from './testing-tab-content';
export * from './priority-selector';
export * from './branch-selector';
export * from './planning-mode-selector';

View File

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

View File

@@ -1,9 +1,8 @@
import { Label } from "@/components/ui/label";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { AgentModel } from "@/store/app-store";
import { CLAUDE_MODELS, ModelOption } from "./model-constants";
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel } from '@/store/app-store';
import { CLAUDE_MODELS, ModelOption } from './model-constants';
interface ModelSelectorProps {
selectedModel: AgentModel;
@@ -14,7 +13,7 @@ interface ModelSelectorProps {
export function ModelSelector({
selectedModel,
onModelSelect,
testIdPrefix = "model-select",
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
return (
<div className="space-y-3">
@@ -30,7 +29,7 @@ export function ModelSelector({
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
const shortName = option.label.replace("Claude ", "");
const shortName = option.label.replace('Claude ', '');
return (
<button
key={option.id}
@@ -38,10 +37,10 @@ export function ModelSelector({
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
'flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
isSelected
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>

View File

@@ -1,20 +1,27 @@
"use client";
'use client';
import { useState } from "react";
import { useState } from 'react';
import {
Zap, ClipboardList, FileText, ScrollText,
Loader2, Check, Eye, RefreshCw, Sparkles
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { PlanSpec } from "@/store/app-store";
Zap,
ClipboardList,
FileText,
ScrollText,
Loader2,
Check,
Eye,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { PlanSpec } from '@/store/app-store';
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// Re-export for backwards compatibility
export type { ParsedTask, PlanSpec } from "@/store/app-store";
export type { ParsedTask, PlanSpec } from '@/store/app-store';
interface PlanningModeSelectorProps {
mode: PlanningMode;
@@ -90,7 +97,7 @@ export function PlanningModeSelector({
compact = false,
}: PlanningModeSelectorProps) {
const [showPreview, setShowPreview] = useState(false);
const selectedMode = modes.find(m => m.value === mode);
const selectedMode = modes.find((m) => m.value === mode);
const requiresApproval = mode === 'spec' || mode === 'full';
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
const hasSpec = planSpec && planSpec.content;
@@ -100,11 +107,13 @@ export function PlanningModeSelector({
{/* Header with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
selectedMode?.bgColor || "bg-muted"
)}>
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center',
selectedMode?.bgColor || 'bg-muted'
)}
>
{selectedMode && <selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />}
</div>
<div>
<Label className="text-sm font-medium">Planning Mode</Label>
@@ -117,12 +126,7 @@ export function PlanningModeSelector({
{/* Quick action buttons when spec/full mode */}
{requiresApproval && hasSpec && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onViewSpec}
className="h-7 px-2"
>
<Button variant="ghost" size="sm" onClick={onViewSpec} className="h-7 px-2">
<Eye className="h-3.5 w-3.5 mr-1" />
View
</Button>
@@ -131,12 +135,7 @@ export function PlanningModeSelector({
</div>
{/* Mode Selection Cards */}
<div
className={cn(
"grid gap-2",
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
)}
>
<div className={cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-4')}>
{modes.map((m) => {
const isSelected = mode === m.value;
const Icon = m.icon;
@@ -147,37 +146,45 @@ export function PlanningModeSelector({
onClick={() => onModeChange(m.value)}
data-testid={`${testIdPrefix}-mode-${m.value}`}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
"border-2 hover:border-primary/50",
'flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200',
'border-2 hover:border-primary/50',
isSelected
? cn("border-primary", m.bgColor)
: "border-border/50 bg-card/50 hover:bg-accent/30"
? cn('border-primary', m.bgColor)
: 'border-border/50 bg-card/50 hover:bg-accent/30'
)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
isSelected ? m.bgColor : "bg-muted"
)}>
<Icon className={cn(
"h-5 w-5 transition-colors",
isSelected ? m.color : "text-muted-foreground"
)} />
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
isSelected ? m.bgColor : 'bg-muted'
)}
>
<Icon
className={cn(
'h-5 w-5 transition-colors',
isSelected ? m.color : 'text-muted-foreground'
)}
/>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<span className={cn(
"font-medium text-sm",
isSelected ? "text-foreground" : "text-muted-foreground"
)}>
<span
className={cn(
'font-medium text-sm',
isSelected ? 'text-foreground' : 'text-muted-foreground'
)}
>
{m.label}
</span>
{m.badge && (
<span className={cn(
"text-[9px] px-1 py-0.5 rounded font-medium",
m.badge === 'Default'
? "bg-emerald-500/15 text-emerald-500"
: "bg-amber-500/15 text-amber-500"
)}>
<span
className={cn(
'text-[9px] px-1 py-0.5 rounded font-medium',
m.badge === 'Default'
? 'bg-emerald-500/15 text-emerald-500'
: 'bg-amber-500/15 text-amber-500'
)}
>
{m.badge === 'Default' ? 'Default' : 'Review'}
</span>
)}
@@ -213,14 +220,16 @@ export function PlanningModeSelector({
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
{requiresApproval && (
<div className={cn(
"rounded-xl border transition-all duration-300",
planSpec?.status === 'approved'
? "border-emerald-500/30 bg-emerald-500/5"
: planSpec?.status === 'generated'
? "border-amber-500/30 bg-amber-500/5"
: "border-border/50 bg-muted/30"
)}>
<div
className={cn(
'rounded-xl border transition-all duration-300',
planSpec?.status === 'approved'
? 'border-emerald-500/30 bg-emerald-500/5'
: planSpec?.status === 'generated'
? 'border-amber-500/30 bg-amber-500/5'
: 'border-border/50 bg-muted/30'
)}
>
<div className="p-4 space-y-3">
{/* Status indicator */}
<div className="flex items-center justify-between">
@@ -228,7 +237,9 @@ export function PlanningModeSelector({
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
<span className="text-sm text-muted-foreground">
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
</span>
</>
) : planSpec?.status === 'approved' ? (
<>
@@ -238,7 +249,9 @@ export function PlanningModeSelector({
) : planSpec?.status === 'generated' ? (
<>
<Eye className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
<span className="text-sm text-amber-500 font-medium">
Spec Ready for Review
</span>
</>
) : (
<>
@@ -293,12 +306,7 @@ export function PlanningModeSelector({
{/* Action buttons when spec is generated */}
{planSpec?.status === 'generated' && (
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
<Button
variant="outline"
size="sm"
onClick={onRejectSpec}
className="flex-1"
>
<Button variant="outline" size="sm" onClick={onRejectSpec} className="flex-1">
Request Changes
</Button>
<Button
@@ -315,12 +323,7 @@ export function PlanningModeSelector({
{/* Regenerate option when approved */}
{planSpec?.status === 'approved' && onGenerateSpec && (
<div className="flex items-center justify-end pt-2 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={onGenerateSpec}
className="h-7"
>
<Button variant="ghost" size="sm" onClick={onGenerateSpec} className="h-7">
<RefreshCw className="h-3.5 w-3.5 mr-1" />
Regenerate
</Button>
@@ -334,7 +337,7 @@ export function PlanningModeSelector({
{!requiresApproval && (
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
{mode === 'skip'
? "The agent will start implementing immediately without creating a plan or spec."
? 'The agent will start implementing immediately without creating a plan or spec.'
: "The agent will create a planning outline before implementing, but won't wait for approval."}
</p>
)}

View File

@@ -1,6 +1,5 @@
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface PrioritySelectorProps {
selectedPriority: number;
@@ -11,7 +10,7 @@ interface PrioritySelectorProps {
export function PrioritySelector({
selectedPriority,
onPrioritySelect,
testIdPrefix = "priority",
testIdPrefix = 'priority',
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
@@ -21,10 +20,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 1
? "bg-red-500/20 text-red-500 border-2 border-red-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-high-button`}
>
@@ -34,10 +33,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 2
? "bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-medium-button`}
>
@@ -47,10 +46,10 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 3
? "bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-low-button`}
>

View File

@@ -1,9 +1,8 @@
import { Label } from "@/components/ui/label";
import { Brain, UserCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { AgentModel, ThinkingLevel, AIProfile } from "@/store/app-store";
import { PROFILE_ICONS } from "./model-constants";
import { Label } from '@/components/ui/label';
import { Brain, UserCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel, ThinkingLevel, AIProfile } from '@/store/app-store';
import { PROFILE_ICONS } from './model-constants';
interface ProfileQuickSelectProps {
profiles: AIProfile[];
@@ -20,7 +19,7 @@ export function ProfileQuickSelect({
selectedModel,
selectedThinkingLevel,
onSelect,
testIdPrefix = "profile-quick-select",
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
@@ -41,36 +40,30 @@ export function ProfileQuickSelect({
</div>
<div className="grid grid-cols-2 gap-2">
{profiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon
? PROFILE_ICONS[profile.icon]
: Brain;
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected =
selectedModel === profile.model &&
selectedThinkingLevel === profile.thinkingLevel;
selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
return (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
'flex items-center gap-2 p-2 rounded-lg border text-left transition-all',
isSelected
? "bg-brand-500/10 border-brand-500 text-foreground"
: "bg-background hover:bg-accent border-input"
? 'bg-brand-500/10 border-brand-500 text-foreground'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${profile.id}`}
>
<div className="w-7 h-7 rounded flex items-center justify-center shrink-0 bg-primary/10">
{IconComponent && (
<IconComponent className="w-4 h-4 text-primary" />
)}
{IconComponent && <IconComponent className="w-4 h-4 text-primary" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}
{profile.thinkingLevel !== "none" &&
` + ${profile.thinkingLevel}`}
{profile.thinkingLevel !== 'none' && ` + ${profile.thinkingLevel}`}
</p>
</div>
</button>
@@ -81,8 +74,8 @@ export function ProfileQuickSelect({
Or customize below.
{showManageLink && onManageLinkClick && (
<>
{" "}
Manage profiles in{" "}
{' '}
Manage profiles in{' '}
<button
type="button"
onClick={onManageLinkClick}

View File

@@ -1,9 +1,8 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Plus } from "lucide-react";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FlaskConical, Plus } from 'lucide-react';
interface TestingTabContentProps {
skipTests: boolean;
@@ -18,9 +17,9 @@ export function TestingTabContent({
onSkipTestsChange,
steps,
onStepsChange,
testIdPrefix = "",
testIdPrefix = '',
}: TestingTabContentProps) {
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : 'skip-tests';
const handleStepChange = (index: number, value: string) => {
const newSteps = [...steps];
@@ -29,7 +28,7 @@ export function TestingTabContent({
};
const handleAddStep = () => {
onStepsChange([...steps, ""]);
onStepsChange([...steps, '']);
};
return (
@@ -39,7 +38,7 @@ export function TestingTabContent({
id={checkboxId}
checked={!skipTests}
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}skip-tests-checkbox`}
/>
<div className="flex items-center gap-2">
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
@@ -49,8 +48,8 @@ export function TestingTabContent({
</div>
</div>
<p className="text-xs text-muted-foreground">
When enabled, this feature will use automated TDD. When disabled, it
will require manual verification.
When enabled, this feature will use automated TDD. When disabled, it will require manual
verification.
</p>
{/* Verification Steps - Only shown when skipTests is enabled */}
@@ -66,14 +65,14 @@ export function TestingTabContent({
value={step}
placeholder={`Verification step ${index + 1}`}
onChange={(e) => handleStepChange(index, e.target.value)}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}feature-step-${index}${testIdPrefix ? '' : '-input'}`}
/>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddStep}
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
data-testid={`${testIdPrefix ? testIdPrefix + '-' : ''}add-step-button`}
>
<Plus className="w-4 h-4 mr-2" />
Add Verification Step

View File

@@ -1,9 +1,8 @@
import { Label } from "@/components/ui/label";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { ThinkingLevel } from "@/store/app-store";
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from "./model-constants";
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ThinkingLevel } from '@/store/app-store';
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants';
interface ThinkingLevelSelectorProps {
selectedLevel: ThinkingLevel;
@@ -14,7 +13,7 @@ interface ThinkingLevelSelectorProps {
export function ThinkingLevelSelector({
selectedLevel,
onLevelSelect,
testIdPrefix = "thinking-level",
testIdPrefix = 'thinking-level',
}: ThinkingLevelSelectorProps) {
return (
<div className="space-y-2 pt-2 border-t border-border">
@@ -29,10 +28,10 @@ export function ThinkingLevelSelector({
type="button"
onClick={() => onLevelSelect(level)}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]',
selectedLevel === level
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${level}`}
>

View File

@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
@@ -8,16 +7,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
RefreshCw,
GitBranchPlus,
Check,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo } from "../types";
} from '@/components/ui/dropdown-menu';
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, BranchInfo } from '../types';
interface BranchSwitchDropdownProps {
worktree: WorktreeInfo;
@@ -49,12 +42,12 @@ export function BranchSwitchDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<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"
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
title="Switch branch"
>
@@ -88,7 +81,7 @@ export function BranchSwitchDropdown({
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
{branchFilter ? 'No matching branches' : 'No branches found'}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
@@ -109,10 +102,7 @@ export function BranchSwitchDropdown({
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onCreateBranch(worktree)} className="text-xs">
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>

View File

@@ -1,3 +1,3 @@
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
export { WorktreeTab } from "./worktree-tab";
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -1,5 +1,4 @@
import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -7,7 +6,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
Trash2,
MoreHorizontal,
@@ -21,9 +20,9 @@ import {
Globe,
MessageSquare,
GitMerge,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -81,12 +80,12 @@ export function WorktreeActionsDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
'h-7 w-7 p-0 rounded-l-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
>
<MoreHorizontal className="w-3 h-3" />
@@ -99,10 +98,7 @@ export function WorktreeActionsDropdown({
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
@@ -122,26 +118,15 @@ export function WorktreeActionsDropdown({
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
<Play className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')} />
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onPull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
<DropdownMenuItem onClick={() => onPull(worktree)} disabled={isPulling} className="text-xs">
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
@@ -153,10 +138,8 @@ export function WorktreeActionsDropdown({
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
@@ -173,10 +156,7 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
className="text-xs"
>
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
@@ -199,7 +179,7 @@ export function WorktreeActionsDropdown({
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, "_blank");
window.open(worktree.pr!.url, '_blank');
}}
className="text-xs"
>
@@ -218,8 +198,8 @@ export function WorktreeActionsDropdown({
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: "", // Will be fetched
body: "", // Will be fetched
author: '', // Will be fetched
body: '', // Will be fetched
comments: [],
reviewComments: [],
};

View File

@@ -1,16 +1,10 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
interface WorktreeTabProps {
worktree: WorktreeInfo;
@@ -91,49 +85,48 @@ 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';
}
};
@@ -142,9 +135,9 @@ export function WorktreeTab({
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",
"cursor-pointer hover:opacity-80 active:opacity-70",
'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
)}
title={`${prLabel} - Click to open`}
@@ -152,25 +145,25 @@ export function WorktreeTab({
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>
</span>
@@ -182,12 +175,12 @@ 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}
@@ -196,9 +189,7 @@ export function WorktreeTab({
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">
@@ -209,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>
@@ -243,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">
@@ -273,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>
@@ -295,13 +294,13 @@ export function WorktreeTab({
{isDevServerRunning && (
<Button
variant={isSelected ? "default" : "outline"}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}

View File

@@ -1,6 +1,6 @@
export { useWorktrees } from "./use-worktrees";
export { useDevServers } from "./use-dev-servers";
export { useBranches } from "./use-branches";
export { useWorktreeActions } from "./use-worktree-actions";
export { useDefaultEditor } from "./use-default-editor";
export { useRunningFeatures } from "./use-running-features";
export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers';
export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions';
export { useDefaultEditor } from './use-default-editor';
export { useRunningFeatures } from './use-running-features';

View File

@@ -1,21 +1,20 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import type { BranchInfo } from "../types";
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo } from '../types';
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const [branchFilter, setBranchFilter] = useState('');
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
console.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
@@ -25,14 +24,14 @@ export function useBranches() {
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
console.error('Failed to fetch branches:', error);
} finally {
setIsLoadingBranches(false);
}
}, []);
const resetBranchFilter = useCallback(() => {
setBranchFilter("");
setBranchFilter('');
}, []);
const filteredBranches = branches.filter((b) =>

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const [defaultEditorName, setDefaultEditorName] = useState<string>('Editor');
const fetchDefaultEditor = useCallback(async () => {
try {
@@ -16,7 +15,7 @@ export function useDefaultEditor() {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
console.error('Failed to fetch default editor:', error);
}
}, []);

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { normalizePath } from "@/lib/utils";
import { toast } from "sonner";
import type { DevServerInfo, WorktreeInfo } from "../types";
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils';
import { toast } from 'sonner';
import type { DevServerInfo, WorktreeInfo } from '../types';
interface UseDevServersOptions {
projectPath: string;
@@ -11,9 +10,7 @@ interface UseDevServersOptions {
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
new Map()
);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
const fetchDevServers = useCallback(async () => {
try {
@@ -30,7 +27,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
console.error('Failed to fetch dev servers:', error);
}
}, []);
@@ -54,7 +51,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
toast.error('Start dev server API not available');
return;
}
@@ -73,11 +70,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
toast.error(result.error || 'Failed to start dev server');
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
console.error('Start dev server failed:', error);
toast.error('Failed to start dev server');
} finally {
setIsStartingDevServer(false);
}
@@ -90,7 +87,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
toast.error('Stop dev server API not available');
return;
}
@@ -103,13 +100,13 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
toast.success(result.result?.message || 'Dev server stopped');
} else {
toast.error(result.error || "Failed to stop dev server");
toast.error(result.error || 'Failed to stop dev server');
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
console.error('Stop dev server failed:', error);
toast.error('Failed to stop dev server');
}
},
[projectPath]
@@ -120,7 +117,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
window.open(serverInfo.url, '_blank');
}
},
[projectPath, runningDevServers]

View File

@@ -1,16 +1,12 @@
import { useCallback } from "react";
import type { WorktreeInfo, FeatureInfo } from "../types";
import { useCallback } from 'react';
import type { WorktreeInfo, FeatureInfo } from '../types';
interface UseRunningFeaturesOptions {
runningFeatureIds: string[];
features: FeatureInfo[];
}
export function useRunningFeatures({
runningFeatureIds,
features,
}: UseRunningFeaturesOptions) {
export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFeaturesOptions) {
const hasRunningFeatures = useCallback(
(worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;

View File

@@ -1,18 +1,14 @@
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { WorktreeInfo } from '../types';
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({
fetchWorktrees,
fetchBranches,
}: UseWorktreeActionsOptions) {
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
@@ -25,7 +21,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
toast.error('Switch branch API not available');
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
@@ -33,11 +29,11 @@ export function useWorktreeActions({
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
console.error('Switch branch failed:', error);
toast.error('Failed to switch branch');
} finally {
setIsSwitching(false);
}
@@ -52,7 +48,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
toast.error('Pull API not available');
return;
}
const result = await api.worktree.pull(worktree.path);
@@ -60,11 +56,11 @@ export function useWorktreeActions({
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
console.error('Pull failed:', error);
toast.error('Failed to pull latest changes');
} finally {
setIsPulling(false);
}
@@ -79,7 +75,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
toast.error('Push API not available');
return;
}
const result = await api.worktree.push(worktree.path);
@@ -88,11 +84,11 @@ export function useWorktreeActions({
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
console.error('Push failed:', error);
toast.error('Failed to push changes');
} finally {
setIsPushing(false);
}
@@ -104,7 +100,7 @@ export function useWorktreeActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
console.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path);
@@ -114,7 +110,7 @@ export function useWorktreeActions({
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
console.error('Open in editor failed:', error);
}
}, []);

View File

@@ -1,9 +1,8 @@
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";
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,34 +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 (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
}
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 {
const fetchWorktrees = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectPath) return;
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(false);
setIsLoading(true);
}
}
}, [projectPath, setWorktreesInStore]);
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);
}
}
},
[projectPath, setWorktreesInStore]
);
useEffect(() => {
fetchWorktrees();
@@ -77,15 +83,16 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
if (worktrees.length > 0) {
const current = currentWorktreeRef.current;
const currentPath = current?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
const currentWorktreeExists =
currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
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);
}
}
@@ -93,11 +100,7 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
},
[projectPath, setCurrentWorktree]
);

View File

@@ -1,8 +1,8 @@
export { WorktreePanel } from "./worktree-panel";
export { WorktreePanel } from './worktree-panel';
export type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
FeatureInfo,
WorktreePanelProps,
} from "./types";
} from './types';

View File

@@ -1,15 +1,9 @@
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 { 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,
@@ -17,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,
@@ -93,7 +87,7 @@ export function WorktreePanel({
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === "true";
return saved === 'true';
});
useEffect(() => {
@@ -102,12 +96,13 @@ export function WorktreePanel({
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
// Periodic interval check (1 second) to detect branch changes on disk
// 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 });
}, 1000);
}, 5000);
return () => {
if (intervalRef.current) {
@@ -130,26 +125,22 @@ 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);
};
const handleBranchDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -169,12 +160,10 @@ export function WorktreePanel({
</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>
@@ -222,12 +211,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
mainWorktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
mainWorktree
)}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
@@ -280,12 +265,8 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
worktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
worktree
)}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
@@ -320,20 +301,14 @@ export function WorktreePanel({
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
) {
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")}
/>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</>