Merge branch 'main' into feature/worktrees

This commit is contained in:
Cody Seibert
2025-12-16 12:14:05 -05:00
51 changed files with 2949 additions and 715 deletions

View File

@@ -0,0 +1,58 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
interface DeleteAllArchivedSessionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
archivedCount: number;
onConfirm: () => void;
}
export function DeleteAllArchivedSessionsDialog({
open,
onOpenChange,
archivedCount,
onConfirm,
}: DeleteAllArchivedSessionsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="delete-all-archived-sessions-dialog">
<DialogHeader>
<DialogTitle>Delete All Archived Sessions</DialogTitle>
<DialogDescription>
Are you sure you want to delete all archived sessions? This action
cannot be undone.
{archivedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{archivedCount} session(s) will be deleted.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
data-testid="confirm-delete-all-archived-sessions"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -27,6 +27,7 @@ import type { SessionListItem } from "@/types/electron";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI } from "@/lib/electron";
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
// Random session name generator
const adjectives = [
@@ -116,6 +117,7 @@ export function SessionManager({
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
@@ -314,6 +316,20 @@ export function SessionManager({
setSessionToDelete(null);
};
// Delete all archived sessions
const handleDeleteAllArchivedSessions = async () => {
const api = getElectronAPI();
if (!api?.sessions) return;
// Delete each archived session
for (const session of archivedSessions) {
await api.sessions.delete(session.id);
}
await loadSessions();
setIsDeleteAllArchivedDialogOpen(false);
};
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
const displayedSessions =
@@ -402,6 +418,22 @@ export function SessionManager({
</div>
)}
{/* Delete All Archived button - shown at the top of archived sessions */}
{activeTab === "archived" && archivedSessions.length > 0 && (
<div className="pb-2 border-b mb-2">
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={() => setIsDeleteAllArchivedDialogOpen(true)}
data-testid="delete-all-archived-sessions-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete All Archived Sessions
</Button>
</div>
)}
{/* Session list */}
{displayedSessions.map((session) => (
<div
@@ -574,6 +606,14 @@ export function SessionManager({
session={sessionToDelete}
onConfirm={confirmDeleteSession}
/>
{/* Delete All Archived Sessions Confirmation Dialog */}
<DeleteAllArchivedSessionsDialog
open={isDeleteAllArchivedDialogOpen}
onOpenChange={setIsDeleteAllArchivedDialogOpen}
archivedCount={archivedSessions.length}
onConfirm={handleDeleteAllArchivedSessions}
/>
</Card>
);
}

View File

@@ -39,11 +39,33 @@ export function CategoryAutocomplete({
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
// Listen for resize events to handle responsive behavior
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [value]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
@@ -57,7 +79,12 @@ export function CategoryAutocomplete({
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<PopoverContent
className="p-0"
style={{
width: Math.max(triggerWidth, 200),
}}
>
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>

View File

@@ -620,6 +620,41 @@ export function GitDiffPanel({
onToggle={() => toggleFile(fileDiff.filePath)}
/>
))}
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
{files.length > 0 && parsedDiffs.length === 0 && (
<div className="space-y-2">
{files.map((file) => (
<div
key={file.path}
className="border border-border rounded-lg overflow-hidden"
>
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
{getFileIcon(file.status)}
<span className="flex-1 text-sm font-mono truncate text-foreground">
{file.path}
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
getStatusBadgeColor(file.status)
)}
>
{getStatusDisplayName(file.status)}
</span>
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === "?" ? (
<span>New file - content preview not available</span>
) : file.status === "D" ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}

View File

@@ -541,6 +541,7 @@ export function BoardView() {
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
allFeatures={hookFeatures}
/>
{/* Agent Output Modal */}

View File

@@ -82,8 +82,8 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
medium: "Med", //
high: "High", //
ultrathink: "Ultra",
};
return labels[level];
@@ -323,6 +323,49 @@ export const KanbanCard = memo(function KanbanCard({
/>
)}
{/* Priority badge */}
{feature.priority && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
"top-2 left-2 min-w-[36px]",
feature.priority === 1 &&
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
feature.priority === 2 &&
"bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50",
feature.priority === 3 &&
"bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
)}
data-testid={`priority-badge-${feature.id}`}
>
P{feature.priority}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<p>
{feature.priority === 1
? "High Priority"
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Category text next to priority badge */}
{feature.priority && (
<div className="absolute top-2 left-[54px] right-12 z-10 flex items-center h-[32px]">
<span className="text-[11px] text-muted-foreground/70 font-medium truncate">
{feature.category}
</span>
</div>
)}
{/* Skip Tests (Manual) indicator badge */}
{feature.skipTests && !feature.error && (
<TooltipProvider delayDuration={200}>
@@ -331,7 +374,7 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
"top-2 left-2",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
)}
data-testid={`skip-tests-badge-${feature.id}`}
@@ -354,7 +397,7 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
"top-2 left-2",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
)}
data-testid={`error-badge-${feature.id}`}
@@ -374,7 +417,11 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
feature.priority
? "top-11 left-2"
: feature.skipTests
? "top-8 left-2"
: "top-2 left-2",
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
"animate-pulse"
)}
@@ -394,9 +441,11 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
feature.priority
? "top-11 left-2"
: feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
@@ -415,7 +464,10 @@ export const KanbanCard = memo(function KanbanCard({
<CardHeader
className={cn(
"p-3 pb-2 block",
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
feature.priority && "pt-12",
!feature.priority &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-10",
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
@@ -606,9 +658,11 @@ export const KanbanCard = memo(function KanbanCard({
)}
</button>
)}
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
{feature.category}
</CardDescription>
{!feature.priority && (
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
{feature.category}
</CardDescription>
)}
</div>
</div>
</CardHeader>
@@ -901,7 +955,7 @@ export const KanbanCard = memo(function KanbanCard({
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="ghost"
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
onClick={(e) => {

View File

@@ -20,7 +20,15 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, FlaskConical } from "lucide-react";
import {
MessageSquare,
Settings2,
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
import {
useAppStore,
@@ -34,7 +42,14 @@ import {
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
} from "../shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface AddFeatureDialogProps {
open: boolean;
@@ -49,6 +64,7 @@ interface AddFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -79,11 +95,19 @@ export function AddFeatureDialog({
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "main",
priority: 2 as number, // Default to medium priority
});
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");
// Get enhancement model from store
const { enhancementModel } = useAppStore();
// Sync skipTests default when dialog opens
useEffect(() => {
@@ -117,6 +141,7 @@ export function AddFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
priority: newFeature.priority,
});
// Reset form
@@ -128,6 +153,7 @@ export function AddFeatureDialog({
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus",
priority: 2,
thinkingLevel: "none",
branchName: "main",
});
@@ -146,6 +172,33 @@ export function AddFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!newFeature.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
newFeature.description,
enhancementMode,
enhancementModel
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
} finally {
setIsEnhancing(false);
}
};
const handleModelSelect = (model: AgentModel) => {
setNewFeature({
...newFeature,
@@ -156,7 +209,10 @@ export function AddFeatureDialog({
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
setNewFeature({
...newFeature,
model,
@@ -210,7 +266,10 @@ export function AddFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -232,6 +291,58 @@ export function AddFeatureDialog({
error={descriptionError}
/>
</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"}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!newFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
@@ -256,13 +367,26 @@ export function AddFeatureDialog({
data-testid="feature-branch-input"
/>
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if needed.
Work will be done in this branch. A worktree will be created if
needed.
</p>
</div>
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) =>
setNewFeature({ ...newFeature, priority })
}
testIdPrefix="priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto">
<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">
@@ -325,16 +449,17 @@ export function AddFeatureDialog({
</TabsContent>
{/* Testing Tab */}
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
steps={newFeature.steps}
onStepsChange={(steps) =>
setNewFeature({ ...newFeature, steps })
}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
</TabsContent>
</Tabs>

View File

@@ -0,0 +1,233 @@
"use client";
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;
onClose: () => void;
feature: Feature | null;
allFeatures: Feature[];
}
export function DependencyTreeDialog({
open,
onClose,
feature,
allFeatures,
}: DependencyTreeDialogProps) {
const [dependencyTree, setDependencyTree] = useState<{
dependencies: Feature[];
dependents: Feature[];
}>({ dependencies: [], dependents: [] });
useEffect(() => {
if (!feature) return;
// Find features this depends on
const dependencies = (feature.dependencies || [])
.map((depId) => allFeatures.find((f) => f.id === depId))
.filter((f): f is Feature => f !== undefined);
// Find features that depend on this one
const dependents = allFeatures.filter((f) =>
f.dependencies?.includes(feature.id)
);
setDependencyTree({ dependencies, dependents });
}, [feature, allFeatures]);
if (!feature) return null;
const getStatusIcon = (status: Feature["status"]) => {
switch (status) {
case "completed":
case "verified":
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
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" />;
}
};
const getPriorityBadge = (priority?: number) => {
if (!priority) return null;
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"
)}
>
P{priority}
</span>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Dependency Tree</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* Current Feature */}
<div className="border-2 border-primary rounded-lg p-4 bg-primary/5">
<div className="flex items-center gap-3 mb-2">
{getStatusIcon(feature.status)}
<h3 className="font-semibold text-sm">Current Feature</h3>
{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>
</div>
{/* Dependencies (what this feature needs) */}
<div>
<div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-sm">
Dependencies ({dependencyTree.dependencies.length})
</h3>
<span className="text-xs text-muted-foreground">
This feature requires:
</span>
</div>
{dependencyTree.dependencies.length === 0 ? (
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
No dependencies - this feature can be started independently
</div>
) : (
<div className="space-y-2">
{dependencyTree.dependencies.map((dep) => (
<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"
)}
>
<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 && "..."}
</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={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"
)}
>
{dep.status.replace(/_/g, " ")}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Dependents (what depends on this feature) */}
<div>
<div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-sm">
Dependents ({dependencyTree.dependents.length})
</h3>
<span className="text-xs text-muted-foreground">
Features blocked by this:
</span>
</div>
{dependencyTree.dependents.length === 0 ? (
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
No dependents - no other features are waiting on this one
</div>
) : (
<div className="space-y-2">
{dependencyTree.dependents.map((dependent) => (
<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 && "..."}
</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={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"
)}
>
{dependent.status.replace(/_/g, " ")}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Warning for incomplete dependencies */}
{dependencyTree.dependencies.some(
(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" />
<div className="text-sm">
<p className="font-medium text-yellow-700 dark:text-yellow-500">
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.
</p>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -20,20 +20,38 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, FlaskConical } from "lucide-react";
import {
MessageSquare,
Settings2,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
import {
Feature,
AgentModel,
ThinkingLevel,
AIProfile,
useAppStore,
} from "@/store/app-store";
import {
ModelSelector,
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
} from "../shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -49,6 +67,7 @@ interface EditFeatureDialogProps {
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
}
) => void;
categorySuggestions: string[];
@@ -56,6 +75,7 @@ interface EditFeatureDialogProps {
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
allFeatures: Feature[];
}
export function EditFeatureDialog({
@@ -67,11 +87,20 @@ export function EditFeatureDialog({
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
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");
const [showDependencyTree, setShowDependencyTree] = useState(false);
// Get enhancement model from store
const { enhancementModel } = useAppStore();
useEffect(() => {
setEditingFeature(feature);
@@ -85,8 +114,10 @@ export function EditFeatureDialog({
if (!editingFeature) return;
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? "none")
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
const updates = {
@@ -98,6 +129,7 @@ export function EditFeatureDialog({
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
priority: editingFeature.priority ?? 2,
};
onUpdate(editingFeature.id, updates);
@@ -123,7 +155,10 @@ export function EditFeatureDialog({
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -132,6 +167,35 @@ export function EditFeatureDialog({
});
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementModel
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
} finally {
setIsEnhancing(false);
}
};
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
if (!editingFeature) {
@@ -180,7 +244,10 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -204,6 +271,58 @@ export function EditFeatureDialog({
data-testid="edit-feature-description"
/>
</div>
<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"}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>
<CategoryAutocomplete
@@ -241,14 +360,30 @@ export function EditFeatureDialog({
)}
{editingFeature.status === "backlog" && (
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if needed.
Work will be done in this branch. A worktree will be created
if needed.
</p>
)}
</div>
{/* Priority Selector */}
<PrioritySelector
selectedPriority={editingFeature.priority ?? 2}
onPrioritySelect={(priority) =>
setEditingFeature({
...editingFeature,
priority,
})
}
testIdPrefix="edit-priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto">
<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">
@@ -314,7 +449,10 @@ export function EditFeatureDialog({
</TabsContent>
{/* Testing Tab */}
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
@@ -328,20 +466,37 @@ export function EditFeatureDialog({
/>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
<DialogFooter className="sm:!justify-between">
<Button
variant="outline"
onClick={() => setShowDependencyTree(true)}
className="gap-2 h-10"
>
Save Changes
</HotkeyButton>
<GitBranch className="w-4 h-4" />
View Dependency Tree
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
>
Save Changes
</HotkeyButton>
</div>
</DialogFooter>
</DialogContent>
<DependencyTreeDialog
open={showDependencyTree}
onClose={() => setShowDependencyTree(false)}
feature={editingFeature}
allFeatures={allFeatures}
/>
</Dialog>
);
}

View File

@@ -239,6 +239,7 @@ export function FeatureSuggestionsDialog({
steps: s.steps,
status: "backlog" as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
// Create each new feature using the features API

View File

@@ -1,5 +1,11 @@
import { useCallback } from "react";
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
import {
Feature,
FeatureImage,
AgentModel,
ThinkingLevel,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
@@ -12,7 +18,10 @@ 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;
@@ -57,7 +66,13 @@ export function useBoardActions({
projectPath,
onWorktreeCreated,
}: UseBoardActionsProps) {
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore();
const {
addFeature,
updateFeature,
removeFeature,
moveFeature,
useWorktrees,
} = useAppStore();
const autoMode = useAutoMode();
/**
@@ -98,9 +113,13 @@ export function useBoardActions({
}
return result.worktree.path;
} else {
console.error("[BoardActions] Failed to create worktree:", result.error);
console.error(
"[BoardActions] Failed to create worktree:",
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "Could not create worktree for this branch.",
description:
result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
@@ -126,6 +145,7 @@ export function useBoardActions({
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
}) => {
const newFeatureData = {
...featureData,
@@ -150,6 +170,7 @@ export function useBoardActions({
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
}
) => {
updateFeature(featureId, updates);
@@ -173,7 +194,9 @@ export function useBoardActions({
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
@@ -191,11 +214,17 @@ 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
);
}
}
@@ -228,7 +257,10 @@ export function useBoardActions({
);
if (result.success) {
console.log("[Board] Feature run started successfully in worktree:", featureWorktreePath || "main");
console.log(
"[Board] Feature run started successfully in worktree:",
featureWorktreePath || "main"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
await loadFeatures();
@@ -276,7 +308,10 @@ export function useBoardActions({
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");
@@ -303,7 +338,10 @@ export function useBoardActions({
return;
}
const result = await api.autoMode.resumeFeature(currentProject.path, feature.id);
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
@@ -327,7 +365,9 @@ export function useBoardActions({
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(feature.description)}`,
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
});
},
[moveFeature, persistFeatureUpdate]
@@ -342,7 +382,9 @@ export function useBoardActions({
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -355,7 +397,12 @@ export function useBoardActions({
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
);
const handleSendFollowUp = useCallback(async () => {
@@ -388,19 +435,28 @@ export function useBoardActions({
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);
// Use the feature's worktreePath to ensure work happens in the correct branch
const featureWorktreePath = followUpFeature.worktreePath;
api.autoMode
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths, featureWorktreePath)
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths,
featureWorktreePath
)
.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",
description:
error instanceof Error ? error.message : "An error occurred",
});
loadFeatures();
});
@@ -428,19 +484,26 @@ export function useBoardActions({
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.",
description:
"This feature is not available in the current version.",
});
return;
}
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
const result = await api.autoMode.commitFeature(currentProject.path, feature.id, feature.worktreePath);
const result = await api.autoMode.commitFeature(
currentProject.path,
feature.id,
feature.worktreePath
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
@@ -454,12 +517,19 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
await loadFeatures();
}
},
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
[
currentProject,
moveFeature,
persistFeatureUpdate,
loadFeatures,
onWorktreeCreated,
]
);
const handleRevertFeature = useCallback(
@@ -471,17 +541,23 @@ export function useBoardActions({
if (!api?.worktree?.revertFeature) {
console.error("Worktree API not available");
toast.error("Revert not available", {
description: "This feature is not available in the current version.",
description:
"This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.revertFeature(currentProject.path, feature.id);
const result = await api.worktree.revertFeature(
currentProject.path,
feature.id
);
if (result.success) {
await loadFeatures();
toast.success("Feature reverted", {
description: `All changes discarded. Moved back to backlog: ${truncateDescription(feature.description)}`,
description: `All changes discarded. Moved back to backlog: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to revert feature:", result.error);
@@ -492,7 +568,8 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error reverting feature:", error);
toast.error("Failed to revert feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -508,17 +585,23 @@ export function useBoardActions({
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.",
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", {
description: `Changes merged to main branch: ${truncateDescription(feature.description)}`,
description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to merge feature:", result.error);
@@ -529,7 +612,8 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -560,7 +644,9 @@ export function useBoardActions({
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -589,7 +675,12 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
);
const handleForceStopFeature = useCallback(
@@ -610,13 +701,18 @@ export function useBoardActions({
toast.success("Agent stopped", {
description:
targetStatus === "waiting_approval"
? `Stopped commit - returned to waiting approval: ${truncateDescription(feature.description)}`
: `Stopped working on: ${truncateDescription(feature.description)}`,
? `Stopped commit - returned to waiting approval: ${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",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -656,9 +752,19 @@ export function useBoardActions({
onWorktreeCreated?.();
// Start the implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...feature, worktreePath: worktreePath || undefined });
await handleStartImplementation({
...feature,
worktreePath: worktreePath || undefined,
});
}
}, [features, runningAutoTasks, handleStartImplementation, getOrCreateWorktreeForFeature, persistFeatureUpdate, onWorktreeCreated]);
}, [
features,
runningAutoTasks,
handleStartImplementation,
getOrCreateWorktreeForFeature,
persistFeatureUpdate,
onWorktreeCreated,
]);
const handleDeleteAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
@@ -669,10 +775,7 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before delete:",
error
);
console.error("[Board] Error stopping feature before delete:", error);
}
}
removeFeature(feature.id);
@@ -682,7 +785,13 @@ export function useBoardActions({
toast.success("All verified features deleted", {
description: `Deleted ${verifiedFeatures.length} feature(s).`,
});
}, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]);
}, [
features,
runningAutoTasks,
autoMode,
removeFeature,
persistFeatureDelete,
]);
return {
handleAddFeature,

View File

@@ -3,3 +3,4 @@ export * from "./model-selector";
export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";

View File

@@ -0,0 +1,63 @@
"use client";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface PrioritySelectorProps {
selectedPriority: number;
onPrioritySelect: (priority: number) => void;
testIdPrefix?: string;
}
export function PrioritySelector({
selectedPriority,
onPrioritySelect,
testIdPrefix = "priority",
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
<Label>Priority</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
"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"
)}
data-testid={`${testIdPrefix}-high-button`}
>
High
</button>
<button
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
"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"
)}
data-testid={`${testIdPrefix}-medium-button`}
>
Medium
</button>
<button
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
"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"
)}
data-testid={`${testIdPrefix}-low-button`}
>
Low
</button>
</div>
</div>
);
}

View File

@@ -2,28 +2,18 @@
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Label } from "@/components/ui/label";
import {
Key,
Palette,
Terminal,
FlaskConical,
Trash2,
Settings2,
Volume2,
VolumeX,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
import { useCliStatus, useSettingsView } from "./settings-view/hooks";
import { NAV_ITEMS } from "./settings-view/config/navigation";
import { SettingsHeader } from "./settings-view/components/settings-header";
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
import { AudioSection } from "./settings-view/audio/audio-section";
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
@@ -33,17 +23,6 @@ import type {
} from "./settings-view/shared/types";
import type { Project as ElectronProject } from "@/lib/electron";
// Navigation items for the side panel
const NAV_ITEMS = [
{ id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
];
export function SettingsView() {
const {
theme,
@@ -91,23 +70,72 @@ export function SettingsView() {
};
// Use CLI status hook
const {
claudeCliStatus,
isCheckingClaudeCli,
handleRefreshClaudeCli,
} = useCliStatus();
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
useCliStatus();
// Use scroll tracking hook
const { activeSection, scrollToSection, scrollContainerRef } =
useScrollTracking({
items: NAV_ITEMS,
filterFn: (item) => item.id !== "danger" || !!currentProject,
initialSection: "api-keys",
});
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
case "claude":
return (
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
);
case "ai-enhancement":
return <AIEnhancementSection />;
case "appearance":
return (
<AppearanceSection
effectiveTheme={effectiveTheme}
currentProject={settingsProject}
onThemeChange={handleSetTheme}
/>
);
case "keyboard":
return (
<KeyboardShortcutsSection
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
/>
);
case "audio":
return (
<AudioSection
muteDoneSound={muteDoneSound}
onMuteDoneSoundChange={setMuteDoneSound}
/>
);
case "defaults":
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
useWorktrees={useWorktrees}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onUseWorktreesChange={setUseWorktrees}
/>
);
case "danger":
return (
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
);
default:
return <ApiKeysSection />;
}
};
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
@@ -118,107 +146,17 @@ export function SettingsView() {
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Sticky Side Navigation */}
{/* Side Navigation - No longer scrolls, just switches views */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeSection}
activeSection={activeView}
currentProject={currentProject}
onNavigate={scrollToSection}
onNavigate={navigateTo}
/>
{/* Scrollable Content */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-6 pb-96">
{/* API Keys Section */}
<ApiKeysSection />
{/* Claude CLI Status Section */}
{claudeCliStatus && (
<ClaudeCliStatus
status={claudeCliStatus}
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
)}
{/* Appearance Section */}
<AppearanceSection
effectiveTheme={effectiveTheme}
currentProject={settingsProject}
onThemeChange={handleSetTheme}
/>
{/* Keyboard Shortcuts Section */}
<KeyboardShortcutsSection
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
/>
{/* Audio Section */}
<div
id="audio"
className="rounded-2xl border border-border/50 bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl shadow-sm shadow-black/5 overflow-hidden scroll-mt-6"
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Volume2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Audio
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure audio and notification settings.
</p>
</div>
<div className="p-6 space-y-4">
{/* Mute Done Sound Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={(checked) =>
setMuteDoneSound(checked === true)
}
className="mt-1"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, disables the &quot;ding&quot; sound that
plays when an agent completes a feature. The feature
will still move to the completed column, but without
audio notification.
</p>
</div>
</div>
</div>
</div>
{/* Feature Defaults Section */}
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
useWorktrees={useWorktrees}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onUseWorktreesChange={setUseWorktrees}
/>
{/* Danger Zone Section - Only show when a project is selected */}
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
/>
</div>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
import { Label } from "@/components/ui/label";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
export function AIEnhancementSection() {
const { enhancementModel, setEnhancementModel } = useAppStore();
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Sparkles className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">AI Enhancement</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Choose the model used when enhancing feature descriptions.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-4">
<Label className="text-foreground font-medium">
Enhancement Model
</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLAUDE_MODELS.map(({ id, label, description, badge }) => {
const isActive = enhancementModel === id;
return (
<button
key={id}
onClick={() => setEnhancementModel(id)}
className={cn(
"group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left",
"transition-all duration-200 ease-out",
isActive
? [
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
"border-2 border-brand-500/40",
"text-foreground",
"shadow-md shadow-brand-500/10",
]
: [
"bg-accent/30 hover:bg-accent/50",
"border border-border/50 hover:border-border",
"text-muted-foreground hover:text-foreground",
"hover:shadow-sm",
],
"hover:scale-[1.02] active:scale-[0.98]"
)}
data-testid={`enhancement-model-${id}`}
>
<div className="flex items-center gap-2 w-full">
<span className={cn(
"font-medium text-sm",
isActive ? "text-foreground" : "group-hover:text-foreground"
)}>
{label}
</span>
{badge && (
<span className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
isActive
? "bg-brand-500/20 text-brand-500"
: "bg-accent text-muted-foreground"
)}>
{badge}
</span>
)}
</div>
<span className="text-xs text-muted-foreground/80">
{description}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { AIEnhancementSection } from "./ai-enhancement-section";

View File

@@ -58,9 +58,8 @@ export function ApiKeysSection() {
return (
<div
id="api-keys"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"

View File

@@ -18,9 +18,8 @@ export function AppearanceSection({
}: AppearanceSectionProps) {
return (
<div
id="appearance"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"

View File

@@ -0,0 +1,64 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
interface AudioSectionProps {
muteDoneSound: boolean;
onMuteDoneSoundChange: (value: boolean) => void;
}
export function AudioSection({
muteDoneSound,
onMuteDoneSoundChange,
}: AudioSectionProps) {
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Volume2 className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Audio
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure audio and notification settings.
</p>
</div>
<div className="p-6 space-y-4">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="mute-done-sound"
checked={muteDoneSound}
onCheckedChange={onMuteDoneSoundChange}
className="mt-1"
data-testid="mute-done-sound-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="mute-done-sound"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<VolumeX className="w-4 h-4 text-brand-500" />
Mute notification sound when agents complete
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, disables the &quot;ding&quot; sound that plays when
an agent completes a feature. The feature will still move to the
completed column, but without audio notification.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -23,9 +23,8 @@ export function ClaudeCliStatus({
return (
<div
id="claude"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"

View File

@@ -1,12 +1,13 @@
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/electron";
import type { NavigationItem } from "../config/navigation";
import type { SettingsViewId } from "../hooks/use-settings-view";
interface SettingsNavigationProps {
navItems: NavigationItem[];
activeSection: string;
activeSection: SettingsViewId;
currentProject: Project | null;
onNavigate: (sectionId: string) => void;
onNavigate: (sectionId: SettingsViewId) => void;
}
export function SettingsNavigation({

View File

@@ -3,14 +3,16 @@ import {
Key,
Terminal,
Palette,
LayoutGrid,
Settings2,
Volume2,
FlaskConical,
Trash2,
Sparkles,
} from "lucide-react";
import type { SettingsViewId } from "../hooks/use-settings-view";
export interface NavigationItem {
id: string;
id: SettingsViewId;
label: string;
icon: LucideIcon;
}
@@ -19,9 +21,10 @@ export interface NavigationItem {
export const NAV_ITEMS: NavigationItem[] = [
{ id: "api-keys", label: "API Keys", icon: Key },
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
];

View File

@@ -16,9 +16,8 @@ export function DangerZoneSection({
return (
<div
id="danger"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"rounded-2xl overflow-hidden",
"border border-destructive/30",
"bg-gradient-to-br from-destructive/5 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-destructive/5"

View File

@@ -22,9 +22,8 @@ export function FeatureDefaultsSection({
}: FeatureDefaultsSectionProps) {
return (
<div
id="defaults"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"

View File

@@ -0,0 +1,2 @@
export { useCliStatus } from "./use-cli-status";
export { useSettingsView, type SettingsViewId } from "./use-settings-view";

View File

@@ -0,0 +1,30 @@
import { useState, useCallback } from "react";
export type SettingsViewId =
| "api-keys"
| "claude"
| "ai-enhancement"
| "appearance"
| "keyboard"
| "audio"
| "defaults"
| "danger";
interface UseSettingsViewOptions {
initialView?: SettingsViewId;
}
export function useSettingsView({
initialView = "api-keys",
}: UseSettingsViewOptions = {}) {
const [activeView, setActiveView] = useState<SettingsViewId>(initialView);
const navigateTo = useCallback((viewId: SettingsViewId) => {
setActiveView(viewId);
}, []);
return {
activeView,
navigateTo,
};
}

View File

@@ -11,9 +11,8 @@ export function KeyboardShortcutsSection({
}: KeyboardShortcutsSectionProps) {
return (
<div
id="keyboard"
className={cn(
"rounded-2xl overflow-hidden scroll-mt-6",
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"